Compare commits

...
Author SHA1 Message Date
saphidandClaude Sonnet 5.5 8fb00b263c fix(tracking): review 7 - rescan on cleanup, non-finite Health values
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:29:56 +10:00
saphidandClaude Sonnet 5.5 4937caf310 fix(tracking): don't call an unworn eye-camera artifact a pulse
Unworn Frame runs gave a steady 'clear' 90-96 BPM. pulse now reads the
proximity sensor and refuses to report when the headset is not worn.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:20:23 +10:00
saphidandClaude Sonnet 5.5 128e7a4c94 fix(tracking): never signal the eye-camera capture; finish cleanup exhaustively
Terminating eyetracking --calib left the DSP eye camera stuck streaming on the
Frame. Capture now lets the bounded run end by itself, deleting images
meanwhile, and only kills as a last resort. Also hardens Ctrl-C cleanup and
heart-check error handling.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:15:24 +10:00
saphidandClaude Opus 5.5 1a439f05c2 fix(tracking): make eye-image cleanup exhaustive and confirm capture ownership
Follow-up review findings: remove() now tries every capture directory and
reports any it could not delete; each cleanup step runs even if an earlier
one fails; the directory the capture tool reports (once its output is
flushed) must match the one we reduced, and is always removed. heart-check
keeps readings with an unrecognised flag and reports unreadable references
without a traceback.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:07:33 +10:00
saphidandClaude Opus 5.5 016d2b8c2d fix(tracking): address independent review of the pulse experiment
- Watch for a second capture directory on every poll; stop and remove every
  directory that appeared, and fail loudly if an eye image can't be deleted.
- Start the worker pool inside the cleanup block.
- Flat patches no longer rank first (zero-power SNR is 0, not infinity).
- Align eyes to the truly nearest frame.
- Keep patch grids as float arrays (a 300 s run no longer needs ~0.4 GB).
- heart-check: tolerate unusual flags, close the Health archive, give a clear
  error when export.xml is missing, and build the lookup index once.

Found by a SWE-2 Max read-only review. Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:59:13 +10:00
saphidandClaude Opus 5.5 42ec68ed51 fix(tracking): reduce eye images while capturing, with a backlog cap
The capture tool's stdout is block-buffered, so waiting for its directory name
left every image on disk until the capture ended (3,554 PNGs in a 20 s run on
the Frame). Detect the new capture directory instead, decode in three worker
processes, and stop the capture if more than 900 images wait. On the Frame a
30 s run now peaks at 7 images on disk.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:44:51 +10:00
saphidandClaude Opus 5.5 fa49fab5bf feat(tracking): experimental eye-camera pulse estimate and heart-rate comparison tool
Adds 'pulse', which captures the IR eye cameras through SteamVR's own
eyetracking --calib mode, reduces each image to patch averages and deletes it
immediately, then estimates pulse from skin brightness. Adds heart-check.py to
show OSC readings live and compare recordings with an Apple Health export or
CSV, and a synthetic Mac BLE strap for relay tests.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:15:05 +10:00
saphid ce9e476ec7 feat(tracking): add local OpenXR OSC and BlueZ heart-rate tools 2026-09-28 22:33:02 +10:00
Alex Southwell dcf9689f64 Merge pull request #11 from saphid/apk-alternatives
Offer older APK versions that fit when Lepton refuses an app
2026-09-28 17:38:35 +10:00
saphidandClaude Opus 5.5 224340edc9 APK alternatives: refresh the catalogue after an install job; pin the test's premise
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:29:29 +10:00
saphidandClaude Opus 5.5 c814cb95d0 Merge main into apk-alternatives: install other versions as background jobs
Main now runs Android installs as background jobs and maps SSH failures to
one offline message. Alternative-version installs go through the same job,
the alternatives dialog waits on it with runJob, and send_error_json keeps
the apk blocker that opens the dialog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:21:55 +10:00
Alex Southwell ff2c4ebfe0 Merge pull request #8 from fbl100/mac-mirror-verified
Mac → Frame mirror: verified, with fixes for scaling, login and the cursor
2026-09-28 17:20:22 +10:00
Alex Southwell 03322d3166 Merge pull request #5 from saphid/iphone-app
iPhone and iPad app: the same features, served from the Frame
2026-09-28 17:20:18 +10:00
Alex Southwell 2d08486278 Merge pull request #4 from saphid/ui-tabs-reliability
Tabs, one offline banner, background installs, drop anywhere
2026-09-28 17:19:46 +10:00
Alex Southwell f780ab2c6a Merge pull request #14 from saphid/site-polish
Website: crop the Tools screenshot and tighten the phone footer
2026-09-28 16:43:21 +10:00
saphidandClaude Opus 5.5 396f2a830a Website: crop the empty half off the Tools screenshot; tighten wrapped footer links
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:39:01 +10:00
Alex Southwell 59761ae015 Merge pull request #12 from saphid/website-kofi
Website: Ko-fi donate buttons and visual fixes
2026-09-28 16:34:18 +10:00
saphidandClaude Opus 5.5 a1fa4ce140 Fix the cross-provider review's findings on APK alternatives
One malformed or unreachable repo no longer hides the others; skip bad
index entries; a refreshed raw index outdates its reduced copy; style the
dialog like the others; validate package ids with PKG_RE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:15:11 +10:00
saphidandClaude Opus 5.5 50405ccf88 Add IzzyOnDroid to APK alternatives; keep the catalogue's index file
Reducing an index no longer deletes apk-catalog/data/index-v2.json, which
the catalogue build reads. Drop the measurement log from docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:30:15 +10:00
saphid 32196b4260 Reduce F-Droid indexes and fetch APK alternatives asynchronously 2026-09-28 14:26:40 +10:00
saphid fbe7ba9575 Find installable APK alternatives in F-Droid main and archive 2026-09-28 14:16:43 +10:00
Frank LevineandClaude Opus 5.5 5e12245932 Streaming doc: Mac → Frame mirror verified; move answered open questions
Tested on Frame BUILD_ID 20260925.6191901 with macOS 27.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:49:12 -04:00
Frank LevineandClaude Opus 5.5 df1810bae3 Add mac-cursor-ring.lua: show the Mac pointer in the VNC mirror
macOS leaves the pointer out of the Screen Sharing framebuffer, and neither Remmina showcursor setting brings it back. A Hammerspoon ring around the pointer is a real window, so it gets mirrored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:49:12 -04:00
Frank LevineandClaude Opus 5.5 3f0e7b198a install-apps: scale the Mac screen profile to fit; warn about the Mac login
Without scale-to-fit a Retina Mac shows 1:1 as a zoomed-in corner. macOS offers Apple auth ahead of plain VNC auth, so Remmina asks for the Mac account login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:49:12 -04:00
saphid 0016d9200c Merge remote-tracking branch 'origin/iphone-app' into iphone-app 2026-09-27 21:27:10 +10:00
saphidandClaude Opus 5.5 fe87a9d826 Keep the page clear of iOS safe areas everywhere; research typing, pointing and mirroring into the Frame
- Header, content, tab bar, status strip, drawer and toasts add the notch,
  Dynamic Island, rounded-corner and home-indicator insets on every side
  (zero on desktops). Phones on their side use the bottom tab bar layout.
- The phone tab bar hides while a text field has focus, instead of riding
  on the keyboard.
- DEBUG hook FRAME_TEST_LANDSCAPE for checking this in the Simulator.
- docs/streaming.md: iPhone mirroring (UxPlay, broadcast extension) and
  keyboard/mouse input (uinput needs no sudo on the Frame, verified).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:26:32 +10:00
Alex Southwell 1b26c4f92c Merge pull request #7 from saphid/fake-frame-tests
Regression tests against a fake Frame, plus a headset smoke test
2026-09-27 21:25:36 +10:00
saphidandClaude Opus 5.5 3db311da13 iPhone app: declare photo saving so Save Image appears; record what was tested
The share sheet only offers Save Image when the app declares
NSPhotoLibraryAddUsageDescription; without it screenshots couldn't be saved to
Photos. docs/iphone.md now lists what was verified against the Frame (Bonjour
discovery, waiting and reconnecting, upload, share sheet, install links,
opening Steam Link) and the two permission prompts iOS shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:49:25 +10:00
saphidandClaude Opus 5.5 e1d138470f Fake Frame on arm64: use Valve's Holo Core image, and show failed builds
The menci/archlinuxarm base failed `pacman -Syu` on GitHub's arm64 runner.
Valve's Holo Core aarch64 preview is the base the Frame's SteamOS is built on,
the iPhone app's frame-container already uses it, and its repos carry every
package the fake needs. A failed image build now reruns with the full log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:16:20 +10:00
saphidandClaude Opus 5.5 c711e136d4 iPhone app: a first screen that says exactly what to do
The app finds the Frame by itself (Valve's _steamos-devkit._tcp Bonjour
service, else the saved address or frame.local on port 22), so setup is two
numbered steps: wake your Frame (Looking… / Found ✓, and after a few seconds
exactly what to check), then the Developer Mode password and Connect. Manual
address and key options sit under Other ways to connect.

A paired Frame that doesn't answer is almost always asleep: instead of an
error, Waiting for your Frame says how to wake it and connects as soon as its
SSH port answers (checked every 3 s; 4 s after the stand-in came back).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:16:14 +10:00
saphidandClaude Opus 5.5 19b9bc2dbe E2E: follow background jobs for Flatpak installs
The tabs UI (#4) runs Flatpak installs as server.start_job jobs, so a failed
install answers 200 with a job id and reports the error on /api/job. The test
now waits for the job instead of expecting an immediate 502.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:43:49 +10:00
saphidandClaude Opus 5.5 b768162139 Smoke test: read Steam's binary compat log with grep -a
On the Frame, compat_log.txt has binary bytes in it, so plain grep only said
"binary file matches" and the x86-64 launch check missed Steam's "is not
installed" line. The fake now writes that line to a compat_log.txt that starts
with a NUL, and the end-to-end test finds it the way the smoke test does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00
saphidandClaude Opus 5.5 56bbac4ddb Smoke test: wait for the evidence a launch needs; check the shortcut sync
A launch that needs the program running kept looking after Steam's
"started" line, rather than failing on it before the process showed up.
Cleanup reads steamos-delete's log, which reports a failed sync but exits
0, and runs it twice to check Steam no longer lists our titles; until then
they stay tracked and the cleanup step fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00
saphidandClaude Opus 5.5 93aebb5019 Fix the review's findings in the test harness
Headset smoke test: drop the paired key from authorized_keys with a
same-mode copy swapped in, so a failed write can't truncate it; check the
throwaway key with no ssh_config or agent; clean up idempotently (tracked
and leftover titles, their json files, Steam's shortcuts via steamos-delete,
and ~/devkit-utils if it wasn't there before), with a failed cleanup a
failed step; count a launch only with fresh evidence (the process, Steam's
log, or the known missing-runtime line), matching with [d]evkit-game so
pgrep doesn't find its own shell. The test programs sleep 10 s.

Fake Frame: log a launch before its reaper can look for it. e2e: kill a
pairing client's process group when a test ends; accept an aarch64 program
running under QEMU on x86 hosts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00
saphidandClaude Opus 5.5 0181071b83 Tests against a fake Frame, and a headset smoke test
tests/fakeframe: a container that stands in for the Frame (Arch Linux, or
Arch Linux ARM on arm64) with sshd, rsync, Valve's steamos-devkit-service
and hooks (vendored unmodified), a fake Steam client for the devkit pipe and
the DevTools port (the app's JavaScript runs in Node against stand-in
SteamClient/appStore objects), stubs for steam, wpctl, flatpak, podman,
Lepton and friends, battery and thermal files under /sys, and fault
switches (fakeframe-ctl): pairing mode, approve/deny/timeout, Steam not
running, headset asleep, sshd off, disk full, runtimes missing. A second
container is the computer running Frame Control.

tests/e2e: 29 tests driving the real ui/server.py, frame_connect.py and
frame_titles.py against it; skipped unless FRAME_E2E=1. scripts/e2e.sh
builds, runs and tears down; CI runs it on ubuntu-24.04-arm.

tests/smoke + scripts/frame-smoke.sh: the core cases against a real Frame,
recorded with its BUILD_ID, cleaning up after itself; --pair to pair a
throwaway key. docs/testing.md describes the layers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00
saphidandClaude Opus 5.5 ca2a991f03 Sideloaded titles: use ids Steam's create-shortcut accepts
On the Frame, Steam refused to register titles whose id had a hyphen
(fc-smoke-exe) with "missing/invalid arguments", and registered the same
program as FCSmokeProbe (headset smoke test, 2026-09-27, BUILD_ID
20260922.6101926). Valve's client only allows ^[A-Za-z_][A-Za-z0-9_.]+$.

title_id now makes ids of letters, digits and _, not starting with a
digit, 2 to 64 long; new installs are checked against that, while titles
already on the Frame are still listed, launched and removed. Steam's error
text is trimmed before it's quoted, and the "install it again with Steam
running" hint only follows a Steam-not-running error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00
saphidandClaude Opus 5.5 10bf18fd50 Document the Frame's recovery images and what we learnt about the device
- docs/recovery-and-images.md: where Valve's Frame images are (not linked from
  the SteamOS download page), file names, sizes and our checksums, the GPT
  layout with exact start sectors, what's in rootfs-A (btrfs, SteamOS 0.3.0
  build 20260922.5152327, users, sudo and sshd config), extracting it, running
  it without the headset, and Valve/Collabora's Holo Core aarch64 preview.
- how-the-frame-works.md: correct the recovery image file names; add verified
  facts on the SSH server, tools on the image (no adb), Lepton instances as
  podman containers, going off the network when asleep, and the battery
  reading at full charge.
- ssh.md: pairing from an iPhone and why devkit RSA pairing doesn't fit it.
- open-questions.md, README.md and the steam-frame skill point to the new pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:25:01 +10:00
saphidandClaude Opus 5.5 d65f7130d5 Test against Valve's own Steam Frame OS from its recovery image
tests/frame-container/frame-image.sh extracts rootfs-A from Valve's Frame
recovery image (steamdeck-images.steamos.cloud/recovery), mounts it read-only
with a throwaway writable layer and starts the image's own sshd, so the iPhone
app can pair with and run its server on the real SteamOS for Frame userland.
Verified: password pairing then key login in the image's sshd log, the power
password check through the image's sudo, status reading SteamOS 0.3.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:59:01 +10:00
saphidandClaude Opus 5.5 b1fa3afd40 Don't retry a pairing failure on returning to the app; README installs the docker client
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:48:43 +10:00
saphidandClaude Opus 5.5 aafd2dbda8 iPhone app: test pairing and power against a Holo Core stand-in
Valve publishes no Steam Frame OS image, so tests/frame-container builds the
Frame's SSH surface on Valve and Collabora's Holo Core aarch64 base: a
steamos user with a password and sudo, OpenSSH with keys and passwords,
Python, and a systemctl that only records requests.

Against it from the Simulator: password pairing, the host-key pin, the power
password check. Found and fixed: a changed host key or a refused login said
"Can't reach the Frame" and retried forever; they now say "Pair with the
Frame again" and offer that. The server's key rejection now says the header
may be wrong, not only missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:44:42 +10:00
saphidandClaude Opus 5.5 db75d1ca71 iPhone app: verified against a Frame from the Simulator
Adds debug-only test hooks (open on a tab, run page JS, leave the tunnel URL in
Caches) used to drive the app in the Simulator, and records what was verified:
all four tabs with live data, capture and 31 fps live video in WKWebView, upload,
install jobs and the power password check through the app's tunnel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:26:23 +10:00
saphidandClaude Opus 5.5 fd3a25434d iOS build: create the derived-files folder before packing the Frame bundle
A clean build (as in CI) hasn't made it yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:48:53 +10:00
saphidandClaude Opus 5.5 14790ac768 Fix the follow-up review's findings
- Pairing saves nothing if it was cancelled while adding the key.
- The ssh stand-in runs under bash: dash refuses job control without a
  terminal, which cost stdin and the process group.
- A server that stops while the tunnel opens fails the attempt (and retries)
  instead of leaving it half-connected.
- The health probe answers within its deadline even when a dead link keeps
  the probe itself waiting.
- A cached version is deleted only if no server runs from it (servers now run
  by absolute path) and it's two weeks unused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:40:17 +10:00
saphidandClaude Opus 5.5 a910f83ac1 Fix the iPhone review's findings
- Cancelling (Change headset, Forget) invalidates the attempt in flight; a
  connection only becomes the app's once every step finished for it.
- A server that stops while the tunnel opens is noticed (exit callbacks are
  synchronised and replayed), and the app isn't left showing a dead page.
- The port is read only once the digits are complete, and range-checked.
- Other versions in ~/.cache/frame-control stay unless untouched for 14 days,
  so a second phone or iPad isn't cut off.
- The key is appended on its own line even if authorized_keys lacks a final
  newline.
- The app checks every 20 s, and on returning to the foreground, that the SSH
  session still answers, and reconnects if not.
- The ssh stand-in runs the command as its own process group and passes a
  TERM on to all of it, so a live-video ffmpeg stops with its stream.
- Touch screens show the library's Play buttons (the rule now follows the
  base one); the failure screen only says it's retrying when it is; the page
  says the app reconnects rather than naming a desktop menu.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:30:40 +10:00
saphidandClaude Opus 5.5 13187e8f6a iPhone and iPad app: the same features, served from the Frame
An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI)
connects with its own SSH key (Citadel), copies the server and helpers to
~/.cache/frame-control/<version> on the Frame once per version, starts
ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the
page through an SSH tunnel. The server exits when the phone disconnects.

Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each
`ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and
phone share one code path. Android display goes through podman exec there, as
the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a
per-session key. Power actions take the Developer Mode password via sudo -S.
--port 0 now prints the port it took.

Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch
screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop
opening in their iOS apps, and a password dialog for power.

App: pairing with the Developer Mode password once (never stored) or with a
key the user adds; host key pinned on first use; plain-language connection
errors with quiet retries; frame-control://install links; alerts and confirms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:18:15 +10:00
saphidandClaude Opus 5.5 0f770dc88a Tabs, one offline banner, background installs, drop anywhere
The page was one 6,800px scroll with nine nav links (hidden below 1150px).
It is now four tabs, Home, Games, Android and Tools, switched with 1-4; old
section links still land on the right tab.

When the Frame can't be reached, the server turns ssh's connection errors into
one plain message (503, offline: true), the page shows a single banner with
Retry and Set Up Connection, retries every 8 s, and reloads every panel when
the Frame answers. Panels say "Waiting for the Frame" instead of raw ssh text.

Flatpak and Android catalogue installs run as background jobs the page polls,
so a slow install no longer holds a request for up to 15 minutes or reports a
false failure; the bottom bar counts running installs.

Files can be dropped anywhere in the window, as the README already said.
Recent reports show the newest five, with Show all. Android display explains
an empty or failed read. A topped-up headset on a charger reads as not
charging rather than "still draining, using 0.0 W".

Fixes a race where the catalogue and reports loads wrote the compat-db
mirror's .tmp file at once and one failed with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 10:23:24 +10:00
117 changed files with 12133 additions and 237 deletions

No files matched your search

+3
View File
@@ -27,6 +27,9 @@ desktop or panels.
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` | | Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` | | Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
| Mac GUI over all of this | `README.md` → Frame Control | `scripts/frame-ui.sh` | | Mac GUI over all of this | `README.md` → Frame Control | `scripts/frame-ui.sh` |
| iPhone/iPad app (server runs on the Frame, `FRAME_LOCAL=1`) | `docs/iphone.md` | `ios/`, `ui/local-bin/ssh` |
| Recovery images, factory reset, boot loops | `docs/recovery-and-images.md`, `docs/how-the-frame-works.md` | `~/Downloads/steam-frame-recovery/` |
| Test without the headset (the Frame OS image's own sshd) | `tests/frame-container/README.md` | `tests/frame-container/frame-image.sh` |
| What's still unverified | `docs/open-questions.md` | — | | What's still unverified | `docs/open-questions.md` | — |
Each script's usage is in its header comment. Read the header rather than Each script's usage is in its header comment. Read the header rather than
+27 -1
View File
@@ -20,6 +20,7 @@ jobs:
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
- name: Script syntax - name: Script syntax
run: | run: |
sh -n ui/local-bin/ssh
for f in scripts/*.sh frame/*/*.sh; do for f in scripts/*.sh frame/*/*.sh; do
case "$(head -n 1 "$f")" in case "$(head -n 1 "$f")" in
*zsh*) zsh -n "$f" ;; *zsh*) zsh -n "$f" ;;
@@ -28,7 +29,7 @@ jobs:
done done
- name: Python compiles - name: Python compiles
run: | run: |
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py ios/scripts/*.py
# Valve's devkit-utils (vendored; run by the Frame's python3). Most have no .py suffix. # Valve's devkit-utils (vendored; run by the Frame's python3). Most have no .py suffix.
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
- name: Server tests - name: Server tests
@@ -59,3 +60,28 @@ jobs:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
- name: Server tests - name: Server tests
run: python -m unittest discover -s tests -v run: python -m unittest discover -s tests -v
# The iPhone app: builds for the Simulator and runs its unit tests.
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Generate the project
run: brew install xcodegen && cd ios && xcodegen generate
- name: Build and test
run: |
cd ios
udid=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for r in d for x in d[r] if x["name"].startswith("iPhone")))')
xcodebuild -project FrameControl.xcodeproj -scheme FrameControl -destination "platform=iOS Simulator,id=$udid" CODE_SIGNING_ALLOWED=NO test
# End-to-end tests against the fake Frame (tests/fakeframe): Arch Linux ARM
# in Docker, on a native arm64 runner like the headset. See docs/testing.md.
e2e:
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
- name: End-to-end tests
run: scripts/e2e.sh
+2 -1
View File
@@ -1,6 +1,7 @@
.DS_Store .DS_Store
__pycache__/ __pycache__/
apk-catalog/data/cache/ apk-catalog/data/cache/
apk-catalog/data/index-v2.json* apk-catalog/data/index-v2*.json*
compat-db/.env.lakebed.server compat-db/.env.lakebed.server
compat-db/.lakebed/ compat-db/.lakebed/
tests/smoke/results/
+9 -1
View File
@@ -16,7 +16,7 @@ See what the headset sees, install games and Android apps, move files and text a
<br> <br>
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900"> <img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
<a id="trailer"></a> <a id="trailer"></a>
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a> <a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
@@ -103,6 +103,9 @@ already ships (sideloading a game copies Valve's own devkit scripts to
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) | | **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) | | **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
**iPhone and iPad:** the same features from your phone, with nothing to install on
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
The app brings its own Python and `adb`; SSH is built into macOS and Windows. The app brings its own Python and `adb`; SSH is built into macOS and Windows.
Google doesn't publish `adb` for arm64 Linux, so that build uses your Google doesn't publish `adb` for arm64 Linux, so that build uses your
distribution's. If you already have `adb`, the app uses yours. distribution's. If you already have `adb`, the app uses yours.
@@ -199,6 +202,10 @@ Frame's software fits together, all checked against a real headset and labelled
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste | | [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build | | [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
| [Eye tracking and heart rate](docs/tracking.md) | Our OpenXR → OSC bridge, BlueZ heart-rate panel and optional local session log; SlimeVR feasibility notes |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Open questions](docs/open-questions.md) | What's still unchecked | | [Open questions](docs/open-questions.md) | What's still unchecked |
<details> <details>
@@ -225,6 +232,7 @@ Frame's software fits together, all checked against a real headset and labelled
```sh ```sh
python3 -m unittest discover -s tests # server tests; no headset needed python3 -m unittest discover -s tests # server tests; no headset needed
scripts/e2e.sh # end-to-end against a fake Frame (Linux with Docker)
cd app && npm install && npm start # run the app from the checkout cd app && npm install && npm start # run the app from the checkout
``` ```
+1
View File
@@ -243,6 +243,7 @@ function fromUi(e) {
} }
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
// frame-control://install links from websites (docs/web-install.md). They can // frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch), // arrive before the window or server exists (macOS open-url on a cold launch),
+2
View File
@@ -2,12 +2,14 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells // 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 // 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). // 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 also receives frame-control://install links (docs/web-install.md): only // 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. // what the link asked for, never an install; the page asks the user first.
const { contextBridge, ipcRenderer, webUtils } = require("electron"); const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", { contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"), readClipboard: () => ipcRenderer.invoke("clipboard:read"),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
onInstallLink: (cb) => { onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link"); ipcRenderer.removeAllListeners("install-link");
+27
View File
@@ -46,6 +46,33 @@ Lepton Development must be installed once. Over SSH,
`ssh frame 'steam steam://install/3056000'` queues it, but the install still `ssh frame 'steam steam://install/3056000'` queues it, but the install still
needs to be confirmed or started in the headset. needs to be confirmed or started in the headset.
## When an app needs a newer Android
Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses
an APK, it shows compatible versions from F-Droid's main and archive repos and
IzzyOnDroid. It shows at most eight version names, newest first, preferring an
arm64-only build, and says how many compatible builds it found in total.
Each index is reduced to its compatible builds once a day and cached (about
16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are
instant.
Choose **Install** to download a listed version, verify its SHA-256 against
the index, and install it as its own app.
You can also inspect a file or look up a package from the command line:
```sh
python3 ui/frame_android.py info some-app.apk
python3 ui/frame_android.py versions some-app.apk
python3 ui/frame_android.py versions org.example.app
```
The search links open APKMirror, APKPure, Uptodown, F-Droid and GitHub. Pick a
version whose minimum is Android 11 or lower and that has an arm64-v8a build
(or no native code). Frame Control does not fetch APKs from those search sites.
Older versions may lack fixes, and being installable does not guarantee an
app will run: see the missing services below. Android may refuse a downgrade
or an update signed by a different publisher; removing the app deletes its data.
## Installed apps disappear when Lepton Development closes (verified 2026-09-25) ## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
Lepton Development runs in a throwaway "dev" context. When it exits for any Lepton Development runs in a throwaway "dev" context. When it exits for any
+9
View File
@@ -17,6 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features ## Features
The window has four 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
counts them while they run.
- **Headset view**: what the lenses show, as SteamVR composites it (the room, - **Headset view**: what the lenses show, as SteamVR composites it (the room,
floating panels, dashboard and controllers). Shows the left eye, like pointing floating panels, dashboard and controllers). Shows the left eye, like pointing
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live** a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
+18 -1
View File
@@ -20,6 +20,15 @@ SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + pane
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000 Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
``` ```
## Tracking additions (verified 2026-09-28)
On SteamOS 0.4.1, build `20260925.6191901`, SteamVR exposes combined gaze
through `XR_EXT_eye_gaze_interaction` in a headless OpenXR 1.0 session. Our
reader obtained valid tracked samples and sent OSC to a configured loopback
receiver. BlueZ LE discovery works; GTK4/GI can render our heart-rate panel.
No BLE strap or SlimeVR trackers were attached. See [tracking](tracking.md)
for the evidence, privacy defaults and untested integration boundaries.
## Facts worth knowing ## Facts worth knowing
| Fact | Where it matters | | Fact | Where it matters |
@@ -51,7 +60,13 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` | | Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame | | **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons | | Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-repair-latest.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-repair-qdl-latest.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, ~4 GB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop | | **SSH server:** OpenSSH 9.7p1. It offers `publickey,password` (keyboard-interactive is off, PAM on) and also asks `userdbctl ssh-authorized-keys` for keys. OpenSSH ≥ 8.8 rejects SHA-1 `ssh-rsa` signatures by default, so a client whose RSA support is SHA-1 only (the Swift library Citadel, for one) can't log in with the RSA key that devkit pairing installs; use ed25519 (**inferred** from OpenSSH defaults). **Verified 2026-09-27**, BUILD_ID 20260922.6101926. | [iphone.md](iphone.md), `ui/frame_connect.py` |
| **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) |
| **Each Lepton instance is a podman container** named `lepton-steamlaunch-<instance id>`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec <container> /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings |
| **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries |
| **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card |
| **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) |
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-<build>.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-<build>.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). | Diagnosing a boot loop | | **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). | Diagnosing a boot loop |
## Debug recipes ## Debug recipes
@@ -79,4 +94,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
- Installing and buying Steam games: [steam-games.md](steam-games.md) - Installing and buying Steam games: [steam-games.md](steam-games.md)
- Remote access from anywhere: [tailscale.md](tailscale.md) - Remote access from anywhere: [tailscale.md](tailscale.md)
- Floating windows in space: [panels.md](panels.md) - Floating windows in space: [panels.md](panels.md)
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
- What's still unverified: [open-questions.md](open-questions.md) - What's still unverified: [open-questions.md](open-questions.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

+109
View File
@@ -0,0 +1,109 @@
# Frame Control for iPhone
The iPhone (and iPad) app does what the desktop app does, from the phone:
headset view and live video, battery and status, screenshots, Steam games,
Android apps and their display settings, sideloading, files, clipboard,
Flatpaks, and power. Source: [`ios/`](../ios).
## How it works
An iPhone can't run Python or `ssh`, but the Frame can. So the app:
1. connects to the Frame over SSH itself (the [Citadel](https://github.com/orlandos-nl/Citadel)
Swift SSH library), with its own ed25519 key from the Keychain;
2. copies Frame Control's server and helpers (`ios/scripts/make_frame_bundle.py`,
under 1 MB) to `~/.cache/frame-control/<version>` on the Frame, once per version;
3. starts `ui/server.py` there with `FRAME_LOCAL=1`. It listens only on the
Frame's own 127.0.0.1, and it stops when the phone disconnects (`--exit-on-eof`);
4. tunnels to it through the SSH session and shows the same page as the desktop
app, in a web view. The page carries a fresh key each session, which the
server requires on every request.
With `FRAME_LOCAL=1`, every `ssh frame COMMAND` the server runs goes to
`ui/local-bin/ssh`, which runs the command on the Frame directly (rsync uses it
as its transport too), so the desktop and phone share one code path. Android
display settings use `podman exec` into each Lepton container instead of adb,
which the Frame doesn't have.
Nothing is left running on the Frame after the phone disconnects; the copied
files stay in `~/.cache/frame-control` (delete it any time).
## Pairing
On the Frame, turn on Developer Mode and set a user password (Steam Settings →
System, then Developer → Set User Password). In the app, enter the headset's
address (`frame.local`, its IP, or its Tailscale name) and that password once.
The app adds its own key to `~/.ssh/authorized_keys` and remembers the Frame's
host key; the password isn't saved. If you already reach the Frame over SSH,
**Or add the key yourself** shows the phone's key to paste into
`authorized_keys`, and connects without a password.
Valve's tap-to-approve devkit pairing isn't used: it only takes RSA keys, and
the Frame's OpenSSH 9.7 rejects the SHA-1 RSA signatures the Swift SSH library
makes.
## What's different on the phone
| Desktop | iPhone |
|---|---|
| Drop files anywhere | Tap **Send to Frame** (or Add a game) and pick files; folders need zipping |
| Screenshots save to `~/Pictures/SteamFrame` | Save opens the share sheet: Save Image puts it in Photos |
| SSH and SFTP open a terminal | They open an app that handles `ssh://` / `sftp://` (Blink Shell, Termius) |
| Steam Link, remote desktop | Open the Steam Link and Windows App apps |
| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password |
| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) |
## Building
```sh
cd ios
xcodegen generate # after changing project.yml
open FrameControl.xcodeproj
```
The build packs the Frame bundle from the checkout, so the phone always runs
the page and server from the same commit. Running on a phone needs your own
signing team in Xcode (Signing & Capabilities).
## Verified
<img src="img/iphone-tabs.jpg" alt="The four tabs in the iPhone app, connected to a Frame" width="900">
In the iOS Simulator (iOS 26.5) against a real Frame, 2026-09-27: the app connected
with its key, copied the bundle over SFTP, started the server on the Frame and
showed all four tabs with live data. In the app's web view, Capture returned a
headset still and Live played H.264 video at 31 fps (WebCodecs works in
WKWebView). Through the app's tunnel: status, games, Steam library, Android apps,
screenshots, a file upload (checked on the Frame), a background install job, and
the power password check (a wrong password is refused). The server on the Frame
exits within seconds of the app closing.
Against Valve's own Steam Frame OS (SteamOS 0.3.0 build 20260922.5152327, the
`rootfs-A` partition of the Frame recovery image, run with its own sshd; see
[tests/frame-container](../tests/frame-container)), and a Holo Core stand-in:
pairing with the password (key added with the right
permissions, host key pinned, password stored nowhere), the power password
check (a wrong or missing password refused; the right one reaches `systemctl`),
a changed host key refused with "Pair with the Frame again", and a wrong
pairing password reported the same way.
Also verified in the Simulator against the Frame (2026-09-27): the setup screen
found the Frame by itself over Bonjour (`frame · 192.168.1.237`); a paired app
waiting for a sleeping Frame connected 4 s after it answered; an upload from the
app's web view landed in `~/Downloads`; the share sheet offers Save Image
(needs `NSPhotoLibraryAddUsageDescription`, now declared); an install link opens
the confirm dialog and downloads nothing until Install; Steam Link without the
app installed opens its App Store page.
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
Sending text to the Frame's clipboard needs the desktop panel open in the
headset, as on the desktop app.
Not yet exercised: Android display changes through podman (no Android app was
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
don't.
+37 -13
View File
@@ -33,14 +33,19 @@ build 20260922.6101926, kernel 6.18, aarch64):
- **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as - **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as
a `--user` Flatpak over SSH and wrote the profile. The desktop's a `--user` Flatpak over SSH and wrote the profile. The desktop's
`XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu. `XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu.
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina The Frame can reach the Mac's Screen Sharing port (5900).
connection itself hasn't been tried in the headset yet (part of 11). - **11.** Answered 2026-09-27 (BUILD_ID 20260925.6191901, macOS 27.0): the
pre-seeded profile connects and shows the Mac in its own panel. It asks for
the Mac account login rather than the VNC password, needs scale-to-fit at
Retina resolutions, and doesn't show the Mac cursor without
`scripts/mac-cursor-ring.lua`. It's usable but noticeably laggy. See
[streaming.md](streaming.md).
- **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id - **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md). created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21. Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
## Check on the headset (in order) ## Check on the headset (in order)
@@ -70,12 +75,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
`ssh frame 'command -v wl-copy xclip rsync flatpak'`. `ssh frame 'command -v wl-copy xclip rsync flatpak'`.
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the 10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
headset's desktop?** Test with `./scripts/install-apps.sh remmina`. headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at 11. ~~**Remmina → macOS Screen Sharing**~~: answered 2026-09-27; see above
Retina resolutions? Is the pre-seeded profile path and [streaming.md](streaming.md).
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina 12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** VNC works but is
actually reads? noticeably laggy, so this is worth trying.
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
VNC is too slow.
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does 13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
it pair with KDE Connect for macOS? it pair with KDE Connect for macOS?
14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need 14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need
@@ -86,8 +89,9 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md). runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
Still open: reaching the Frame from outside the home network, and the service Still open: reaching the Frame from outside the home network, and the service
starting after a reboot. starting after a reboot.
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the 17. **Floating panels in the headset** (see [panels.md](panels.md)): panels
panels from `panel-on-frame.sh` show up, take input, and offer **Float in from `panel-on-frame.sh` show up and take controller input (verified
2026-09-27 with `mac-screen`). Still open: do they offer **Float in
World** / **Move** / **Size**? Do floating positions survive closing and World** / **Move** / **Size**? Do floating positions survive closing and
reopening the app, or a reboot? reopening the app, or a reboot?
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch 18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
@@ -103,12 +107,32 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
the colour-coded test clips play in 3D (red left eye, cyan right) for both the colour-coded test clips play in 3D (red left eye, cyan right) for both
H.264 and H.265? Does the DLNA browser find a server on the Mac? H.264 and H.265? Does the DLNA browser find a server on the Mac?
## Verified 2026-09-27
- **Recovery images exist** for the Frame at
`https://steamdeck-images.steamos.cloud/recovery/`; the root filesystem inside
is btrfs and runs, as a userland, on ARM64 Linux. See
[recovery-and-images.md](recovery-and-images.md).
- **Frame Control's server runs on the Frame itself** (the iPhone app does
this), including headset capture, 31 fps live video and file uploads. See
[iphone.md](iphone.md).
- **Password pairing and `sudo -S`** work against the recovery image's own
sshd and sudo (not yet against the headset, whose password we don't hold).
## Still open (2026-09-27)
- Does `podman exec <lepton container> /system/bin/sh -c 'wm size'` change an
instance's display the way `adb shell wm size` does?
- Can the recovery image, or its kernel, boot in a VM at all?
- Does a real sleep, restart or shut down from the iPhone app work (via
`sudo -S systemctl`)?
- The Mac EDL flashing script in `~/Downloads/steam-frame-recovery/` hasn't
been run against a Frame.
## Unconfirmed claims made in these docs ## Unconfirmed claims made in these docs
- `/home` and `/etc` persist across Frame OS updates. This is inferred from - `/home` and `/etc` persist across Frame OS updates. This is inferred from
Steam Deck behaviour. Steam Deck behaviour.
- The whole Mac → Frame desktop path (VNC → Remmina). Each part is documented
separately, but the combination is untested.
- Steam Remote Play with a Mac as host is broken. That's based on community - Steam Remote Play with a Mac as host is broken. That's based on community
reports, not tested with the Frame. reports, not tested with the Frame.
- `connect.sh --harden`, `serve-bootstrap.sh` and - `connect.sh --harden`, `serve-bootstrap.sh` and
+109
View File
@@ -0,0 +1,109 @@
# Recovery images and OS images for the Frame
Where to get the Steam Frame's operating system, what's inside it, and how to
run it for testing without the headset. For recovering a Frame that won't boot,
see the boot menu and boot-loop entries in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
## Downloads
Valve's SteamOS download page (`store.steampowered.com/steamos/download`)
redirects to the [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227),
which offers the Steam Deck image. The **Steam Frame images are on the same
server** but aren't linked from that page:
**https://steamdeck-images.steamos.cloud/recovery/** (a plain directory
listing, checked 2026-09-27).
| File | Size | Use |
|---|---|---|
| `steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2` (or `.img.zip`) | 3.8 GiB | Write to an 8 GB+ USB-C stick, then **Boot from USB** in the Frame's boot menu |
| `steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz` (or `.zip`) | 3.8 GiB | Flash over a USB-C cable in Qualcomm EDL mode with `flash.sh` (Linux) or `flash.cmd` (Windows), which use [qdl](https://github.com/linux-msm/qdl). **Wipes everything** |
All four are dated 2026-09-22. Everything else there is for the Steam Deck
(`steamdeck-…`, x86-64), which won't run on the Frame. Valve publishes **no
checksums**. These are the SHA-256s of our downloads (2026-09-26), which passed
`bzip2 -t` and `tar -t`:
```
3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676 steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2
d3323bfa8efe9ece1954948421cdf5f705e8942eb50c960e2916d935d1b850ab steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz
```
Our copies, with a Mac EDL flashing script built on qdl (untested), are in
`~/Downloads/steam-frame-recovery/` on the Mac.
## What's inside the USB image
A GPT disk with 512-byte sectors and one A slot (a Frame has A and B slots;
the installer makes the rest). **Verified 2026-09-27** from
`steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2`:
| # | Name | Start sector | Size | Type GUID |
|---|---|---|---|---|
| 1 | `esp` | 34 | 256 MiB | `c12a7328-f81f-11d2-ba4b-00a0c93ec93b` (EFI system) |
| 2 | `efi-A` | 524322 | 64 MiB | `ebd0a0a2-b9e5-4433-87c0-68b6b72699c7` |
| 3 | `rootfs-A` | 655394 | 5120 MiB | `4f68bce3-e8cd-4db1-96e7-fbcaf984b709` |
| 4 | `var-A` | 11141154 | 256 MiB | `4d21b016-b534-45c2-a9fb-5c16e091fd2d` |
| 5 | `home` | 11665442 | 100 MiB | `933ac7e1-2eb4-4f13-b844-0e14e2aef915` |
The partitions start at sector 34, not on MiB boundaries, so compute offsets
from the table (sector × 512), not from rounded sizes. `rootfs-A` is **btrfs**
(label `rootfs-A`, 9.2 GB of files), mounted read-only on the Frame.
Its `/etc/os-release` says `NAME="SteamOS"`, `ID=steamos`, `ID_LIKE=arch`,
`VERSION_CODENAME=holo`; the running system reports version 0.3.0, variant
`vr`, build **20260922.5152327**, which is a different number from the
`5153644` in the file name. Our headset reports build 20260922.6101926.
Inside, it matches a real Frame:
- User `steamos` (uid 1000) is in `wheel` (gid 998), and sudoers has
`%wheel ALL=(ALL) ALL`, so sudo asks for the Developer Mode password.
- `sshd_config` includes `sshd_config.d/*.conf`, uses `.ssh/authorized_keys`
plus `AuthorizedKeysCommand /usr/bin/userdbctl ssh-authorized-keys %u`,
and sets `KbdInteractiveAuthentication no` and `UsePAM yes`. So sshd offers
`publickey,password`, the same as the headset.
- `/usr/bin` has `sshd`, `sudo`, `python3` and `podman`.
Get just the root filesystem without unpacking the whole 5.8 GB image (the
partition's start and size, in sectors, come from the table above):
```sh
bzcat steamframe-oobe-repair-*.img.bz2 | tail -c +$((655394 * 512 + 1)) | head -c $((10485760 * 512)) > rootfs-A.img
```
A Mac can't mount btrfs; a Linux machine or VM can (`mount -o ro -t btrfs`).
## Running it without the headset
The image can't boot in a generic virtual machine: its kernel and bootloader
are built for the Frame's Qualcomm Snapdragon 8 Gen 3 (**inferred**; not
attempted). Its **userland** runs fine on any ARM64 Linux, which covers
anything that talks to the Frame over SSH.
[`tests/frame-container/frame-image.sh`](../tests/frame-container/frame-image.sh)
extracts `rootfs-A`, mounts it read-only with a throwaway writable layer, and
starts the image's own `sshd` on port 2223 (user `steamos`, a test password;
`systemctl` only records requests). On a Mac, run it in Colima's ARM64 VM (see
[tests/frame-container/README.md](../tests/frame-container/README.md)).
**Verified 2026-09-27:** the iPhone app paired with it by password (the image's
sshd logged `Accepted password`, then `Accepted publickey … ED25519`), ran
Frame Control's server on the image's Python, and the image's sudo rejected a
wrong power password and passed the right one to `systemctl`. Without the
Frame's hardware there's no SteamVR, Steam client, battery or Lepton, so those
parts stay untested this way.
## Holo Core aarch64 (Valve and Collabora)
The ARM64 port of Arch Linux that the Frame's SteamOS is built on, published as
a preview in July 2026 ([Collabora's announcement](https://www.collabora.com/news-and-blog/news-and-events/building-an-arch-linux-aarch64-port-for-holo-core.html)).
It's a base system and build environment, not the Frame's OS:
- Source: `https://gitlab.steamos.cloud/holo/holo-core-aarch64-preview`
- Packages: `https://holo-packages.steamos.cloud/holo-core-aarch64-preview/mash-20251118`
- Container: `registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest`
(1.7 GB; `/etc/os-release` says "Holo core Aarch64 port (preview)"; `pacman`
installs OpenSSH 10.2, Python 3.13 and sudo from its repositories. Checked 2026-09-27.)
[`tests/frame-container/Dockerfile`](../tests/frame-container/Dockerfile) builds a
lighter Frame stand-in on it (a `steamos` user with a password and sudo, sshd
with keys and passwords), handy when you don't have the 4 GB image.
+2 -1
View File
@@ -93,7 +93,8 @@ controls to place each panel. See [docs/panels.md](panels.md).
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) | | `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) | | `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
| `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](apks.md)) | | `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](apks.md)) |
| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**: overlays created; in-headset placement not yet checked) | | `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**, including `mac-screen` in the headset) |
| `scripts/mac-cursor-ring.lua` | Mac | Hammerspoon script: a ring around the Mac pointer so it shows in the VNC mirror (**verified**) |
| `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) | | `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) |
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) | | `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) | | `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
+10 -4
View File
@@ -21,7 +21,8 @@ desktop app, which knows where a dropped folder lives; in a plain browser, zip
it.) A dialog shows: it.) A dialog shows:
- **Name**: what Steam shows. Steam uses the title id as the name, so it's - **Name**: what Steam shows. Steam uses the title id as the name, so it's
limited to letters, digits, `_` and `-`; the dialog shows the result. limited to letters, digits and `_`, and can't start with a digit; the
dialog shows the result.
- **Launches**: the program picked to start the game, with the other - **Launches**: the program picked to start the game, with the other
candidates in the list. candidates in the list.
- **Runtime**: picked from the program, see below. Windows programs can switch - **Runtime**: picked from the program, see below. Windows programs can switch
@@ -133,10 +134,15 @@ splits that string is **not checked**.
with the same rule, because `scp -r` would follow a link out of the folder with the same rule, because `scp -r` would follow a link out of the folder
and upload whatever it points at. and upload whatever it points at.
- Installs run one at a time, and Remove is refused while one runs. - Installs run one at a time, and Remove is refused while one runs.
- The title id is limited to `[A-Za-z0-9_-]`, at most 64 characters. Valve's - The title id is limited to letters, digits and `_`, doesn't start with a
scripts pass it to a shell (`steamos-delete` runs `rm -r` on it). Valve's digit (one that would gets `_` in front), and is 2 to 64 characters. That's
what Steam's `create-shortcut` accepts: on the Frame it refused
`fc-smoke-exe` with `missing/invalid arguments` and registered the same
program as `FCSmokeProbe` (2026-09-27, BUILD_ID 20260922.6101926), and
Valve's client only allows `^[A-Za-z_][A-Za-z0-9_.]+$`. Valve's scripts
also pass the id to a shell (`steamos-delete` runs `rm -r` on it). Valve's
reserved sideload names (`steam`, `steamvr`, and their `deckard` forms, reserved sideload names (`steam`, `steamvr`, and their `deckard` forms,
which would replace the Steam client itself) get `-game` added. which would replace the Steam client itself) get `_game` added.
- Nothing needs `sudo`; everything goes to your home folder on the Frame. - Nothing needs `sudo`; everything goes to your home folder on the Frame.
- In the app, a dropped folder is read from its local path by the app's own - In the app, a dropped folder is read from its local path by the app's own
server, which only accepts requests from its own page (see server, which only accepts requests from its own page (see
+12
View File
@@ -102,6 +102,18 @@ started. `curl http://<frame-ip>:32000/properties.json` shows whether the servic
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS `~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
updates (inferred from Deck; the Frame uses the same A/B image scheme). updates (inferred from Deck; the Frame uses the same A/B image scheme).
## From an iPhone or iPad
The iPhone app ([iphone.md](iphone.md)) makes its own ed25519 key and adds it
with the Developer Mode password, once, over a password login; the Frame's sshd
offers `publickey,password` (OpenSSH 9.7p1, keyboard-interactive off). It can't
use the devkit pairing above: that installs an RSA key, and the Swift SSH
library signs RSA only with SHA-1, which OpenSSH 8.8 and later refuse by default.
The app pins the Frame's host key on first use and asks you to pair again if it
changes. **Verified 2026-09-27** against the Frame's recovery image
([recovery-and-images.md](recovery-and-images.md)); on the headset, the add-the-key-yourself
route was used.
## Keeping `sshd` enabled across updates ## Keeping `sshd` enabled across updates
- **Frame**: SSH is tied to the Developer Mode toggle, so it should survive - **Frame**: SSH is tied to the Developer Mode toggle, so it should survive
+67 -15
View File
@@ -1,9 +1,11 @@
# Screen and desktop streaming # Screen and desktop streaming
This covers two directions: This covers three directions, plus input:
- **A. Frame → Mac**: see and control the headset from the Mac. - **A. Frame → Mac**: see and control the headset from the Mac.
- **B. Mac → Frame**: use the Mac's desktop inside the headset. - **B. Mac → Frame**: use the Mac's desktop inside the headset.
- **C. iPhone → Frame**: mirror the phone inside the headset.
- **Input**: type and point in the Frame from the Mac or iPhone.
The confidence labels are the same as in [ssh.md](ssh.md). The confidence labels are the same as in [ssh.md](ssh.md).
@@ -32,7 +34,7 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
| Option | Setup | Confidence | Verdict | | Option | Setup | Confidence | Verdict |
|---|---|---|---| |---|---|---|---|
| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Inferred.** Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Latency is fine for productivity but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). | | **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Verified 2026-09-27** (Frame BUILD_ID 20260925.6191901, macOS 27.0), in its own panel via `panel-on-frame.sh mac-screen`. Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Noticeable lag, even at lower Remmina quality settings on a good 5 GHz link, where neither Wi-Fi nor the Frame's CPU was the bottleneck. Usable for reading and coding, but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
| Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. | | Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. |
| Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. | | Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. |
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. | | Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
@@ -45,20 +47,70 @@ them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
`scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes `scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes
`~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina` on the Frame over `~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina` on the Frame over
SSH. The profile then appears in Remmina's list, and you just click it. You'll SSH. The profile then appears in Remmina's list, and you just click it. It
still be asked for the VNC password in the headset the first time, unless you scales the Mac's desktop to fit the window (`scale=1`, `viewmode=1`). Without
choose to save it. Remmina stores passwords encrypted with a per-install key, that, Remmina shows a Retina Mac's native pixels 1:1, so you see a zoomed-in
so the script doesn't try to write the password. (The Remmina file format is corner. (Verified 2026-09-27.)
standard; the Flatpak data path is inferred.)
## Input and text entry without the virtual keyboard **Expect a Mac login prompt, not the VNC password.** macOS offers Apple's own
authentication (RFB security type 30) ahead of plain VNC auth (type 2), and
Remmina picks it. So Remmina asks for your **Mac account name and login
password**; the "VNC viewers may control screen" password isn't used. To store
the password without typing it in the headset, run on the Frame:
- **A Bluetooth keyboard and mouse** paired to the Frame is the obvious way to ```sh
avoid the virtual keyboard. Road to VR says there are "only a few things printf '%s' "$PASSWORD" | flatpak run org.remmina.Remmina \
you'd actually want to do" on the Linux desktop unless you connect a --update-profile ~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina \
keyboard and mouse. --set-option password
(Pairing a BT keyboard on the Frame is inferred from SteamOS; not verified.) ```
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh` (see
[file-transfer.md](file-transfer.md#clipboard)). Remmina encrypts it into the profile with its own key, because there's no
secret service in the SSH session. (Verified 2026-09-27.)
### The Mac's cursor
The mirror doesn't show the Mac's pointer, with either `showcursor` value.
macOS keeps the pointer out of the picture it sends, and Remmina's cursor mode
draws the cursor shape only at the Frame's own pointer, which doesn't follow
the Mac trackpad. `scripts/mac-cursor-ring.lua` works around this: a
[Hammerspoon](https://www.hammerspoon.org/) script that draws a ring around the
Mac pointer as a real window, so it's part of the mirrored picture. Setup is in
its header. (Verified 2026-09-27.)
Going the other way, pointing a controller at the panel moves the Mac's mouse,
because Remmina forwards input (`viewonly=0`).
## C. Show the iPhone's screen inside the Frame
iOS only shares its screen two ways: **AirPlay** (Screen Mirroring in Control
Centre) or a **ReplayKit broadcast extension** in an app. Nothing else can
capture it.
| Option | What it takes | Confidence | Verdict |
|---|---|---|---|
| **UxPlay** (an open-source AirPlay receiver) on the Frame | Build it for aarch64 (no Flathub package; there's a Snap and distro packages), run it in `~` or a podman container, and advertise it over mDNS. The iPhone *and* the Mac then see "Frame" in Screen Mirroring, with nothing to install on either | **Inferred.** It runs on ARM64 Linux such as the Raspberry Pi ([UxPlay](https://github.com/FDH2/UxPlay)). Not tried on the Frame: needs mDNS registration and its ports (7000, 7001, 7100 and a UDP range) reachable | **Recommended to try first.** It's the only receiver-side option, and it covers the Mac too. The window shows in the Frame's Linux desktop panel |
| A broadcast extension in Frame Control | ReplayKit sends the screen to a small extension (50 MB memory limit), which encodes H.264 and sends it through the app's SSH tunnel to the page, shown the same way as the Frame's live view in reverse | **Inferred** from Apple's ReplayKit docs | Full control and no network setup, but several days' work, and the picture only shows where Frame Control's page is open in the headset |
## Input: type and point in the Frame from the Mac or iPhone
**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
while the desktop panel is open in the headset.
| Option | Mac | iPhone | Notes |
|---|---|---|---|
| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
Other ways to get text in:
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh`, or Frame Control's
clipboard box (see [file-transfer.md](file-transfer.md#clipboard)). Needs
the desktop panel open.
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside - **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
that RDP session. that RDP session.
+190
View File
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frame Control 0.3.1: features by OS</title>
<style>
:root {
--bg: #1b2838; --panel: #16202d; --line: #2a3f5a; --text: #c7d5e0; --dim: #8f98a0;
--tested: #5ba32b; --partial: #d9a33a; --auto: #4b8bbe; --built: #3d4f63; --no: #6b2b2b;
}
* { box-sizing: border-box; }
body { margin: 0; background: linear-gradient(#171a21, var(--bg) 320px); color: var(--text);
font: 15px/1.5 "Motiva Sans", -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 1180px; margin: 0 auto; padding: 40px 24px 80px; }
h1 { color: #fff; font-size: 30px; margin: 0 0 4px; font-weight: 600; }
h2 { color: #fff; font-size: 18px; margin: 40px 0 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: .06em; }
.sub { color: var(--dim); margin: 0 0 28px; }
a { color: #66c0f4; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 16px 18px; }
.card h3 { margin: 0 0 8px; color: #fff; font-size: 16px; }
.card dl { margin: 0; display: grid; grid-template-columns: 76px 1fr; gap: 3px 10px; font-size: 13.5px; }
.card dt { color: var(--dim); }
.card dd { margin: 0; }
.legend { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0 0 14px; font-size: 13.5px; }
.legend span { display: inline-flex; align-items: center; gap: 7px; }
table { width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
th, td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
thead th { background: #0e141b; color: #fff; font-weight: 600; position: sticky; top: 0; z-index: 1; }
thead th.os { width: 150px; text-align: center; }
tr.group td { background: #203044; color: #fff; font-weight: 600; font-size: 13px;
text-transform: uppercase; letter-spacing: .05em; }
td.os { text-align: center; }
td .feat { color: #fff; }
td .note { color: var(--dim); font-size: 13px; }
.pill { display: inline-block; min-width: 92px; padding: 2px 9px; border-radius: 999px;
font-size: 12.5px; font-weight: 600; color: #fff; white-space: nowrap; }
.t { background: var(--tested); }
.p { background: var(--partial); color: #1b1b1b; }
.a { background: var(--auto); }
.b { background: var(--built); color: #c7d5e0; }
.n { background: var(--no); }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
ul { margin: 6px 0 0; padding-left: 20px; }
li { margin: 3px 0; }
footer { color: var(--dim); font-size: 13px; margin-top: 36px; }
</style>
</head>
<body>
<main>
<h1>Frame Control 0.3.1: features by OS</h1>
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
Python 3.12, <code>adb</code> and CA certificates, so nothing else needs installing (only <code>ssh</code> on Linux,
plus the system <code>adb</code> on arm64 Linux).</p>
<h2>Builds and test machines</h2>
<div class="cards">
<div class="card"><h3>macOS</h3><dl>
<dt>Built</dt><dd>Apple Silicon (arm64): <code>.dmg</code>, <code>.zip</code>. No Intel build.</dd>
<dt>Signing</dt><dd>Ad-hoc signed, not notarised</dd>
<dt>Tested on</dt><dd>Apple Silicon Mac, macOS 26, using a local 0.3.1 build with the bundled Python and <code>adb</code>. All 15 calls it made to the Frame at startup returned OK.</dd>
</dl></div>
<div class="card"><h3>Windows</h3><dl>
<dt>Built</dt><dd>x64: NSIS installer <code>.exe</code> and <code>.zip</code></dd>
<dt>Signing</dt><dd>Unsigned. SmartScreen shows a warning.</dd>
<dt>Tested on</dt><dd>Windows 11 x64 VM. Real-Frame results below are from 0.3.0. The 0.3.1 installer from CI installs cleanly (31 s) and reinstalls over itself (38 s). The server starts on the bundled Python, and HTTPS to Steam and F-Droid works. The Frame went offline before its 0.3.1 run on the headset.</dd>
</dl></div>
<div class="card"><h3>Linux</h3><dl>
<dt>Built</dt><dd>x86_64 and arm64: <code>AppImage</code> and <code>.deb</code></dd>
<dt>Signing</dt><dd>n/a</dd>
<dt>Tested on</dt><dd>x86_64 Ubuntu 26.04 with no <code>adb</code> and no clipboard tools, using the 0.3.1 AppImage under Xvfb with the bundled Python and <code>adb</code>. The arm64 builds and the <code>.deb</code> packages weren't run; the arm64 package was only checked to contain an ARM Python.</dd>
</dl></div>
</div>
<h2>Features</h2>
<div class="legend">
<span><i class="dot" style="background:var(--tested)"></i><b>Tested</b>: worked against the real Frame on that OS</span>
<span><i class="dot" style="background:var(--partial)"></i><b>Partial</b>: only part of the feature was tested (see note)</span>
<span><i class="dot" style="background:var(--auto)"></i><b>Automated</b>: covered by CI tests on that OS, not tried on a real Frame</span>
<span><i class="dot" style="background:var(--built)"></i><b>Built</b>: in the build, not tested</span>
<span><i class="dot" style="background:var(--no)"></i><b>Not built</b></span>
</div>
<table>
<thead><tr><th>Feature</th><th class="os">macOS</th><th class="os">Windows</th><th class="os">Linux</th></tr></thead>
<tbody>
<tr class="group"><td colspan="4">Connection</td></tr>
<tr><td><div class="feat">Set Up Connection</div><div class="note">Finds the Frame, writes the <code>frame</code> SSH alias, copies your key using the Frame's password. macOS runs <code>connect.sh</code> in Terminal; Windows and Linux run <code>frame_connect.py</code>.</div></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Existing alias used, script not re-run</div></td>
<td class="os"><span class="pill t">Tested</span></td>
<td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Shared SSH connection</div><div class="note">A single SSH connection is reused, so each request takes about 0.3 s. Windows OpenSSH can't do this, so there each request opens its own connection (about 0.5 to 1 s).</div></td>
<td class="os"><span class="pill t">Tested</span></td>
<td class="os"><span class="pill n">Not built</span><div class="note">OpenSSH limitation</div></td>
<td class="os"><span class="pill t">Tested</span></td></tr>
<tr class="group"><td colspan="4">Headset view</td></tr>
<tr><td><div class="feat">Capture headset view</div><div class="note">The left eye or both eyes as the lenses show them, saved as PNG</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Capture desktop panel</div><div class="note">gamescope's flat layer</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Live view</div><div class="note">720p H.264 at about 30 fps, decoded with WebCodecs</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Headset screenshots</div><div class="note">Browse the screenshots you took with Steam's shortcut, and save them to <code>~/Pictures/SteamFrame</code></div></td>
<td class="os"><span class="pill t">Tested</span></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Listed (5 found); saving not tried</div></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Listed with thumbnails; saving not tried</div></td></tr>
<tr class="group"><td colspan="4">Status</td></tr>
<tr><td><div class="feat">Battery and charging</div><div class="note">Percentage, watts, time to full or empty, charger type, temperature</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">System status</div><div class="note">Storage, memory, temperature, Wi-Fi, uptime, running services</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Volume and mute</div></td>
<td class="os"><span class="pill t">Tested</span></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Read only</div></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Read only</div></td></tr>
<tr class="group"><td colspan="4">Games</td></tr>
<tr><td><div class="feat">Owned games with Frame ratings</div><div class="note">Verified, Playable, Unsupported or Unknown</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Install a game on the Frame</div><div class="note">Uses the headset's Steam client, with live progress</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr><td><div class="feat">Store search, Buy, Store on Frame</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr><td><div class="feat">Library shelf and Play button</div></td>
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr class="group"><td colspan="4">Android apps</td></tr>
<tr><td><div class="feat">Installed Android apps list</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">F-Droid catalogue search</div><div class="note">About 4,500 apps with Frame ratings, bundled with the app</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Install, launch, stop, test, remove an app</div><div class="note">Each app runs as its own Lepton instance, using the bundled <code>adb</code>. APK files are read by a built-in parser (no <code>aapt2</code>) that matched <code>aapt2</code> on 9 F-Droid APKs.</div></td>
<td class="os"><span class="pill t">Tested</span><div class="note">Diary: read, install, launch, test, remove</div></td>
<td class="os"><span class="pill b">Built</span></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Launch and stop</div></td></tr>
<tr><td><div class="feat">Report an APK</div><div class="note">Reports are saved on your computer; the shared database is maintainer-only</div></td>
<td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td></tr>
<tr><td><div class="feat">Android display settings</div><div class="note">Resolution, UI scale, text size</div></td>
<td class="os"><span class="pill t">Tested</span><div class="note">Density and text size set, then reset</div></td>
<td class="os"><span class="pill b">Built</span></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Read over the bundled adb</div></td></tr>
<tr class="group"><td colspan="4">Transfer</td></tr>
<tr><td><div class="feat">Send files to ~/Downloads</div><div class="note">Test files had non-English characters in their names (é, ✓). macOS and Linux copy with rsync; Windows uses scp.</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
<tr><td><div class="feat">Drop an APK to install it</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr><td><div class="feat">Send text or clipboard to the Frame</div><div class="note">Needs the headset desktop open. The app reads your clipboard through Electron, so no extra tools are needed.</div></td>
<td class="os"><span class="pill t">Tested</span><div class="note">Reading the clipboard retested in 0.3.1</div></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Reached the Frame; desktop was closed</div></td>
<td class="os"><span class="pill p">Partial</span><div class="note">Clipboard read with no xclip; Frame desktop was closed</div></td></tr>
<tr><td><div class="feat">Flatpak install and remove</div></td>
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr class="group"><td colspan="4">One-click tools</td></tr>
<tr><td><div class="feat">SSH or SFTP in a terminal</div><div class="note">macOS: Terminal. Windows: cmd. Linux: GNOME Terminal, Konsole, xterm and others.</div></td>
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr><td><div class="feat">Steam Link</div></td>
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr><td><div class="feat">Remote desktop</div><div class="note">macOS: Windows App. Windows: Remote Desktop. Linux: Remmina or FreeRDP.</div></td>
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr><td><div class="feat">Sleep, restart, shut down</div><div class="note">Opens a terminal because SteamOS asks for the sudo password</div></td>
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
<tr class="group"><td colspan="4">App</td></tr>
<tr><td><div class="feat">Local server test suite</div><div class="note">Runs in GitHub Actions on every push (Python 3.12 on macOS and Windows, Python 3.13 on Ubuntu), including the APK reader tests</div></td>
<td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td></tr>
<tr><td><div class="feat">Mac or PC wording</div><div class="note">The UI says Finder or File Explorer, and Mac or PC, to match your system</div></td>
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
</tbody>
</table>
<h2>Notes</h2>
<ul>
<li><b>Tested</b> means the app, running on that OS, got a successful response from the real Frame: for example, a PNG from a capture, 868 owned games, or the Android apps listed.</li>
<li>The Windows VM tests ran in its desktop session. <code>ssh.exe</code> hangs when it's started from a remote SSH session, but a normal desktop user won't hit that.</li>
<li>The macOS test from 25 September also covered the capture shown when the headset is in standby, input validation, and using the clipboard with the headset desktop open.</li>
<li>Everything marked <b>Built</b> runs a command that works on its own. It just hasn't been tried end to end from the app on that OS yet.</li>
</ul>
<footer>Frame Control is an unofficial tool, not made by Valve. MIT licence.</footer>
</main>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
# Testing
Frame Control is tested in three layers, from fast and fake to slow and real.
A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/steam-frame/issues/6)).
| Layer | Runs | Needs | Covers |
|---|---|---|---|
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
## Unit tests
```sh
python3 -m unittest discover -s tests
```
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
## The fake Frame
`tests/fakeframe/` builds a container that stands in for the headset, and a
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
both, starts them with `docker compose`, runs `tests/e2e` in the host
container and takes everything down, exiting with the tests' status:
```sh
scripts/e2e.sh # everything, about 2 minutes plus the first build
scripts/e2e.sh test_titles # one module
scripts/e2e.sh test_faults.Faults.test_disk_full # one test
FAKEFRAME_KEEP=1 scripts/e2e.sh # leave it running afterwards
```
It needs a Linux host with Docker and `docker compose`, and zsh. The images
are `fakeframe-frame` and `fakeframe-host`; the compose project, network and
volumes are `fakeframe-e2e*`. CI runs it on a native arm64 runner
(`ubuntu-24.04-arm`, the `e2e` job in `.github/workflows/checks.yml`).
The host container exists because OpenSSH reads `~/.ssh/config` from the
passwd home directory, not `$HOME`. There, `ssh frame` reaches the fake Frame
through the same `Host frame` block `ui/frame_connect.py` writes, the
repository is mounted read-only at `/repo`, and each test module starts the
real `ui/server.py` (Python 3.9) and talks to it over HTTP with the headers
its guards want.
### What's real and what's fake
| On the fake Frame | |
|---|---|
| Arch Linux (`archlinux:base`, or Valve's Holo Core aarch64 preview on arm64), user `steamos`, `/etc/os-release` with BUILD_ID 20260922.6101926 | Real OS, Frame's identity |
| `sshd` with key and password logins, `rsync`, `python3` | Real |
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
| Valve's devkit-utils, copied over by Frame Control itself | Real |
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
Every fake behaviour copied from the device has a comment citing the doc or
observation and the BUILD_ID it came from; anything not seen on a headset is
marked as a guess. The fake keeps its state in `/var/lib/fakeframe/state.json`
(shortcuts, devkit titles, compat tool mapping, launches, pairing requests,
Lepton instances, volume, Flatpaks, clipboard) and logs stub calls to
`calls.jsonl` beside it.
Native programs really run: a launched aarch64 title executes on an arm64
host, and an x86-64 one on x86-64 (the container shares the host's kernel).
Proton titles are recorded with the command Steam would run, not run.
### Fault switches
`fakeframe-ctl` works over SSH (`ssh frame fakeframe-ctl help`) and from the
host container (`FAKEFRAME_CTL=http://fakeframe:9999`), so a test can flip a
switch while SSH is down:
| Command | Effect |
|---|---|
| `pairing on\|off` | Steam's **Pair new host** screen open or not; off gives the device's 403 text |
| `answer approve\|deny\|timeout` | How the pairing prompt is answered |
| `steam on\|off` | Steam client running (pid file, pipe, DevTools) |
| `sleep on\|off` | Headset asleep: ports 22 and 32000 accept and never answer, so SSH times out |
| `sshd on\|off` | sshd stopped: new connections are refused, open ones stay |
| `devkit-service on\|off` | Port 32000 closed |
| `disk-full on\|off` | Fills the small (64 MB) filesystem on `~/devkit-game` |
| `runtime NAME installed\|missing` | Proton, the Steam Linux Runtimes, Lepton |
| `battery KEY=VALUE...` | e.g. `capacity=15 status=Discharging current_now=-900000` |
| `keys harness\|none`, `authorized-keys` | Set or read `~/.ssh/authorized_keys` |
| `reset`, `state`, `calls [TOOL]` | Start over; read the state and call log |
### What the fake can't show
- Rendering: the headset view, desktop capture content, live video, SteamVR,
gamescope and panels. The capture stub returns a placeholder PNG.
- Proton and FEX: whether a Windows or x86-64 program actually runs.
- Android: there's no Android in the Lepton stand-in, so no ADB, display
settings, probes or app crashes.
- The real Steam client's UI and anything it does that isn't modelled, and
mDNS discovery.
- `sudo` and the power buttons, Tailscale, and the Windows and macOS sides of
the app (the host container is Linux, so the `rsync` paths are tested and the
`scp` fallback isn't).
## Headset smoke test
```sh
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
```
It checks `properties.json` and the status, then installs, launches and
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
the ARM64 program running, the `.exe` started (its process or Steam's log),
and the x86-64 program running or Steam logging that its runtime isn't
installed, which is what the Frame does today. Steam's log lines about each
title are kept.
Everything it installs is removed again, also after a failure: the titles and
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
before (if it was, it stays, synced to this checkout as Frame Control always
does). A cleanup that fails counts as a failed step. Results go to
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
isn't reachable.
`--pair` asks the devkit service to pair a new RSA key, which needs someone
in the headset to open **Settings → Developer → Pair new host** and approve
it; the key is checked and then taken out of `authorized_keys` again.
## When the device disagrees with the fake
The fake is only as good as what's been seen on a headset. When the smoke
test (or anyone) finds the Frame doing something else:
1. Record what the device did, with the date and BUILD_ID, in the doc that
covers it (`docs/sideloading.md`, `docs/ssh.md` and so on).
2. Change the fake to match, with a comment citing that observation. The
behaviours are in `tests/fakeframe/rootfs/usr/local/lib/fakeframe/`
(`fakesteam.py` for Steam, `cef_shim.js` for DevTools, `init.py` for the
switches, the stubs in `rootfs/usr/local/bin`).
3. Run `scripts/e2e.sh`. If the app is wrong, the tests now fail the way the
device did; fix the app and add a unit test.
For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut`
refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
## Tracking protocols and fake BlueZ
`tests/test_tracking.py` exercises our gaze conversion, OSC sender, HRS parser
and BlueZ lifecycle with an in-memory fake object tree. It runs in the normal
unit suite without Bluetooth, GTK or OpenXR. Real Frame results and the absent
strap/tracker boundaries are recorded in [tracking](tracking.md).
+329
View File
@@ -0,0 +1,329 @@
# Eye tracking and heart rate
Frame Control's own tools run on the Frame, using OpenXR and BlueZ. No
VRCFaceTracking, LunaHR, Pulsoid or other tracking app is required. This is a
command-line first version; it does not add a desktop app tab.
## What was checked
**Verified 2026-09-28**, on a real aarch64 Frame running SteamOS 0.4.1,
BUILD_ID `20260925.6191901`:
| Check | Result |
|---|---|
| OpenXR gaze | SteamVR advertises `XR_EXT_eye_gaze_interaction`, `XR_MND_headless` and `XR_KHR_convert_timespec_time`. `supportsEyeGazeInteraction=1`. A headless session reached FOCUSED and produced 269 valid, tracked orientations in the first ten-second probe. |
| Our gaze → OSC bridge | A separate ten-second run produced 280 valid samples and 280 correctly padded 44-byte `/tracking/eye/CenterPitchYaw` messages at an explicitly configured loopback receiver. Only counters and packet-layout checks were retained. |
| Bluetooth stack | BlueZ active, adapter powered, central/peripheral roles available. LE discovery started and stopped successfully. No pairing or adapter power settings changed. |
| Our heart-rate panel | GTK4/GI runs on the stock image. A **synthetic 72 BPM** notification displayed in our X11 window, tagged `STEAM_GAME=2000000027`. Window capture checked; no real heart-rate measurement was taken. |
| SlimeVR, separate feasibility check | Native aarch64 server v21.1.0 ran with an isolated Temurin 21 JRE, created its driver sockets and accepted a local TCP connection on port 21110. Driver v6.0.0 loaded with all shared libraries resolved; `HmdDriverFactory("IServerTrackedDeviceProvider_004")` returned a non-null provider and error 0. |
![Our heart-rate panel on the Frame, showing synthetic 72 BPM](img/heart-rate-panel.png)
The image is a capture of our own Frame window using a fake notification,
not a real sensor reading.
**Untested:** a real BLE strap's notifications, physical fit/contact behaviour,
end-to-end heart-rate display/OSC/log with a strap, avatar response in VRChat,
gaze accuracy/calibration, coexistence with every immersive app, in-headset
panel placement, SlimeVR tracker/calibration data and the SlimeVR driver running
inside SteamVR. No trackers or strap are attached. The driver was loaded in a
separate process; it was **not registered or activated in SteamVR**. Steam and
SteamVR were not stopped or restarted.
**Verified blocker resolved:** importing `tkinter` fails because `libtk8.6.so`
is absent. The panel uses the installed GTK4/GI bindings instead. The Frame's
OpenXR headers advertise a newer API version than the runtime accepts; our
reader requests OpenXR 1.0 explicitly.
## Install our tools
From this checkout on your computer, while the Frame is awake:
```sh
python3 scripts/tracking-on-frame.py install
```
This copies our Python code and compiles our small C OpenXR reader into
`~/.local/share/frame-control/tracking/` on the Frame. It uses the Frame's
existing compiler, OpenXR headers/loader, Python, dbus-python, GI and GTK4.
Nothing is downloaded, and no sudo, driver registration, system setting,
service or autostart is added. `FRAME_ALIAS` can select another SSH alias.
The desktop app/server keeps its existing stdlib-only dependency set.
## Eye tracking → OSC
Start with a ten-second capability/data-availability check:
```sh
python3 scripts/tracking-on-frame.py gaze --seconds 10
```
This prints support, session-state numbers and sample counters. It opens no
OSC socket and prints no gaze coordinates. Exit 0 means at least one valid
sample, 3 means no valid sample was observed, and 1 means an API/runtime error.
If there are no valid samples, wake/wear the headset and check its tracking
setup; a successful capability check alone does not prove usable gaze.
To send to VRChat running **on the Frame**, explicitly enable OSC in VRChat
and choose its local UDP endpoint:
```sh
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
```
For a receiver on another computer, replace `127.0.0.1` with that computer's
IP address and choose its listening port. Addresses are IP literals (IPv4 or
IPv6); there is no discovery or default destination. Loopback here always
means **the Frame**, not the computer running the SSH command. OSC uses
unencrypted UDP: configure only a receiver you intend to receive this data.
**Documented:** [VRChat's eye OSC interface](https://docs.vrchat.com/docs/osc-eye-tracking)
accepts `/tracking/eye/CenterPitchYaw` with two floats in degrees, positive down
and right. We locate OpenXR's combined gaze pose relative to VIEW (the head),
rotate its -Z forward vector and convert that direction to these angles.
Only active, orientation-valid **and tracked** samples are sent, at up to
30 Hz. No eyelid/blink, individual-eye or face values are invented. We do not
send neutral gaze on tracking loss; VRChat's documented timeout restores its
automatic eye behaviour after input stops.
**Privacy:** gaze is personal data. It stays in process memory and a private
pipe between our reader and bridge. There is no gaze log option, telemetry,
OSC receiver or raw gaze on stdout/stderr. Only an explicit `--osc IP PORT`
opens an output socket. Runtime diagnostics and validity counters are not
measurements. Stop with Ctrl-C or let `--seconds` expire (maximum 24 hours).
A lost headless session ends the run; it does not silently reconnect.
## BLE heart rate → our panel, OSC and optional log
First discover/pair your strap in SteamOS's Bluetooth settings. Select that
strap's Bluetooth address explicitly; our tool does not scan for or connect
to arbitrary nearby devices.
```sh
python3 scripts/tracking-on-frame.py heart \
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600
```
This uses BlueZ's standard Heart Rate Service (`180d`) and Heart Rate
Measurement (`2a37`) notifications. It finds the characteristic only beneath
the selected device's HRS service. The reader handles 8- and 16-bit BPM,
contact flags and optional energy/RR fields; energy and RR intervals are
validated for length but discarded. Zero BPM, reported loss of skin contact,
malformed packets and readings older than five seconds are not shown as a
current measurement. A disconnect stops the run; reconnect and start again.
This is a social/fitness readout, not a medical monitor.
The panel is our GTK4 window on gamescope's X display. Use SteamVR's panel
controls to float/dock it (see [panels](panels.md)). **Stop**, closing the panel,
Ctrl-C, SSH hangup or the duration limit ends our subscription. A connection
that was already open when we started is preserved; a connection we opened
is disconnected on exit. No Bluetooth power or pairing state is changed.
Add either output explicitly:
```sh
python3 scripts/tracking-on-frame.py heart \
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600 \
--osc 127.0.0.1 9000 --address /avatar/parameters/HeartRate \
--log /home/steamos/heart-session.csv
```
The OSC value is integer BPM. `HeartRate` is a chosen avatar parameter, **not a
built-in VRChat heart-rate feature**; your avatar/receiver must define the
matching parameter. `--address` can select another literal OSC path. The local
panel works without OSC, a log or an avatar integration.
The optional CSV contains only `unix_seconds,bpm`. It is created privately
(mode 0600), refuses existing files/symlinks, and lives **on the Frame** at the
path you specify. Nothing is logged by default, and heart-rate values are not
printed to the terminal. Delete your session file when you no longer need it.
### Checking heart rate against a reference
`scripts/heart-check.py` runs on your computer. `listen` shows our OSC
readings live as they arrive, so you can watch them next to another device:
```sh
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
```
Point the Frame at it with `--osc <your computer's IP> 9000`. `compare` lines
up two recordings by time and reports the mean difference, bias, the share
within ±5 BPM and the delay between them. It passes when the mean difference
is at most 5 BPM, at least 80% of reference readings are matched and nothing
was shown while the sensor reported lost skin contact:
```sh
python3 scripts/heart-check.py compare ours.csv reference.csv
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
```
The reference can be a CSV (`time,bpm[,flags]`, time in unix seconds or ISO
8601) or an Apple Health export (`export.zip` or `export.xml`). Only heart-rate
records within the recording's time range are read. Everything stays on your
computer.
`scripts/heart-test-strap.swift` turns a Mac into a synthetic strap. It
advertises the standard Heart Rate Service and sends a fixed, known sequence
(8-bit and 16-bit values and a skin-contact loss), printing each sent value, so
`compare` can check that the Frame shows exactly what was sent. It needs
Bluetooth permission for the process that runs it. **Untested on 2026-09-29:**
it compiled, but on this Mac, launched from an agent session, macOS never
delivered a Bluetooth state and no permission prompt appeared, so it never
advertised.
## Pulse from the eye cameras (experimental)
The Frame has no heart-rate sensor. **Verified 2026-09-29** (SteamOS 0.4.1,
build `20260925.6191901`): its sensors are an ambient light/proximity sensor
(`vcnl4000`), a hall sensor (`als31300`), two passthrough cameras
(`arcimx616`), two tracking cameras (`og01a1b`) and two IR eye cameras
(`og0ve10`). There is no optical heart-rate (PPG) sensor.
The experiment asks whether the eye cameras can see a pulse anyway. With each
heartbeat, the blood volume in the skin around the eye changes slightly and
its IR reflectance changes with it. This is camera-based photoplethysmography;
near-IR works, though the signal is weaker than in green light.
```sh
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show
```
How it works:
- **Capture (verified).** SteamVR ships `eyetracking --calib N`, which saves
both eye cameras for N seconds as 400×400 8-bit IR PNGs with a monotonic
timestamp per frame, at about 90 fps per eye. SteamVR's live eye tracker,
part of `steamvr.service`, gets its frames from the DSP and stops its
cameras when the headset is off. Unworn captures ran alongside it: its PID
and log were unchanged and our OpenXR gaze session still started
afterwards. **Untested:** whether the capture and the live tracker coexist
while the headset is worn and tracking.
- **Privacy.** Each image is reduced to a 16×16 grid of patch averages as
soon as it is complete, then deleted. Three worker processes do this beside
the capture. If more than 900 images (about five seconds) ever wait, we stop
reading them and delete them undecoded until the capture ends, and report
an error. The capture directory is removed on exit, even after errors. No image is kept or leaves the Frame. The estimate
is printed only with `--show`, and sent or saved only with `--osc` or
`--log`, as for the strap.
- **Estimate.** Patch traces are averaged down to 15 Hz and turned into
relative change. A 2-second moving median removes drift and blinks.
Patches with frequent spikes (the eyeball and eyelid) are dropped, as are
dark or saturated ones. The 20% of patches with the clearest rhythm between
42 and 180 BPM are combined in the frequency domain. Output is an overall
estimate plus one estimate per second over 15-second windows. A result
counts as **clear** only when the top patches agree and the combined signal
stands out from the noise. Otherwise the command exits 3 and sends no OSC.
`--log` still records the per-second estimates, so a comparison shows how
far off an unclear result was.
The thresholds are provisional until checked on real wearers.
**Verified on the Frame, unworn, 2026-09-29:** captures of 3,600-5,400 eye
frames never had more than 8 images on disk, finished a few seconds after the
capture ended and left no capture directory. **The estimator alone gave a
false "clear" pulse.** With nobody wearing the headset, five runs reported a
steady, self-consistent rhythm (90, 90, 93, 94 and 96 BPM; patch agreement
100%, signal/noise 0.63-0.70), and earlier runs reported 127-129 BPM at lower
signal/noise. It is a periodic camera or illumination artifact, and its
frequency drifts between runs. A wearer-less scene cannot contain a pulse, so
the signal/noise gate cannot tell this artifact from one. Because of that,
`pulse` reads the Frame's proximity sensor (`vcnl4000`) before and after the
capture. It reads about 3 unworn (**verified**). If either reading is below 20
the result is never called clear, nothing is sent over OSC, and the command
exits 3. **Inferred, unmeasured:** that a worn reading is well above 20; the
cut-off is provisional until someone wears the headset. If the sensor can't be
read, the guard is skipped. Confirming a real pulse also needs a reference
(below).
**Do not interrupt the capture. Verified on the Frame, 2026-09-29:** sending
SIGTERM to `eyetracking --calib` left the DSP service's eye camera (OV6211)
stuck "streaming": its log had no "Stopping streaming" line, and every later
request failed with "Failed to start streaming". Head tracking kept working.
Clearing it needs the DSP service restarted or the Frame rebooted, so `pulse`
never signals the tool. After Ctrl-C or a failure it keeps deleting images
until the tool ends by itself (at most the `--seconds` plus a few seconds),
and only kills a tool that overruns by 30 s, with a warning that the eye
cameras may need a reboot. So an interrupted run can take a while to return.
**Verified on synthetic data** (unit tests): a 0.3% brightness pulse in a
third of the patches, with noise, drift, blinks and eye movement, is
recovered within 1.5 BPM at 58, 72 and 115 BPM; noise and blinks alone are
not reported as a pulse. **Not yet verified:** whether a real wearer's eye
images contain a usable pulse, and how accurate it is. That needs someone
wearing the headset and a reference, as below.
### Comparing with an Apple Watch
1. On the watch, start a workout (for example **Other**) so it measures heart
rate every few seconds rather than occasionally.
2. Put the Frame on, sit still and look ahead. Run:
```sh
python3 scripts/tracking-on-frame.py pulse --seconds 120 --show \
--log /home/steamos/pulse.csv
```
The per-second estimates print at the end. Compare them with what the
watch showed.
3. End the workout. On the iPhone, open Health → your picture → **Export All
Health Data**, and AirDrop `export.zip` to the Mac.
4. On the Mac:
```sh
scp frame:pulse.csv . && ssh frame rm pulse.csv
python3 scripts/heart-check.py compare pulse.csv ~/Downloads/export.zip
```
This first version analyses after the capture ends, because the method must
prove itself before a live panel is worth building. The Apple Watch is a
reference, not ground truth: in workouts it is typically within a few BPM of
a chest strap when you are still.
## SlimeVR: feasibility only
SlimeVR is an independent application stack. Neither of our features installs,
launches or depends on it. Users who want it can follow
[SlimeVR's setup documentation](https://docs.slimevr.dev/server/index.html).
The consented upstream releases tested were
[server v21.1.0](https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v21.1.0)
and [driver v6.0.0](https://github.com/SlimeVR/SlimeVR-OpenVR-Driver/releases/tag/v6.0.0),
under SlimeVR's MIT/Apache-2.0 licensing.
**Verified layout, read-only:** the Frame's registered runtime is `/opt/steamvr`;
its native driver is `drivers/cv/bin/linuxarm64/driver_cv.so`, with a
`drivers/cv/driver.vrdrivermanifest`. Frame controller manifests/resources are
under `drivers/frame_controller/`. Configuration is under
`~/.config/openvr/config/`, not the Steam client's config directory. The
SlimeVR release also uses `slimevr/bin/linuxarm64/driver_slimevr.so` plus its
manifest. Nothing in those installed SteamVR directories was changed.
**Inferred:** the matching ABI/layout and standalone factory success make
SteamVR integration plausible. They do not prove successful driver `Init`,
server/driver IPC, tracking, or calibration. That needs a separate integration
check with hardware and an agreed SteamVR restart. No Java executable was on
PATH for this check, so an isolated JRE was used. SlimeVR's server opens LAN
listeners; our temporary server was stopped and the temporary downloads,
configuration and logs were removed. It is not left installed or running.
## Tests and remaining checks
```sh
python3 -m unittest discover -s tests
```
`tests/test_tracking.py` covers HRS packet parsing, contact/staleness, OSC
padding/types and a real loopback socket, quaternion signs, opt-in networking,
private/exclusive logging and a fake BlueZ object tree. The fake checks service
ownership, notification routing, delayed GATT discovery and connection cleanup.
It does not pretend to be a physical strap or a real OpenXR runtime.
Before calling hardware support complete, attach a strap and check BPM against
its own display/reference, loss of contact, disconnect/reconnect, Stop, OSC and
CSV together. Check avatar eyes while looking up/down/left/right in a supported
VRChat session. No third-party tracking app is needed for either test.
Independent review attempt: `devin -p --model swe-2-max` with the frozen diff,
contribution standards and read-only instructions returned no output for ten
minutes. It was terminated with exit 143. No completed review or actual model
identity was returned; hardware checks and independent review remain follow-up
work before making the draft ready.
+135
View File
@@ -0,0 +1,135 @@
/* Frame Control's OpenXR gaze source. No values on stdout/stderr or disk.
* The Python bridge supplies a private pipe with --fd; standalone probes only
* report counters. Uses a headless session, never submits frames or takes focus. */
#define XR_USE_TIMESPEC
#include <time.h>
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t stopped;
static void stop(int sig) { (void)sig; stopped = 1; }
#define CHECK(call) do { result = (call); if (XR_FAILED(result)) { \
fprintf(stderr, "%s failed (%d)\n", #call, result); goto cleanup; } } while (0)
int main(int argc, char **argv) {
int seconds = 10, fd = -1, running = 0, rc = 1;
unsigned active = 0, valid = 0, samples = 0;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--seconds") && i+1 < argc) seconds = atoi(argv[++i]);
else if (!strcmp(argv[i], "--fd") && i+1 < argc) fd = atoi(argv[++i]);
else { fprintf(stderr, "usage: gaze [--seconds 1..86400] [--fd private-pipe]\n"); return 2; }
}
if (seconds < 1 || seconds > 86400 || (fd != -1 && fd < 3)) return 2;
FILE *out = fd == -1 ? NULL : fdopen(fd, "w");
if (fd != -1 && !out) return 2;
signal(SIGINT, stop); signal(SIGTERM, stop); signal(SIGHUP, stop); signal(SIGPIPE, SIG_IGN);
XrResult result;
XrInstance instance = XR_NULL_HANDLE;
XrSession session = XR_NULL_HANDLE;
XrActionSet set = XR_NULL_HANDLE;
XrSpace gaze = XR_NULL_HANDLE, view = XR_NULL_HANDLE;
const char *extensions[] = {"XR_EXT_eye_gaze_interaction", "XR_MND_headless", "XR_KHR_convert_timespec_time"};
XrInstanceCreateInfo create = {.type = XR_TYPE_INSTANCE_CREATE_INFO};
strcpy(create.applicationInfo.applicationName, "Frame Control gaze");
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
create.enabledExtensionCount = 3; create.enabledExtensionNames = extensions;
CHECK(xrCreateInstance(&create, &instance));
XrSystemGetInfo get = {.type = XR_TYPE_SYSTEM_GET_INFO, .formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY};
XrSystemId system;
CHECK(xrGetSystem(instance, &get, &system));
XrSystemEyeGazeInteractionPropertiesEXT eye = {.type = XR_TYPE_SYSTEM_EYE_GAZE_INTERACTION_PROPERTIES_EXT};
XrSystemProperties props = {.type = XR_TYPE_SYSTEM_PROPERTIES, .next = &eye};
CHECK(xrGetSystemProperties(instance, system, &props));
printf("supportsEyeGazeInteraction=%u\n", eye.supportsEyeGazeInteraction);
if (!eye.supportsEyeGazeInteraction) goto cleanup;
XrSessionCreateInfo sc = {.type = XR_TYPE_SESSION_CREATE_INFO, .systemId = system};
CHECK(xrCreateSession(instance, &sc, &session));
XrActionSetCreateInfo asc = {.type = XR_TYPE_ACTION_SET_CREATE_INFO};
strcpy(asc.actionSetName, "gaze"); strcpy(asc.localizedActionSetName, "Gaze");
CHECK(xrCreateActionSet(instance, &asc, &set));
XrActionCreateInfo ac = {.type = XR_TYPE_ACTION_CREATE_INFO, .actionType = XR_ACTION_TYPE_POSE_INPUT};
strcpy(ac.actionName, "gaze_pose"); strcpy(ac.localizedActionName, "Gaze pose");
XrAction action;
CHECK(xrCreateAction(set, &ac, &action));
XrPath profile, input;
CHECK(xrStringToPath(instance, "/interaction_profiles/ext/eye_gaze_interaction", &profile));
CHECK(xrStringToPath(instance, "/user/eyes_ext/input/gaze_ext/pose", &input));
XrActionSuggestedBinding binding = {action, input};
XrInteractionProfileSuggestedBinding suggested = {.type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING,
.interactionProfile = profile, .countSuggestedBindings = 1, .suggestedBindings = &binding};
CHECK(xrSuggestInteractionProfileBindings(instance, &suggested));
XrSessionActionSetsAttachInfo attach = {.type = XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO, .countActionSets = 1, .actionSets = &set};
CHECK(xrAttachSessionActionSets(session, &attach));
XrActionSpaceCreateInfo space = {.type = XR_TYPE_ACTION_SPACE_CREATE_INFO, .action = action, .poseInActionSpace.orientation.w = 1};
CHECK(xrCreateActionSpace(session, &space, &gaze));
XrReferenceSpaceCreateInfo ref = {.type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW,
.poseInReferenceSpace.orientation.w = 1};
CHECK(xrCreateReferenceSpace(session, &ref, &view));
PFN_xrConvertTimespecTimeToTimeKHR convert;
CHECK(xrGetInstanceProcAddr(instance, "xrConvertTimespecTimeToTimeKHR", (PFN_xrVoidFunction *)&convert));
struct timespec start, now;
clock_gettime(CLOCK_MONOTONIC, &start);
while (!stopped) {
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec - start.tv_sec >= seconds) break;
XrEventDataBuffer event = {.type = XR_TYPE_EVENT_DATA_BUFFER};
while ((result = xrPollEvent(instance, &event)) == XR_SUCCESS) {
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
XrSessionState state = ((XrEventDataSessionStateChanged *)&event)->state;
printf("sessionState=%d\n", state); fflush(stdout);
if (state == XR_SESSION_STATE_READY && !running) {
XrSessionBeginInfo begin = {.type = XR_TYPE_SESSION_BEGIN_INFO, .primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO};
CHECK(xrBeginSession(session, &begin)); running = 1;
} else if (state == XR_SESSION_STATE_STOPPING) {
CHECK(xrEndSession(session)); running = 0; stopped = 1;
} else if (state == XR_SESSION_STATE_EXITING || state == XR_SESSION_STATE_LOSS_PENDING) stopped = 1;
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) stopped = 1;
event.type = XR_TYPE_EVENT_DATA_BUFFER;
}
if (XR_FAILED(result)) goto cleanup;
if (running && !stopped) {
XrActiveActionSet activeSet = {set, XR_NULL_PATH};
XrActionsSyncInfo sync = {.type = XR_TYPE_ACTIONS_SYNC_INFO, .countActiveActionSets = 1, .activeActionSets = &activeSet};
CHECK(xrSyncActions(session, &sync));
if (result != XR_SUCCESS) {
struct timespec delay = {.tv_nsec = 33333333};
nanosleep(&delay, NULL);
continue; /* No stale gaze when the runtime denies focus. */
}
XrActionStateGetInfo ag = {.type = XR_TYPE_ACTION_STATE_GET_INFO, .action = action};
XrActionStatePose pose = {.type = XR_TYPE_ACTION_STATE_POSE};
CHECK(xrGetActionStatePose(session, &ag, &pose));
samples++;
if (pose.isActive) {
active++;
XrTime time; CHECK(convert(instance, &now, &time));
XrSpaceLocation location = {.type = XR_TYPE_SPACE_LOCATION};
CHECK(xrLocateSpace(gaze, view, time, &location));
XrSpaceLocationFlags needed = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT;
if ((location.locationFlags & needed) == needed) {
valid++;
if (out) {
XrQuaternionf q = location.pose.orientation;
if (fprintf(out, "%g %g %g %g\n", q.x, q.y, q.z, q.w) < 0 || fflush(out)) goto cleanup;
}
}
}
}
struct timespec delay = {.tv_nsec = 33333333}; nanosleep(&delay, NULL);
}
printf("samples=%u active=%u valid=%u\n", samples, active, valid);
rc = valid ? 0 : 3; /* Distinguish a working session from observed gaze. */
cleanup:
if (view) xrDestroySpace(view);
if (gaze) xrDestroySpace(gaze);
if (session) xrDestroySession(session);
if (set) xrDestroyActionSet(set);
if (instance) xrDestroyInstance(instance);
if (out) fclose(out);
return rc;
}
+481
View File
@@ -0,0 +1,481 @@
"""Experimental pulse estimate from the Frame's IR eye-tracking cameras.
Skin brightens and darkens very slightly with each heartbeat as blood volume
changes (photoplethysmography). The eye cameras film the skin around each eye
under steady IR light at about 90 frames per second, so that rhythm may be
visible in the average brightness of small patches of skin.
Capture uses SteamVR's own `eyetracking --calib` mode, which writes PNG pairs
to /tmp. Each image is reduced to a grid of patch averages the moment it is
complete, then deleted; the capture directory is removed on exit. No image
leaves the Frame or outlives the run. This is an experiment, not a medical
measurement.
"""
from array import array
import bisect
import cmath
import json
import math
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
ET_DIR = Path("/opt/steamvr/tools/eyetracking")
ET_BIN = ET_DIR / "bin/linuxarm64/eyetracking"
ET_WEIGHTS = ET_DIR / "resources/et_dsp_20250610_03136.weights"
GRID = 16 # 16 × 16 patches of 25 × 25 pixels on the 400 × 400 image
RATE = 15.0 # analysis sample rate, Hz; the band of interest ends at 3 Hz
LOW, HIGH = 42.0, 180.0 # BPM search band
# The Frame's proximity sensor (vcnl4000) reads about 3 with nobody wearing it.
# A worn reading has not been measured yet, so the cut-off is provisional.
IIO = Path("/sys/bus/iio/devices")
WORN_MIN = 20.0
WINDOW = 15.0 # seconds per windowed estimate
# ---------------------------------------------------------------- capture ---
def grid_means(pixels, width, height, stride, channels, grid=GRID):
"""Average of channel 0 in each of grid × grid equal patches."""
bw, bh = width // grid, height // grid
sums = [0] * (grid * grid)
for y in range(bh * grid):
start = y * stride
row = pixels[start:start + width * channels:channels]
base = (y // bh) * grid
for bx in range(grid):
sums[base + bx] += sum(row[bx * bw:(bx + 1) * bw])
area = bw * bh
return [s / area for s in sums]
def load_grid(path):
import gi
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import GdkPixbuf
image = GdkPixbuf.Pixbuf.new_from_file(str(path))
return grid_means(image.read_pixel_bytes().get_data(), image.get_width(), image.get_height(),
image.get_rowstride(), image.get_n_channels())
def reduce_file(loader, path):
"""Reduce one image to its patch grid and delete it, whatever happens."""
try:
return loader(path)
finally:
os.unlink(path)
class Capture:
"""Run SteamVR's eye-camera capture and reduce frames as they arrive."""
NAME = re.compile(r"^(left|right)_(\d+)\.png$")
# Only SteamVR's own capture directories are read and removed.
PREFIX = "/tmp/etcalib_"
# Printed by the capture tool; its output is only flushed when it exits.
WRITING = re.compile(r"Writing capture to: (\S+)")
# About five seconds of frames (~65 MB in RAM-backed /tmp). If reduction
# falls further behind than this, the capture stops rather than letting
# eye images pile up.
MAX_BACKLOG = 900
def __init__(self, seconds, runner=subprocess.Popen, loader=load_grid, workers=3):
self.seconds, self.runner, self.loader, self.workers = seconds, runner, loader, workers
self.grids = {"left": {}, "right": {}}
self.directory = None
self.pool = None
self.submitted = set()
self.futures = {}
self.new = set() # every capture directory that appeared during our run
self.before = None # capture directories that existed before the run
self.log = None
def candidates(self):
parent, stem = os.path.split(self.PREFIX)
return {Path(entry.path) for entry in os.scandir(parent)
if entry.name.startswith(stem) and entry.is_dir(follow_symlinks=False)}
def run(self):
# The capture tool's output goes to a private temporary file, read for
# failure messages. It is block-buffered, so the capture directory is
# found by watching for a new one rather than waiting for its name.
import tempfile
self.before = before = self.candidates()
process = None
with tempfile.TemporaryFile("w+") as log:
self.log = log
try:
if self.workers:
# SteamVR pins its eye tracker to cores 0-1; decoding runs beside it.
import concurrent.futures
import multiprocessing
self.pool = concurrent.futures.ProcessPoolExecutor(
self.workers, mp_context=multiprocessing.get_context("fork"))
process = self.runner([str(ET_BIN), "-b", "CDSP", "-w", str(ET_WEIGHTS), "--calib", str(self.seconds)],
cwd=str(ET_BIN.parent), stdout=log, stderr=subprocess.STDOUT)
deadline = time.monotonic() + self.seconds + 30
while True:
# Checked on every poll: a second capture directory means
# we can't tell which images are ours, so stop.
self.new |= self.candidates() - before
if len(self.new) > 1:
raise RuntimeError("another eye-camera capture is running")
if not self.directory and self.new:
self.directory = next(iter(self.new))
finished = process.poll() is not None
self.reduce(final=finished)
if finished:
break
if time.monotonic() > deadline:
raise RuntimeError("eye-camera capture did not finish")
time.sleep(0.02)
log.seek(0)
text = log.read()
if process.returncode or not self.directory:
reason = "cameras unavailable" if "Failed to" in text else f"exit {process.returncode}"
raise RuntimeError(f"eye-camera capture failed ({reason})")
named = self.written(log)
if named and named != self.directory:
raise RuntimeError("the capture wrote somewhere else; not using those images")
return self.frames()
finally:
# Each step runs even if an earlier one failed: eye images must
# be removed whatever else went wrong.
# Ctrl-C and SIGTERM (raised as KeyboardInterrupt) are held
# until the images are gone, then re-raised.
interrupted, failed = None, None
for step in (lambda: self.finish(process),
lambda: self.pool and self.pool.shutdown(wait=True, cancel_futures=True),
self.adopt_reported):
try:
step()
except BaseException as error:
if isinstance(error, Exception):
failed = failed or error
else:
interrupted = interrupted or error
self.log = None
self.remove()
if interrupted:
raise interrupted
if failed:
raise RuntimeError(f"the eye-camera capture did not stop cleanly: {failed}")
def finish(self, process):
"""Let the capture tool end by itself, deleting its images meanwhile.
Never signal it: stopping the tool early leaves the headset's eye
camera stuck streaming until the DSP service restarts (verified on the
Frame with SIGTERM). Its run is bounded by `seconds`, so waiting is
short. Only if it overruns by a wide margin is it killed."""
if not process:
return
interrupted = None
give_up = time.monotonic() + self.seconds + 30
while True:
try:
if process.poll() is not None:
break
self.discard()
if time.monotonic() > give_up:
process.kill()
process.wait()
raise RuntimeError("the eye-camera capture had to be killed; "
"the eye cameras may need a reboot to stream again")
time.sleep(0.05)
except KeyboardInterrupt as error: # keep cleaning up until it ends
interrupted = interrupted or error
if interrupted:
raise interrupted
def discard(self):
"""Delete the eye images written so far, without reading them."""
if self.before is not None:
self.new |= self.candidates() - self.before
for directory in self.new | ({self.directory} if self.directory else set()):
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
for entry in os.scandir(directory):
if self.NAME.match(entry.name):
try:
os.unlink(entry.path)
except OSError:
pass
def adopt_reported(self):
"""The directory the tool reported is ours by its own account."""
named = self.written(self.log)
if named and str(named).startswith(self.PREFIX):
self.new.add(named)
def written(self, log):
"""The directory the capture tool reported, once its output is flushed."""
log.seek(0)
match = self.WRITING.search(log.read())
return Path(match.group(1)) if match else None
def reduce(self, final=False):
"""Reduce and delete every complete image; an image is complete once a
later one of the same eye exists, or the capture has ended."""
if not self.directory or not self.directory.is_dir():
return
pending = {"left": [], "right": []}
for entry in os.scandir(self.directory):
match = self.NAME.match(entry.name)
if match:
pending[match.group(1)].append((int(match.group(2)), entry.path))
if sum(len(files) for files in pending.values()) > self.MAX_BACKLOG:
raise RuntimeError("eye-image processing fell behind; capture abandoned")
for eye, files in pending.items():
files.sort()
ready = files if final else files[:-1]
for index, path in ready:
if path in self.submitted:
continue
self.submitted.add(path)
if self.pool:
self.futures[(eye, index)] = self.pool.submit(reduce_file, self.loader, path)
else:
self.grids[eye][index] = array("f", reduce_file(self.loader, path))
for key, future in list(self.futures.items()):
if final or future.done():
self.grids[key[0]][key[1]] = array("f", future.result())
del self.futures[key]
def frames(self):
"""[(monotonic seconds, eye, grid)] joined with the capture metadata."""
meta = json.loads((self.directory / "meta.json").read_text())
frames = []
for pair in meta["frames"]:
for eye in ("left", "right"):
info = pair.get(eye) or {}
match = self.NAME.match(Path(info.get("fname", "")).name)
grid = self.grids[eye].get(int(match.group(2))) if match else None
if info.get("valid") and grid is not None:
frames.append((float(info["tsMono"]), eye, grid))
return frames
def remove(self):
"""Remove every capture directory that appeared during the run. Eye
images must not outlive it, so a failure to delete is an error."""
left = []
if self.before is not None:
try:
self.new |= self.candidates() - self.before # even if interrupted before the first poll
except OSError:
pass
for directory in self.new | ({self.directory} if self.directory else set()):
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
try:
shutil.rmtree(directory)
except OSError:
left.append(str(directory))
if left:
raise RuntimeError("could not delete eye images in " + ", ".join(sorted(left)))
# --------------------------------------------------------------- analysis ---
def fft(values):
"""In-place iterative radix-2 FFT of a list whose length is a power of 2."""
n = len(values)
a = list(values)
j = 0
for i in range(1, n):
bit = n >> 1
while j & bit:
j ^= bit
bit >>= 1
j |= bit
if i < j:
a[i], a[j] = a[j], a[i]
size = 2
while size <= n:
step = cmath.exp(-2j * math.pi / size)
half = size // 2
for start in range(0, n, size):
w = 1
for k in range(start, start + half):
t = w * a[k + half]
a[k + half] = a[k] - t
a[k] += t
w *= step
size *= 2
return a
def resample(times, values, rate=RATE):
"""Average samples into 1/rate bins from the first sample; empty bins are
filled from the previous bin."""
if not times:
return []
start = times[0]
count = int((times[-1] - start) * rate) + 1
sums, counts = [0.0] * count, [0] * count
for t, v in zip(times, values):
i = min(int((t - start) * rate), count - 1)
sums[i] += v
counts[i] += 1
out, last = [], None
for s, c in zip(sums, counts):
last = s / c if c else last
out.append(last)
first = next(v for v in out if v is not None)
return [first if v is None else v for v in out]
def clean(signal, rate=RATE):
"""Relative change with slow drift removed; None if the patch is mostly
blinks or eye movement. Spikes (blinks, saccades) become gaps at the
local level rather than clipped steps, which would add false rhythm."""
mean = sum(signal) / len(signal)
x = [v / mean - 1 for v in signal]
half = int(rate) # 2-second centred moving median removes drift and blinks
trend = []
for i in range(len(x)):
chunk = sorted(x[max(0, i - half):i + half + 1])
trend.append(chunk[len(chunk) // 2])
residual = [v - m for v, m in zip(x, trend)]
mad = sorted(abs(v) for v in residual)[len(residual) // 2] or 1e-9
limit = 4 * 1.4826 * mad
spikes = sum(1 for v in residual if abs(v) > limit)
if spikes > 0.05 * len(residual):
return None
return [v if abs(v) <= limit else 0.0 for v in residual]
def spectrum(signal, rate=RATE):
"""[(bpm, power)] within the search band, Hann-windowed and zero-padded."""
n = len(signal)
size = 1
while size < max(n * 4, 256):
size *= 2
window = [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)] if n > 1 else [1.0]
padded = [s * w for s, w in zip(signal, window)] + [0.0] * (size - n)
result = fft(padded)
out = []
for k in range(size // 2):
bpm = k * rate / size * 60
if LOW <= bpm <= HIGH:
out.append((bpm, abs(result[k]) ** 2))
return out
def peak(spec):
"""Peak BPM with parabolic interpolation between spectral bins."""
i = max(range(len(spec)), key=lambda k: spec[k][1])
if 0 < i < len(spec) - 1:
a, b, c = spec[i - 1][1], spec[i][1], spec[i + 1][1]
denominator = a - 2 * b + c
offset = 0.5 * (a - c) / denominator if denominator else 0.0
return spec[i][0] + offset * (spec[1][0] - spec[0][0])
return spec[i][0]
def snr(spec, bpm, width=4.0):
"""Power near the pulse and its first harmonic against the rest of the band."""
near = sum(p for f, p in spec if abs(f - bpm) <= width or abs(f - 2 * bpm) <= width)
rest = sum(p for f, p in spec) - near
if near <= 0:
return 0.0
return near / rest if rest > 0 else float("inf")
def normalised(spec):
total = sum(p for _, p in spec) or 1.0
return [p / total for _, p in spec]
def usable(values):
"""Patches that are neither dark nor saturated for the whole recording."""
mean = sum(values) / len(values)
return 8 <= mean <= 245
def estimate(times, patches, rate=RATE, share=0.2, window=WINDOW):
"""Estimate pulse from patch brightness traces.
times: monotonic seconds per frame. patches: {name: [brightness per frame]}.
Selects the share of usable patches with the clearest periodic signal,
combines their spectra and reports the overall and windowed estimates.
"""
cleaned = {}
for name, values in patches.items():
if usable(values):
signal = clean(resample(times, values, rate), rate)
if signal is not None and any(signal): # flat patches carry no rhythm
cleaned[name] = signal
if not cleaned or len(next(iter(cleaned.values()))) < rate * 8:
raise ValueError("need at least 8 seconds of usable eye-camera frames")
quality = []
for name, signal in cleaned.items():
spec = spectrum(signal, rate)
own = peak(spec)
quality.append((snr(spec, own), name, own, spec))
quality.sort(reverse=True)
chosen = quality[:max(4, int(len(quality) * share))]
combined = [sum(values) for values in zip(*(normalised(spec) for _, _, _, spec in chosen))]
bins = [f for f, _ in chosen[0][3]]
bpm = peak(list(zip(bins, combined)))
top = chosen[:8]
agree = sum(1 for _, _, own, _ in top if abs(own - bpm) <= 5) / len(top)
series = []
samples = int(window * rate)
length = len(next(iter(cleaned.values())))
for end in range(samples, length + 1, int(rate)):
specs = [spectrum(cleaned[name][end - samples:end], rate) for _, name, _, _ in chosen]
total = [sum(values) for values in zip(*(normalised(s) for s in specs))]
window_bins = [f for f, _ in specs[0]]
series.append((times[0] + end / rate, peak(list(zip(window_bins, total)))))
return {
"bpm": bpm,
"agreement": agree,
"patches": len(chosen),
"usable": len(cleaned),
"snr": snr(list(zip(bins, combined)), bpm),
"series": series,
}
def analyse(frames):
"""Estimate from Capture.frames(); both eyes' patches are analysed together
on a shared time base, each sample taken from that eye's nearest frame."""
times = sorted({t for t, _, _ in frames})
patches = {}
for eye in ("left", "right"):
eye_frames = sorted((t, grid) for t, e, grid in frames if e == eye)
if not eye_frames:
continue
eye_times = [t for t, _ in eye_frames]
nearest = []
for t in times:
i = bisect.bisect_left(eye_times, t)
if i == len(eye_times) or (i > 0 and t - eye_times[i - 1] <= eye_times[i] - t):
i -= 1
nearest.append(i)
for k in range(len(eye_frames[0][1])):
patches[f"{eye}{k}"] = [eye_frames[i][1][k] for i in nearest]
return estimate(times, patches)
def proximity(root=None):
"""The headset's proximity reading, or None if it can't be read."""
try:
for device in sorted(Path(root or IIO).glob("iio:device*")):
if (device / "name").read_text().strip() == "vcnl4000":
return float((device / "in_proximity_raw").read_text())
except (OSError, ValueError):
pass
return None
def worn(reading):
"""False only when the sensor says the headset is not on a face."""
return reading is None or reading >= WORN_MIN
def reliable(result):
"""Whether the estimate is clear enough to show as a reading. Thresholds
are provisional until checked against a reference on a real wearer."""
return result["agreement"] >= 0.75 and result["snr"] >= 0.5
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env python3
"""Frame-local tracking tools. No network destination or data log by default."""
import argparse
import math
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import time
class TrackingError(RuntimeError):
"""A safe, actionable status message containing no sensor data."""
HRS = "0000180d-0000-1000-8000-00805f9b34fb"
MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"
DEVICE = "org.bluez.Device1"
SERVICE = "org.bluez.GattService1"
CHARACTERISTIC = "org.bluez.GattCharacteristic1"
def heart_rate(data):
"""Validate the Bluetooth HRS measurement, returning BPM/contact only.
Energy and RR intervals are checked for length but never retained.
None means contact is supported and the strap reports no skin contact.
"""
data = bytes(data)
if len(data) < 2 or data[0] & 0xe0:
raise ValueError("invalid HRS measurement")
flags = data[0]
size = 2 if flags & 1 else 1
end = 1 + size + (2 if flags & 8 else 0)
if len(data) < end:
raise ValueError("truncated HRS measurement")
extra = len(data) - end
if (flags & 16 and (extra < 2 or extra % 2)) or (not flags & 16 and extra):
raise ValueError("invalid HRS optional fields")
if flags & 4 and not flags & 2:
return None
bpm = int.from_bytes(data[1:1 + size], "little")
return bpm if bpm else None
def gaze_angles(quaternion):
"""OpenXR head-relative -Z forward → VRChat degrees, down/right positive."""
if len(quaternion) != 4 or not all(math.isfinite(v) for v in quaternion):
raise ValueError("invalid gaze orientation")
norm = math.sqrt(sum(v * v for v in quaternion))
if not 0.9 < norm < 1.1:
raise ValueError("invalid gaze orientation")
x, y, z, w = (v / norm for v in quaternion)
# Rotate OpenXR's forward vector (0, 0, -1) into VIEW space.
dx, dy, dz = -2 * (x*z + w*y), 2 * (w*x - y*z), 2 * (x*x + y*y) - 1
return math.degrees(math.atan2(-dy, math.hypot(dx, dz))), math.degrees(math.atan2(dx, -dz))
def osc_message(address, values):
if not address.startswith("/") or any(c.isspace() or c in '\0#*,?[]{}' for c in address):
raise ValueError("OSC address must be a literal path")
def string(value):
encoded = value.encode("utf-8") + b"\0"
return encoded + b"\0" * (-len(encoded) % 4)
tags, payload = ",", b""
for value in values:
if type(value) is int:
tags += "i"
payload += struct.pack(">i", value)
else:
if not math.isfinite(value):
raise ValueError("OSC value must be finite")
tags += "f"
payload += struct.pack(">f", value)
return string(address) + string(tags) + payload
class Osc:
def __init__(self, endpoint=None):
self.sock = None
self.target = None
if endpoint:
import ipaddress
address = ipaddress.ip_address(endpoint[0])
port = int(endpoint[1])
if address.is_unspecified or address.is_multicast or not 1 <= port <= 65535:
raise ValueError("OSC needs a unicast IP address and port 1..65535")
self.sock = socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_DGRAM)
self.target = (str(address), port)
def send(self, address, values):
if self.sock:
self.sock.sendto(osc_message(address, values), self.target)
def close(self):
if self.sock:
self.sock.close()
class HeartSession:
def __init__(self, osc, address, log=None, clock=time.monotonic):
self.osc, self.address, self.clock = osc, address, clock
self.bpm, self.updated = None, None
self.log = None
if log:
# Exclusive creation refuses existing files and symlinks; mode is
# private even with a permissive process umask.
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
self.log = os.fdopen(fd, "w")
self.log.write("unix_seconds,bpm\n")
def notification(self, data):
self.bpm = heart_rate(data)
self.updated = self.clock()
if self.bpm is not None:
self.osc.send(self.address, [self.bpm])
if self.log:
self.log.write(f"{time.time():.3f},{self.bpm}\n")
self.log.flush()
def current(self):
if self.updated is None or self.clock() - self.updated > 5:
return None
return self.bpm
def close(self):
if self.log:
self.log.close()
class BluezHeart:
"""One explicitly selected, already discovered strap; no ambient scan."""
def __init__(self, bus, interface, address, on_value):
self.bus, self.interface, self.on_value = bus, interface, on_value
self.device = self.characteristic = None
self.connected_here = False
self.notifying = False
self.match = None
objects = self.objects()
matches = [path for path, interfaces in objects.items()
if str(interfaces.get(DEVICE, {}).get("Address", "")).upper() == address.upper()]
if len(matches) != 1:
raise TrackingError("Strap not found uniquely in BlueZ; pair/discover it in SteamOS Bluetooth settings first")
self.device = matches[0]
self.match = bus.add_signal_receiver(self.changed, signal_name="PropertiesChanged",
dbus_interface="org.freedesktop.DBus.Properties",
bus_name="org.bluez", path_keyword="path")
try:
if not objects[self.device][DEVICE].get("Connected"):
self.call(self.device, DEVICE).Connect(timeout=20)
self.connected_here = True
except Exception:
self.close()
raise
def objects(self):
return self.call("/", "org.freedesktop.DBus.ObjectManager").GetManagedObjects()
def call(self, path, kind):
return self.interface(self.bus.get_object("org.bluez", path), kind)
def subscribe(self):
objects = self.objects()
if not objects.get(self.device, {}).get(DEVICE, {}).get("ServicesResolved"):
return False
services = {p for p, obj in objects.items() if str(obj.get(SERVICE, {}).get("UUID", "")).lower() == HRS
and obj[SERVICE].get("Device") == self.device}
for path, obj in objects.items():
props = obj.get(CHARACTERISTIC, {})
if props.get("Service") in services and str(props.get("UUID", "")).lower() == MEASUREMENT:
if "notify" not in props.get("Flags", []):
raise TrackingError("Heart-rate characteristic does not support notifications")
self.characteristic = path
self.call(path, CHARACTERISTIC).StartNotify()
self.notifying = True
return True
raise TrackingError("Selected device has no standard Heart Rate Service measurement")
def changed(self, kind, changes, invalidated, path=None):
if kind == CHARACTERISTIC and path == self.characteristic and "Value" in changes:
self.on_value(changes["Value"])
elif kind == DEVICE and path == self.device and "Connected" in changes and not changes["Connected"]:
self.on_value(None)
def close(self):
try:
if self.notifying:
self.call(self.characteristic, CHARACTERISTIC).StopNotify()
finally:
if self.match:
self.match.remove()
if self.connected_here:
self.call(self.device, DEVICE).Disconnect()
def run_gaze(args, osc):
binary = Path(__file__).with_name("gaze")
command = [str(binary), "--seconds", str(args.seconds)]
if not args.osc:
return subprocess.call(command)
read_fd, write_fd = os.pipe()
process = None
try:
process = subprocess.Popen(command + ["--fd", str(write_fd)], pass_fds=(write_fd,))
os.close(write_fd)
write_fd = None
with os.fdopen(read_fd) as source:
read_fd = None
for line in source:
try:
angles = gaze_angles([float(v) for v in line.split()])
except ValueError:
continue
osc.send("/tracking/eye/CenterPitchYaw", angles)
return process.wait()
finally:
if read_fd is not None:
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
class HeartPanel:
"""Our GTK panel, using the Frame's existing GTK4/GI platform libraries."""
def __init__(self):
os.environ["GDK_BACKEND"] = "x11"
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkX11", "4.0")
from gi.repository import Gtk, Gdk, GdkX11, GLib
Gtk.init()
self.running = True
self.window = Gtk.Window(title="Frame Control · Heart rate")
self.window.set_default_size(480, 320)
self.window.connect("close-request", self.stop)
Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
box.set_valign(Gtk.Align.CENTER)
box.set_halign(Gtk.Align.CENTER)
box.set_size_request(440, -1)
for side in ("top", "bottom", "start", "end"):
getattr(box, "set_margin_" + side)(24)
self.window.set_child(box)
title = Gtk.Label(label="Heart rate")
title.add_css_class("title-2")
box.append(title)
self.reading = Gtk.Label(label="—")
self.reading.add_css_class("reading")
box.append(self.reading)
self.status = Gtk.Label(label="Waiting for strap")
box.append(self.status)
button = Gtk.Button(label="Stop")
button.connect("clicked", self.stop)
box.append(button)
css = Gtk.CssProvider()
css.load_from_data(b".reading { font-size: 144px; font-weight: 700; }")
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
self.window.present()
context = GLib.MainContext.default()
while context.pending():
context.iteration(False)
try:
xid = GdkX11.X11Surface.get_xid(self.window.get_surface())
subprocess.run(["xprop", "-id", str(xid), "-f", "STEAM_GAME", "32c", "-set", "STEAM_GAME", "2000000027"],
check=True, stdout=subprocess.DEVNULL)
except Exception:
self.window.destroy()
raise
def stop(self, *args):
self.running = False
return True
def update(self, bpm):
self.reading.set_label(str(bpm) if bpm is not None else "—")
self.status.set_label("beats per minute" if bpm is not None else "Waiting for strap")
def close(self):
self.window.destroy()
def run_heart(args, osc):
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
DBusGMainLoop(set_as_default=True)
session = HeartSession(osc, args.address, args.log)
reader, root = None, None
failure = []
def value(data):
if data is None:
session.bpm = None
failure.append("Strap disconnected; reconnect and start again")
return
try:
session.notification(data)
except ValueError:
session.bpm = None
except OSError:
failure.append("OSC or session log write failed")
try:
reader = BluezHeart(dbus.SystemBus(), dbus.Interface, args.device, value)
context = GLib.MainContext.default()
deadline = time.monotonic() + 20
while not reader.subscribe():
if time.monotonic() > deadline:
raise TrackingError("Timed out waiting for the strap's GATT services")
while context.pending():
context.iteration(False)
time.sleep(0.1)
end = time.monotonic() + args.seconds
print("Heart-rate notifications started; readings stay local unless OSC or a log was selected.")
if args.panel:
root = HeartPanel()
running = lambda: root.running if root else True
while running() and time.monotonic() < end and not failure:
while context.pending():
context.iteration(False)
if root:
root.update(session.current())
time.sleep(0.05)
if failure:
raise TrackingError(failure[0])
return 0
finally:
if root:
root.close()
try:
if reader:
reader.close()
finally:
session.close()
def run_pulse(args, osc):
"""Experimental: estimate pulse from the IR eye cameras (see pulse.py)."""
import pulse
if not pulse.ET_BIN.exists():
raise TrackingError("SteamVR's eye-tracking tool is not installed on this Frame")
print(f"Capturing {args.seconds} s from the eye cameras. Wear the headset and keep still.", flush=True)
readings = [pulse.proximity()]
try:
frames = pulse.Capture(args.seconds).run()
readings.append(pulse.proximity())
except RuntimeError as error: # capture status only; no image data
raise TrackingError(str(error))
print(f"Captured {len(frames)} eye frames; images already deleted. Analysing...", flush=True)
try:
result = pulse.analyse(frames)
except ValueError as error:
raise TrackingError(str(error))
on_face = all(pulse.worn(reading) for reading in readings)
if not on_face:
# Unworn, the cameras still show a steady periodic artifact that looks
# like a pulse (verified on the Frame), so it is never called clear.
print("The proximity sensor says the headset is not being worn; no pulse can be read.")
clear = on_face and pulse.reliable(result)
offset = time.time() - time.monotonic() # the capture's timestamps are CLOCK_MONOTONIC
if args.log:
fd = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as log:
log.write("unix_seconds,bpm\n")
for when, bpm in result["series"]:
log.write(f"{when + offset:.3f},{bpm:.1f}\n")
if clear:
osc.send(args.address, [round(result["bpm"])])
if args.show:
print(f"Estimate: {result['bpm']:.1f} BPM ({'clear' if clear else 'NOT clear'}; "
f"patch agreement {result['agreement']:.0%}, signal/noise {result['snr']:.2f}, "
f"{result['patches']} of {result['usable']} usable patches)")
print("Per-second estimates (15 s windows): "
+ " ".join(str(round(bpm)) for _, bpm in result["series"]))
else:
print("A clear pulse was found." if clear else "No clear pulse was found.")
return 0 if clear else 3
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
gaze = commands.add_parser("gaze", help="headless OpenXR; prints counters only without --osc")
heart = commands.add_parser("heart", help="standard BLE HRS from an explicitly selected strap")
pulse = commands.add_parser("pulse", help="experimental pulse estimate from the IR eye cameras")
for command in (gaze, heart, pulse):
command.add_argument("--osc", nargs=2, metavar=("IP", "PORT"), help="explicit UDP destination; no default")
for command in (gaze, heart):
command.add_argument("--seconds", type=int, default=10, help="bounded run, 1..86400 seconds (default: 10)")
pulse.add_argument("--seconds", type=int, default=60, help="capture length, 20..300 seconds (default: 60)")
heart.add_argument("--device", required=True, help="strap Bluetooth address already discovered by BlueZ")
heart.add_argument("--panel", action="store_true", help="show our panel on gamescope DISPLAY=:0")
for command in (heart, pulse):
command.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter")
command.add_argument("--log", help="new private CSV file; disabled by default")
pulse.add_argument("--show", action="store_true", help="print the estimate and per-second series")
args = parser.parse_args()
if not 1 <= args.seconds <= 86400:
parser.error("--seconds must be 1..86400")
if args.command == "pulse" and not 20 <= args.seconds <= 300:
parser.error("pulse --seconds must be 20..300")
def interrupted(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, interrupted)
osc = None
try:
if args.command in ("heart", "pulse"):
osc_message(args.address, [0])
if getattr(args, "panel", False):
os.environ["DISPLAY"] = ":0"
osc = Osc(args.osc)
run = {"gaze": run_gaze, "heart": run_heart, "pulse": run_pulse}[args.command]
return run(args, osc)
except TrackingError as error:
print(str(error))
return 1
except KeyboardInterrupt:
return 130
except Exception as error:
# Never dump notifications, gaze, BLE addresses or exception payloads.
print(f"Tracking stopped ({type(error).__name__}). Check the device, runtime and selected output.")
return 1
finally:
if osc:
osc.close()
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
xcuserdata/
*.xcuserstate
build/
DerivedData/
+539
View File
@@ -0,0 +1,539 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
41A697B9924018DA48F24A1F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237D9AF04EEA257AB382F60E /* Keys.swift */; };
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */; };
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */; };
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */; };
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 72E728699F904E68DEC369D3 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 1015B8BE90EB02C2062752A1;
remoteInfo = FrameControl;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlApp.swift; sourceTree = "<group>"; };
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortForwarder.swift; sourceTree = "<group>"; };
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
35C098707058D19A2E23092E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1518C8775325C731AD7E2421 = {
isa = PBXGroup;
children = (
B4F84A5777E9EFEAEB54DB91 /* FrameControl */,
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */,
59B34B34E2BE8BCD237BCF26 /* Products */,
);
sourceTree = "<group>";
};
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
isa = PBXGroup;
children = (
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */,
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
237D9AF04EEA257AB382F60E /* Keys.swift */,
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */,
);
path = SSH;
sourceTree = "<group>";
};
59B34B34E2BE8BCD237BCF26 /* Products */ = {
isa = PBXGroup;
children = (
F3E2F5607DD877272483D64E /* FrameControl.app */,
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
68AF00C8593B71502E1FB72B /* App */ = {
isa = PBXGroup;
children = (
8A11F3431826A26B247C0695 /* AppModel.swift */,
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
);
path = App;
sourceTree = "<group>";
};
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
isa = PBXGroup;
children = (
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
);
path = FrameControlTests;
sourceTree = "<group>";
};
A939D1267299AE8A48092557 /* Views */ = {
isa = PBXGroup;
children = (
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */,
DB544223FC60A59CC3E8EF5F /* SetupView.swift */,
);
path = Views;
sourceTree = "<group>";
};
B4F84A5777E9EFEAEB54DB91 /* FrameControl */ = {
isa = PBXGroup;
children = (
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */,
6B5B6718C5EA77FA67F6B14C /* Info.plist */,
68AF00C8593B71502E1FB72B /* App */,
5064A5B6FE5B17B18E5FA4B8 /* SSH */,
A939D1267299AE8A48092557 /* Views */,
CC25EAB6C385A68D63F7DDF7 /* Web */,
);
path = FrameControl;
sourceTree = "<group>";
};
CC25EAB6C385A68D63F7DDF7 /* Web */ = {
isa = PBXGroup;
children = (
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */,
);
path = Web;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1015B8BE90EB02C2062752A1 /* FrameControl */ = {
isa = PBXNativeTarget;
buildConfigurationList = F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */;
buildPhases = (
81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */,
D452F3AE39D323226E2E4D1D /* Sources */,
B6E396EEA6D8BB0EE84E01A4 /* Resources */,
35C098707058D19A2E23092E /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = FrameControl;
packageProductDependencies = (
6BA549B6CC0A0CB847126456 /* Citadel */,
);
productName = FrameControl;
productReference = F3E2F5607DD877272483D64E /* FrameControl.app */;
productType = "com.apple.product-type.application";
};
88565F33E966FD0BAC7AC9C8 /* FrameControlTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */;
buildPhases = (
F220B2041FE675A075E860BB /* Sources */,
);
buildRules = (
);
dependencies = (
B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */,
);
name = FrameControlTests;
packageProductDependencies = (
);
productName = FrameControlTests;
productReference = 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
72E728699F904E68DEC369D3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 1518C8775325C731AD7E2421;
minimizedProjectReferenceProxies = 1;
packageReferences = (
AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 59B34B34E2BE8BCD237BCF26 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1015B8BE90EB02C2062752A1 /* FrameControl */,
88565F33E966FD0BAC7AC9C8 /* FrameControlTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
B6E396EEA6D8BB0EE84E01A4 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
84423CB45629465420180A64 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Pack the Frame bundle";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "mkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"${SRCROOT}/scripts/make_frame_bundle.py\" \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" > \"${DERIVED_FILE_DIR}/frame-bundle.version\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" \"${DERIVED_FILE_DIR}/frame-bundle.version\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
D452F3AE39D323226E2E4D1D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
41A697B9924018DA48F24A1F /* Keys.swift in Sources */,
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */,
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */,
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */,
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F220B2041FE675A075E860BB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 1015B8BE90EB02C2062752A1 /* FrameControl */;
targetProxy = E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
0B43879551190738EFF21848 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FrameControl/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol;
PRODUCT_NAME = "Frame Control";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
3228B6B229BF6430C8338B55 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 0.1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
54BEF779B5906F671E4134CE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 0.1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
57A1F4BD520A2EDA424181E8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control";
};
name = Release;
};
6E69BB8A560DC32B8D0E10A6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control";
};
name = Debug;
};
C7FCE7EB18B4AEF8EFEC8FDE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FrameControl/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol;
PRODUCT_NAME = "Frame Control";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6E69BB8A560DC32B8D0E10A6 /* Debug */,
57A1F4BD520A2EDA424181E8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */ = {
isa = XCConfigurationList;
buildConfigurations = (
54BEF779B5906F671E4134CE /* Debug */,
3228B6B229BF6430C8338B55 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C7FCE7EB18B4AEF8EFEC8FDE /* Debug */,
0B43879551190738EFF21848 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/orlandos-nl/Citadel.git";
requirement = {
kind = exactVersion;
version = 0.12.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
6BA549B6CC0A0CB847126456 /* Citadel */ = {
isa = XCSwiftPackageProductDependency;
package = AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */;
productName = Citadel;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 72E728699F904E68DEC369D3 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,96 @@
{
"originHash" : "06e1233a9a9b220c5f5b14eefc3220aa9e394ac504fece9df28b2a550b7d6017",
"pins" : [
{
"identity" : "bigint",
"kind" : "remoteSourceControl",
"location" : "https://github.com/attaswift/BigInt.git",
"state" : {
"revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe",
"version" : "5.7.0"
}
},
{
"identity" : "citadel",
"kind" : "remoteSourceControl",
"location" : "https://github.com/orlandos-nl/Citadel.git",
"state" : {
"revision" : "ae8562f895de06ccb86fdb1cbb65fd99c8976e12",
"version" : "0.12.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2",
"version" : "1.7.3"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
"version" : "1.3.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6",
"version" : "1.7.1"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
"version" : "3.15.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb",
"version" : "1.15.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d",
"version" : "2.103.0"
}
},
{
"identity" : "swift-nio-ssh",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Wellz26/swift-nio-ssh.git",
"state" : {
"revision" : "d88989f3d3bb1dfb2a38ce4af598afbf7fc3095c",
"version" : "0.3.7"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750",
"version" : "1.8.1"
}
}
],
"version" : 3
}
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
BuildableName = "FrameControlTests.xctest"
BlueprintName = "FrameControlTests"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
BuildableName = "FrameControlTests.xctest"
BlueprintName = "FrameControlTests"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
BuildableName = "FrameControl.app"
BlueprintName = "FrameControl"
ReferencedContainer = "container:FrameControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+291
View File
@@ -0,0 +1,291 @@
import Citadel
import Foundation
import SwiftUI
import UIKit
/// The app's one piece of state: which headset, and how far along connecting to it is.
@MainActor
final class AppModel: ObservableObject {
enum Phase: Equatable {
case setup
case connecting(String)
case ready(URL)
case failed(String)
}
@Published private(set) var phase: Phase
@Published private(set) var settings: FrameSettings?
/// Install links that arrived before the page was ready for them.
@Published var pendingInstallLinks: [InstallLink] = []
private var link: FrameLink?
private var server: HeadsetServer?
private var forwarder: PortForwarder?
private var attempt = 0
private static let settingsKey = "frame.settings"
private static let hostKeyKey = "frame.hostKey"
init() {
let saved = UserDefaults.standard.data(forKey: Self.settingsKey).flatMap { try? JSONDecoder().decode(FrameSettings.self, from: $0) }
settings = saved
phase = saved == nil ? .setup : .connecting("Connecting")
}
var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" }
private var hostKey: String? { UserDefaults.standard.string(forKey: Self.hostKeyKey) }
// MARK: pairing
/// First time: log in with the Developer Mode password, add this phone's key to
/// ~/.ssh/authorized_keys, record the Frame's host key, then connect with the key.
func pair(host: String, user: String, password: String) async {
guard let target = Self.parse(host: host, user: user) else {
fail("Enter the headset's address and user name.", retry: false)
return
}
invalidate()
let mine = attempt
await teardown()
guard mine == attempt else { return }
phase = .connecting("Signing in to \(target.host)")
let pin = PinnedHostKey(expected: nil)
do {
let link = try await FrameLink.connect(target, auth: .passwordBased(username: target.user, password: password), hostKey: pin)
defer { Task { await link.close() } }
guard mine == attempt else { return }
phase = .connecting("Adding this \(deviceName)'s key")
let line = authorizedKeysLine
// A file whose last line has no newline would otherwise swallow the key.
let file = "~/.ssh/authorized_keys"
try await link.check("umask 077; mkdir -p ~/.ssh && touch \(file) && "
+ "{ grep -qxF \(shellQuote(line)) \(file) || { "
+ "[ -s \(file) ] && [ -n \"$(tail -c 1 \(file))\" ] && printf '\\n' >> \(file); "
+ "printf '%s\\n' \(shellQuote(line)) >> \(file); }; }",
"Couldn't add the key on the Frame")
guard mine == attempt else { return } // cancelled meanwhile: save nothing
guard let seen = pin.seen else { throw FrameFailure("The Frame didn't show a host key") }
UserDefaults.standard.set(seen, forKey: Self.hostKeyKey)
UserDefaults.standard.set(try JSONEncoder().encode(target), forKey: Self.settingsKey)
settings = target
} catch {
guard mine == attempt else { return }
let failure = error as? FrameFailure
fail(failure?.message ?? FrameLink.describe(error, host: target.host), retry: false,
needsPairing: failure?.needsPairing ?? false)
return
}
await connect()
}
/// For someone who added this phone's key to the Frame themselves: no password.
/// The Frame's host key is recorded on this first connection.
func useKey(host: String, user: String) async {
guard let target = Self.parse(host: host, user: user) else {
fail("Enter the headset's address and user name.", retry: false)
return
}
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
UserDefaults.standard.set(try? JSONEncoder().encode(target), forKey: Self.settingsKey)
settings = target
await connect()
}
/// "host", "host:port" or "[v6]:port", plus a user name.
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
let rest = target.host[target.host.index(after: close)...]
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
let port = Int(target.host[target.host.index(after: colon)...]) {
target.port = port
target.host = String(target.host[..<colon])
}
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
return target
}
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
var authorizedKeysLine: String {
DeviceKey.authorizedKeysLine(DeviceKey.loadOrCreate(), comment: "frame-control@\(deviceName)")
}
/// Forget the headset: back to the pairing screen. The Frame keeps the key line;
/// remove it from ~/.ssh/authorized_keys there to revoke this phone.
func forget() async {
invalidate()
await teardown()
UserDefaults.standard.removeObject(forKey: Self.settingsKey)
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
settings = nil
phase = .setup
}
func showSetup() {
invalidate()
Task { await teardown() }
phase = .setup
}
/// Whether the failure screen is retrying on its own.
@Published private(set) var retrying = false
// MARK: connecting
/// Every connection attempt has a number; anything that finishes after a newer
/// attempt started (or the user went back to setup) closes what it made and stops.
private func invalidate() {
attempt += 1
retrying = false
}
/// quiet: a background retry, which leaves the failure screen up until it works.
func connect(quiet: Bool = false) async {
guard let settings else {
phase = .setup
return
}
invalidate()
let mine = attempt
await teardown()
func current() -> Bool { mine == attempt }
func step(_ s: String) { if current() && !quiet { phase = .connecting(s) } }
step("Connecting to \(settings.host)")
var link: FrameLink?
var forwarder: PortForwarder?
do {
let bundle = try HeadsetServer.Bundle.fromApp()
let auth = SSHAuthenticationMethod.ed25519(username: settings.user, privateKey: DeviceKey.loadOrCreate())
let pin = PinnedHostKey(expected: hostKey)
let l = try await FrameLink.connect(settings, auth: auth, hostKey: pin)
link = l
guard current() else { throw CancellationError() }
if hostKey == nil, let seen = pin.seen { UserDefaults.standard.set(seen, forKey: Self.hostKeyKey) }
let dir = try await HeadsetServer.deploy(bundle, over: l) { s in Task { @MainActor in step(s) } }
guard current() else { throw CancellationError() }
step("Starting Frame Control on the headset")
let key = Self.randomKey()
let server = try await HeadsetServer.start(in: dir, over: l, key: key, device: deviceName)
guard current() else { throw CancellationError() }
let f = try await PortForwarder.start(over: l, to: server.port)
forwarder = f
guard current() else { throw CancellationError() }
if let tail = server.exited { // stopped while the tunnel was opening
throw FrameFailure("Frame Control on the headset stopped. \(tail.suffix(200))")
}
// Only now does this attempt's connection become the app's.
self.link = l
self.server = server
self.forwarder = f
readySince = Date()
var page = "http://127.0.0.1:\(f.localPort)/?key=\(key)"
#if DEBUG
// Test hooks for the Simulator: open on a given tab, and leave the URL where
// a test can drive the same tunnel (`simctl get_app_container … data`).
if let tab = ProcessInfo.processInfo.environment["FRAME_TEST_PAGE"] { page += "#\(tab)" }
if let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
try? page.write(to: dir.appendingPathComponent("frame-test-url.txt"), atomically: true, encoding: .utf8)
}
#endif
phase = .ready(URL(string: page)!)
// Runs at once if it stopped in the moment since the check above.
server.whenExited { [weak self] tail in
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
}
watchHealth(mine)
} catch {
forwarder?.stop()
if let link { await link.close() } // ends its server too
guard current(), !(error is CancellationError) else { return }
let failure = error as? FrameFailure
fail(failure?.message ?? FrameLink.describe(error, host: settings.host), retry: !(failure?.needsPairing ?? false),
needsPairing: failure?.needsPairing ?? false)
}
}
/// Whether the last failure needs the user to pair again rather than wait.
@Published private(set) var needsPairing = false
private func fail(_ message: String, retry: Bool, needsPairing: Bool = false) {
self.needsPairing = needsPairing
phase = .failed(message)
retrying = retry && settings != nil
guard retrying else { return }
// Keep trying quietly while the app is open: the Frame may just be asleep.
// A new task each time, so retrying for hours doesn't nest awaits.
let mine = attempt
Task { [weak self] in
try? await Task.sleep(nanoseconds: 10_000_000_000)
guard let self, mine == self.attempt, case .failed = self.phase,
UIApplication.shared.applicationState == .active else { return }
await self.connect(quiet: true)
}
}
/// While connected, check every 20 s that the SSH session still answers: a
/// network change can leave it looking open while nothing gets through.
private func watchHealth(_ mine: Int) {
Task { [weak self] in
while true {
try? await Task.sleep(nanoseconds: 20_000_000_000)
guard let self, mine == self.attempt, case .ready = self.phase else { return }
if UIApplication.shared.applicationState != .active { continue }
if await !(self.link?.answers() ?? false) {
guard mine == self.attempt else { return }
await self.connect(quiet: true)
return
}
}
}
}
/// Called when the app comes back to the foreground: iOS may have dropped the
/// connection, or left it looking open, while it was in the background.
func resume() {
switch phase {
case .ready:
let mine = attempt
Task {
let ok = await link?.answers() ?? false
if (!ok || server?.exited != nil), mine == attempt { await connect() }
}
case .failed:
// A changed identity or a refused login needs the user, not another try.
if settings != nil, !needsPairing { Task { await connect() } }
default:
break
}
}
private var readySince = Date.distantPast
private func lost(_ which: Int, _ why: String) {
guard which == attempt, case .ready = phase else { return }
// Restart it once; if it dies again straight away, say so instead of looping.
if Date().timeIntervalSince(readySince) < 20 {
invalidate()
Task { await teardown() }
fail(why, retry: false)
} else {
Task { await connect() }
}
}
private func teardown() async {
forwarder?.stop()
forwarder = nil
server = nil
if let link {
self.link = nil
await link.close() // ends the server too: its stdin closes
}
}
private static func randomKey() -> String {
var bytes = [UInt8](repeating: 0, count: 24)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
}
@@ -0,0 +1,44 @@
import SwiftUI
@main
struct FrameControlApp: App {
@StateObject private var model = AppModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootView(model: model)
.task {
#if DEBUG
// Simulator testing without the pairing screen: print this device's key,
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
// FRAME_TEST_LANDSCAPE=1 turns the app on its side, to check the safe areas there.
if ProcessInfo.processInfo.environment["FRAME_TEST_LANDSCAPE"] != nil,
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))
}
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
let f = pair.components(separatedBy: "|")
if f.count == 3 { await model.pair(host: f[0], user: f[1], password: f[2]); return }
}
if model.settings == nil, let host = ProcessInfo.processInfo.environment["FRAME_TEST_HOST"] {
await model.useKey(host: host, user: "steamos")
return
}
#endif
if model.settings != nil { await model.connect() }
}
.onOpenURL { url in
// frame-control://install?… from a website (docs/web-install.md).
guard let link = InstallLink(url.absoluteString), model.pendingInstallLinks.count < 5 else { return }
model.pendingInstallLinks.append(link)
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { model.resume() }
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import Foundation
/// frame-control://install?manifest=URL or ?url=URL (docs/web-install.md), the same
/// first filter as app/install-link.js. The server on the Frame applies the full
/// rules (HTTPS, no private addresses, redirects) before fetching anything.
struct InstallLink: Equatable {
enum Kind: String { case manifest, url }
let kind: Kind
let target: String
static let scheme = "frame-control"
private static let maxLink = 4096
private static let maxURL = 2048
init?(_ raw: String) {
guard raw.count <= Self.maxLink, raw.lowercased().hasPrefix("\(Self.scheme):"),
let link = URLComponents(string: raw), link.scheme?.lowercased() == Self.scheme,
link.host?.lowercased() == "install", ["", "/"].contains(link.path) else { return nil }
let items = link.queryItems ?? []
guard items.count == 1, let item = items.first, let kind = Kind(rawValue: item.name),
let target = item.value, !target.isEmpty, target.count <= Self.maxURL,
let url = URLComponents(string: target), ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
url.host?.isEmpty == false, url.user == nil, url.password == nil else { return nil }
self.kind = kind
self.target = target
}
}
@@ -0,0 +1,4 @@
{
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0xFF", "green" : "0x9F", "red" : "0x1A" } }, "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1,4 @@
{
"images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

@@ -0,0 +1 @@
{ "images" : [ { "filename" : "icon.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Frame Control</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.saphid.framecontrol.install</string>
<key>CFBundleURLSchemes</key>
<array>
<string>frame-control</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>ssh</string>
<string>sftp</string>
<string>steamlink</string>
<string>rdp</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_steamos-devkit._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Frame Control finds your Steam Frame on your network and connects to it.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Frame Control saves headset captures and screenshots to your photo library when you ask it to.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>
+125
View File
@@ -0,0 +1,125 @@
import Foundation
import Network
/// Finds the Frame on the local network so nobody has to type its address.
/// A Frame in Developer Mode advertises Valve's devkit service over Bonjour
/// (`_steamos-devkit._tcp`); failing that, `fallback` (the saved address, or
/// frame.local) is checked by opening its SSH port. Both repeat until stopped.
@MainActor
final class FrameFinder: ObservableObject {
struct Found: Equatable {
let host: String // what to connect to
let name: String // what to call it
}
@Published private(set) var found: Found?
/// When the search began, to tell "still looking" from "can't find it".
@Published private(set) var since = Date()
private var browser: NWBrowser?
private var probeTask: Task<Void, Never>?
private var fallback = "frame.local"
func start(fallback: String?) {
stop()
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
found = nil
since = Date()
browse()
probeTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let host = self.fallback
if self.found == nil, await Self.sshAnswers(host: host) {
self.found = Found(host: host, name: host)
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
func stop() {
browser?.cancel()
browser = nil
probeTask?.cancel()
probeTask = nil
}
private func browse() {
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { [weak self] results, _ in
for result in results {
guard case let .service(name, _, _, _) = result.endpoint else { continue }
Self.resolve(result.endpoint) { host in
Task { @MainActor in
guard let self, let host else { return }
// A found device is used over the fallback probe.
self.found = Found(host: host, name: name)
}
}
}
}
browser.start(queue: .main)
self.browser = browser
}
/// The device's IP address: connect to the service and read where it went.
nonisolated private static func resolve(_ endpoint: NWEndpoint, done: @escaping @Sendable (String?) -> Void) {
let connection = NWConnection(to: endpoint, using: .tcp)
let once = Once()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
var host: String?
if case let .hostPort(h, _)? = connection.currentPath?.remoteEndpoint {
host = "\(h)".components(separatedBy: "%").first // drop an IPv6 interface suffix
}
connection.cancel()
if once.claim() { done(host) }
case .failed, .cancelled:
if once.claim() { done(nil) }
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
connection.cancel()
if once.claim() { done(nil) }
}
}
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
nonisolated static func sshAnswers(host address: String) async -> Bool {
var host = address, port: UInt16 = 22
if let target = AppModel.parse(host: address, user: "steamos") {
host = target.host
port = UInt16(target.port)
}
return await sshAnswers(host: host, port: port)
}
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
let connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port) ?? 22, using: .tcp)
let once = Once()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
connection.cancel()
if once.claim() { c.resume(returning: true) }
case .failed, .waiting:
connection.cancel()
if once.claim() { c.resume(returning: false) }
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
connection.cancel()
if once.claim() { c.resume(returning: false) }
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import Citadel
import CryptoKit
import Foundation
import NIOCore
import NIOSSH
/// Where the Frame is and who to log in as.
struct FrameSettings: Codable, Equatable {
var host: String
var port: Int = 22
var user: String = "steamos"
}
struct FrameFailure: LocalizedError {
let message: String
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
var needsPairing = false
init(_ message: String, needsPairing: Bool = false) {
self.message = message
self.needsPairing = needsPairing
}
var errorDescription: String? { message }
}
/// Trust on first use: pairing records the Frame's host key; later connections
/// accept that key and nothing else, as ssh's known_hosts does.
final class PinnedHostKey: NIOSSHClientServerAuthenticationDelegate, @unchecked Sendable {
struct Changed: Error {}
let expected: String?
private let lock = NSLock()
private var _seen: String?
var seen: String? { lock.withLock { _seen } }
init(expected: String?) { self.expected = expected }
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
let key = String(openSSHPublicKey: hostKey)
lock.withLock { _seen = key }
if expected == nil || expected == key {
validationCompletePromise.succeed(())
} else {
validationCompletePromise.fail(Changed())
}
}
}
/// One SSH connection to the Frame, and the few things the app does over it.
final class FrameLink: @unchecked Sendable {
let client: SSHClient
private init(client: SSHClient) { self.client = client }
static func connect(_ settings: FrameSettings, auth: SSHAuthenticationMethod, hostKey: PinnedHostKey) async throws -> FrameLink {
do {
let client = try await SSHClient.connect(
host: settings.host, port: settings.port, authenticationMethod: auth,
hostKeyValidator: .custom(hostKey), reconnect: .never, connectTimeout: .seconds(8))
return FrameLink(client: client)
} catch {
let text = String(describing: error)
throw FrameFailure(describe(error, host: settings.host),
needsPairing: error is PinnedHostKey.Changed || text.contains("allAuthenticationOptionsFailed"))
}
}
/// The plain-language reason a connection failed, like the desktop server's messages.
static func describe(_ error: Error, host: String) -> String {
if error is PinnedHostKey.Changed {
return "The Frame's SSH identity changed (after a reinstall, or a different device at \(host)). Pair again."
}
let text = String(describing: error)
if text.contains("allAuthenticationOptionsFailed") || text.contains("authentication") {
return "The Frame didn't accept the login. Pair again, and check the Developer Mode password."
}
if ["timeout", "Timeout", "timed out", "Host is down", "No route to host", "Network is unreachable",
"errno: 64", "errno: 65", "errno: 51", "errno: 60"].contains(where: text.contains) {
return "The Frame isn't answering at \(host). It may be asleep, switched off, or on another network."
}
if text.contains("refused") || text.contains("ECONNREFUSED") {
return "The Frame refused the connection at \(host). Check Developer Mode is still on."
}
if text.contains("NXDOMAIN") || text.contains("resolve") || text.contains("unknownHost") || text.contains("NoAddress") {
return "Can't find \(host) on the network. Check the address, and that the Frame is on the same network."
}
return "Couldn't connect to \(host): \(text)"
}
var isConnected: Bool { client.isConnected }
/// Whether the Frame answers a trivial command within a few seconds. The probe
/// runs unstructured: a dead link can keep it waiting well past the deadline,
/// and the answer mustn't wait for it.
func answers(within seconds: Double = 6) async -> Bool {
guard client.isConnected else { return false }
let once = Once()
return await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
Task { let ok = (try? await self.run("true").status) == 0; if once.claim() { c.resume(returning: ok) } }
Task { try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9)); if once.claim() { c.resume(returning: false) } }
}
}
func close() async {
try? await client.close()
}
/// Runs a shell command; returns its combined output and exit status.
func run(_ command: String) async throws -> (output: String, status: Int) {
// stderr joins stdout (Citadel treats any stderr as a failure), and the
// status comes back as the last line so a non-zero exit isn't an exception.
let buffer = try await client.executeCommand("{ \(command)\n} 2>&1; echo \"@@rc=$?\"")
var text = String(buffer: buffer)
var status = 0
if let range = text.range(of: "@@rc=", options: .backwards) {
status = Int(text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1
text = String(text[..<range.lowerBound])
}
return (text.trimmingCharacters(in: .whitespacesAndNewlines), status)
}
/// Runs a command that must succeed; its output, or a FrameFailure with it.
@discardableResult
func check(_ command: String, _ what: String) async throws -> String {
let r = try await run(command)
guard r.status == 0 else { throw FrameFailure("\(what): \(r.output.isEmpty ? "exit \(r.status)" : r.output)") }
return r.output
}
/// Writes data to a path relative to the home directory.
func upload(_ data: Data, to path: String) async throws {
let sftp = try await client.openSFTP()
do {
try await sftp.withFile(filePath: path, flags: [.write, .create, .truncate]) { file in
try await file.write(ByteBuffer(bytes: data))
}
try? await sftp.close()
} catch {
try? await sftp.close()
throw error
}
}
}
/// True for the first caller only.
final class Once: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
}
func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
+177
View File
@@ -0,0 +1,177 @@
import Citadel
import Foundation
import NIOCore
/// Frame Control's server, running on the Frame itself. The app copies the bundle
/// (ios/scripts/make_frame_bundle.py) to ~/.cache/frame-control/<version> once per
/// version, then starts ui/server.py there over SSH. It listens only on the Frame's
/// 127.0.0.1, and it exits when this SSH session ends (--exit-on-eof).
final class HeadsetServer: @unchecked Sendable {
let port: Int
private let lock = NSLock()
private var _exited: String?
private var onExit: (@Sendable (String) -> Void)?
/// Set once the server stops, with its last output.
var exited: String? { lock.withLock { _exited } }
private init(port: Int) { self.port = port }
/// Calls back once when the server stops, at once if it already has.
func whenExited(_ callback: @escaping @Sendable (String) -> Void) {
let already: String? = lock.withLock {
if _exited == nil { onExit = callback }
return _exited
}
if let already { callback(already) }
}
fileprivate func markExited(_ tail: String) {
let callback: (@Sendable (String) -> Void)? = lock.withLock {
guard _exited == nil else { return nil }
_exited = tail
defer { onExit = nil }
return onExit
}
callback?(tail)
}
static let cacheDir = ".cache/frame-control"
struct Bundle {
let data: Data
let version: String
static func fromApp() throws -> Bundle {
guard let url = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "tar.gz"),
let data = try? Data(contentsOf: url),
let vurl = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "version"),
let version = try? String(contentsOf: vurl, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
version.range(of: "^[0-9a-f]{16}$", options: .regularExpression) != nil else {
throw FrameFailure("This build of the app is missing its Frame bundle")
}
return Bundle(data: data, version: version)
}
}
/// Copies the bundle over unless this version is already there; removes older versions.
static func deploy(_ bundle: Bundle, over link: FrameLink, progress: @escaping @Sendable (String) -> Void) async throws -> String {
let dir = "\(cacheDir)/\(bundle.version)"
let py = try await link.run("command -v python3 >/dev/null && python3 -c 'import sys; print(sys.version_info >= (3, 8))'")
guard py.status == 0, py.output.hasSuffix("True") else {
throw FrameFailure("The Frame has no Python 3.8 or later, which Frame Control needs there.")
}
if try await link.run("test -f \(dir)/ui/server.py").status != 0 {
progress("Copying Frame Control to the headset")
try await link.check("mkdir -p \(cacheDir)", "Couldn't make \(cacheDir)")
let archive = "\(dir).tar.gz"
try await link.upload(bundle.data, to: archive)
progress("Unpacking")
try await link.check("rm -rf \(dir).tmp && mkdir \(dir).tmp && tar xzf \(archive) -C \(dir).tmp && rm -f \(archive) "
+ "&& rm -rf \(dir) && mv \(dir).tmp \(dir)", "Couldn't unpack Frame Control on the headset")
}
// Another phone or iPad may be running a different version right now: a version
// goes only when no server runs from it and it hasn't been used for two weeks
// (this one is marked as used). Servers run by absolute path, so pgrep sees it.
_ = try? await link.run("touch \(dir) && cd \(cacheDir) && for d in */; do d=${d%/}; "
+ "[ \"$d\" = \(bundle.version) ] && continue; "
+ "[ -n \"$(find \"$d\" -maxdepth 0 -mtime +14)\" ] || continue; "
+ "pgrep -f \"$PWD/$d/\" >/dev/null && continue; rm -rf -- \"$d\"; done")
return dir
}
/// Starts the server in dir and waits for it to say which port it took.
static func start(in dir: String, over link: FrameLink, key: String, device: String) async throws -> HeadsetServer {
let command = "cd \(dir) && FRAME_LOCAL=1 FRAME_UI_KEY=\(key) FRAME_DEVICE=\(shellQuote(device)) "
+ "exec python3 -I -u -B \"$PWD/ui/server.py\" --port 0 --exit-on-eof 2>&1"
let stream = try await link.client.executeCommandStream(command)
let box = PortWaiter()
let reader = Task { () -> Void in
var text = ""
do {
for try await chunk in stream {
switch chunk {
case .stdout(let b), .stderr(let b): text += String(buffer: b)
}
if text.count > 20_000 { text = String(text.suffix(10_000)) }
if let port = Self.port(in: text) { box.found(port) }
}
} catch {
text += "\n\(error)"
}
box.ended(text)
}
let server: HeadsetServer
do {
server = HeadsetServer(port: try await box.wait(seconds: 30))
} catch {
reader.cancel()
throw error
}
box.whenEnded { [weak server] tail in server?.markExited(tail) }
return server
}
/// The port from the server's first line. Output arrives in chunks, so the digits
/// only count once something follows them (the line goes on after the port).
static func port(in text: String) -> Int? {
guard let r = text.range(of: #"Frame Control on http://127\.0\.0\.1:[0-9]+\s"#, options: .regularExpression),
let port = Int(text[r].dropLast().split(separator: ":").last ?? ""), (1...65535).contains(port) else { return nil }
return port
}
}
/// Hands the port from the output reader to start(), or the output if the server died first.
private final class PortWaiter: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Int, Error>?
private var result: Result<Int, Error>?
private var endedTail: String?
private var onEnd: (@Sendable (String) -> Void)?
/// Calls back when the output ends, at once if it already has.
func whenEnded(_ callback: @escaping @Sendable (String) -> Void) {
let already: String? = lock.withLock {
if endedTail == nil { onEnd = callback }
return endedTail
}
if let already { callback(already) }
}
func found(_ port: Int) { finish(.success(port)) }
func ended(_ text: String) {
let tail = String(text.suffix(600)).trimmingCharacters(in: .whitespacesAndNewlines)
let callback: (@Sendable (String) -> Void)? = lock.withLock {
endedTail = tail
defer { onEnd = nil }
return onEnd
}
finish(.failure(FrameFailure("Frame Control's server on the headset stopped: \(tail.isEmpty ? "no output" : tail)")))
callback?(tail)
}
private func finish(_ r: Result<Int, Error>) {
let c: CheckedContinuation<Int, Error>? = lock.withLock {
guard result == nil else { return nil }
result = r
defer { continuation = nil }
return continuation
}
c?.resume(with: r)
}
func wait(seconds: Double) async throws -> Int {
Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9))
self?.finish(.failure(FrameFailure("Frame Control's server on the headset didn't start within \(Int(seconds)) s")))
}
return try await withCheckedThrowingContinuation { c in
let done: Result<Int, Error>? = lock.withLock {
if let result { return result }
continuation = c
return nil
}
if let done { c.resume(with: done) }
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import CryptoKit
import Foundation
import NIOSSH
import Security
/// Small wrapper over the Keychain for this app's secrets.
enum Keychain {
private static let service = "com.saphid.framecontrol"
private static func query(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
kSecAttrAccount as String: account]
}
static func data(_ account: String) -> Data? {
var q = query(account)
q[kSecReturnData as String] = true
q[kSecMatchLimit as String] = kSecMatchLimitOne
var out: AnyObject?
return SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess ? out as? Data : nil
}
static func set(_ data: Data, _ account: String) {
SecItemDelete(query(account) as CFDictionary)
var q = query(account)
q[kSecValueData as String] = data
// Only on this device and not in backups: the key is this phone's identity.
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(q as CFDictionary, nil)
}
static func delete(_ account: String) {
SecItemDelete(query(account) as CFDictionary)
}
}
/// This phone's SSH key: ed25519, made once, kept in the Keychain.
enum DeviceKey {
private static let account = "ssh-ed25519"
static func loadOrCreate() -> Curve25519.Signing.PrivateKey {
if let raw = Keychain.data(account), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) {
return key
}
let key = Curve25519.Signing.PrivateKey()
Keychain.set(key.rawRepresentation, account)
return key
}
/// The line for ~/.ssh/authorized_keys, e.g. "ssh-ed25519 AAAA… frame-control@iPhone".
static func authorizedKeysLine(_ key: Curve25519.Signing.PrivateKey, comment: String) -> String {
String(openSSHPublicKey: NIOSSHPrivateKey(ed25519Key: key).publicKey) + " " + comment
}
}
+113
View File
@@ -0,0 +1,113 @@
import Citadel
import Foundation
import NIOCore
import NIOPosix
import NIOSSH
/// Listens on this phone's 127.0.0.1 and carries each connection to a port on the
/// Frame's 127.0.0.1 through the SSH session (ssh -L). The web view loads the
/// server from here; every API request still needs the session's key.
final class PortForwarder: @unchecked Sendable {
private let channel: Channel
let localPort: Int
private init(channel: Channel, localPort: Int) {
self.channel = channel
self.localPort = localPort
}
static func start(over link: FrameLink, to remotePort: Int) async throws -> PortForwarder {
let client = link.client
// The listener shares the SSH connection's event loop, so the glue between
// each pair of channels never crosses threads.
let bootstrap = ServerBootstrap(group: client.eventLoop)
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true)
// Nothing is read from the web view until the SSH side is ready for it.
.childChannelOption(ChannelOptions.autoRead, value: false)
.childChannelInitializer { inbound in
inbound.eventLoop.makeFutureWithTask {
let (local, remote) = GlueHandler.matchedPair()
try await inbound.pipeline.addHandler(local).get()
let origin = try inbound.remoteAddress ?? SocketAddress(ipAddress: "127.0.0.1", port: 0)
_ = try await client.createDirectTCPIPChannel(
using: SSHChannelType.DirectTCPIP(targetHost: "127.0.0.1", targetPort: remotePort, originatorAddress: origin)
) { channel in channel.pipeline.addHandler(remote) }
try await inbound.setOption(ChannelOptions.autoRead, value: true).get()
}
}
let channel = try await bootstrap.bind(host: "127.0.0.1", port: 0).get()
guard let port = channel.localAddress?.port else { throw FrameFailure("Couldn't open a local port") }
return PortForwarder(channel: channel, localPort: port)
}
func stop() {
channel.close(promise: nil)
}
}
/// Joins two channels: what one reads, the other writes, with backpressure and
/// half-close passed across (the pattern from SwiftNIO's examples).
final class GlueHandler: ChannelDuplexHandler, @unchecked Sendable {
typealias InboundIn = NIOAny
typealias OutboundIn = NIOAny
typealias OutboundOut = NIOAny
private var partner: GlueHandler?
private var context: ChannelHandlerContext?
private var pendingRead = false
static func matchedPair() -> (GlueHandler, GlueHandler) {
let a = GlueHandler(), b = GlueHandler()
a.partner = b
b.partner = a
return (a, b)
}
private func partnerWrite(_ data: NIOAny) { context?.write(data, promise: nil) }
private func partnerFlush() { context?.flush() }
private func partnerWriteEOF() { context?.close(mode: .output, promise: nil) }
private func partnerClose() { context?.close(promise: nil) }
private var partnerWritable: Bool { context?.channel.isWritable ?? false }
private func partnerBecameWritable() {
if pendingRead {
pendingRead = false
context?.read()
}
}
func handlerAdded(context: ChannelHandlerContext) { self.context = context }
func handlerRemoved(context: ChannelHandlerContext) {
self.context = nil
partner = nil
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) { partner?.partnerWrite(data) }
func channelReadComplete(context: ChannelHandlerContext) { partner?.partnerFlush() }
func channelInactive(context: ChannelHandlerContext) { partner?.partnerClose() }
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
if let e = event as? ChannelEvent, case .inputClosed = e {
partner?.partnerWriteEOF()
}
context.fireUserInboundEventTriggered(event)
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
partner?.partnerClose()
}
func channelWritabilityChanged(context: ChannelHandlerContext) {
if context.channel.isWritable { partner?.partnerBecameWritable() }
}
func read(context: ChannelHandlerContext) {
if let partner, partner.partnerWritable {
context.read()
} else {
pendingRead = true
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
struct RootView: View {
@ObservedObject var model: AppModel
var body: some View {
ZStack {
Color.frameBackground.ignoresSafeArea()
switch model.phase {
case .setup:
SetupView(model: model)
case .connecting(let step):
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
case .failed(let message) where model.retrying && !model.needsPairing:
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
case .failed(let message):
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
retry: { Task { await model.connect() } }, change: { model.showSetup() })
case .ready(let url):
WebShell(url: url, model: model).ignoresSafeArea()
}
}
.preferredColorScheme(.dark)
.tint(.frameBlue)
}
}
extension Color {
static let frameBackground = Color(red: 0.055, green: 0.078, blue: 0.106)
static let framePanel = Color(red: 0.118, green: 0.137, blue: 0.161)
static let frameBlue = Color(red: 0.102, green: 0.624, blue: 1.0)
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
}
struct ConnectingView: View {
let step: String
let host: String?
let cancel: () -> Void
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
ProgressView().controlSize(.large)
Text(step).font(.headline).multilineTextAlignment(.center)
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
Button("Change headset", action: cancel).padding(.top, 8)
}
.padding(32)
}
}
struct FailedView: View {
let message: String
let canRetry: Bool
let retrying: Bool
let needsPairing: Bool
let retry: () -> Void
let change: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
.font(.system(size: 44)).foregroundStyle(.orange)
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
if needsPairing {
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
} else if canRetry {
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
}
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
}
.padding(32)
.frame(maxWidth: 480)
}
}
/// A paired Frame that isn't answering is almost always asleep: say how to wake
/// it, and connect the moment it does (its SSH port is checked every 3 s).
struct WaitingView: View {
let host: String
let detail: String
let deviceName: String
let reachable: () -> Void
let change: () -> Void
@State private var pulse = false
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17))
.opacity(pulse ? 1 : 0.55)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
Text("Waiting for your Frame").font(.title3.bold())
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 10) {
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
}
.padding(14)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
Button("Connect to a different Frame", action: change).font(.footnote)
}
.padding(28)
.frame(maxWidth: 480)
.onAppear { pulse = true }
.task(id: host) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
reachable()
return
}
}
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import SwiftUI
import UIKit
/// First run: two steps. Wake the Frame (the app finds it by itself), then type the
/// Developer Mode password once. Everything else waits under "Other ways to connect".
struct SetupView: View {
@ObservedObject var model: AppModel
@StateObject private var finder = FrameFinder()
@State private var password = ""
@State private var manualHost = ""
@State private var user = "steamos"
@State private var showOther = false
@State private var showHelp = false
@FocusState private var passwordFocused: Bool
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
private var host: String {
let typed = manualHost.trimmingCharacters(in: .whitespaces)
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
header
StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep }
StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep }
otherWays
}
.padding(20)
.frame(maxWidth: 560)
.frame(maxWidth: .infinity)
}
.scrollDismissesKeyboard(.interactively)
.background(Color.frameBackground)
.onAppear {
manualHost = model.settings?.host ?? ""
user = model.settings?.user ?? "steamos"
var fallback = model.settings?.host
#if DEBUG
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
#endif
finder.start(fallback: fallback)
}
.onDisappear { finder.stop() }
// After a few seconds of not finding it, say exactly what to check.
.task(id: finder.since) {
try? await Task.sleep(nanoseconds: 8_000_000_000)
showHelp = true
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 8) {
Image("AppIconImage").resizable().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13))
Text("Connect to your Steam Frame").font(.title2.bold())
Text("One time only. After this, the app connects by itself whenever your Frame is awake.")
.foregroundStyle(Color.frameMuted)
}
}
// MARK: step 1
@ViewBuilder private var wakeStep: some View {
if let found = finder.found {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Found your Frame").fontWeight(.semibold)
Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)")
.font(.footnote).foregroundStyle(Color.frameMuted)
}
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
}
} else {
HStack(spacing: 10) {
ProgressView()
Text("Looking for it on this network…").foregroundStyle(Color.frameMuted)
}
Text("Put the headset on, or press its power button, so it's awake.")
if showHelp {
VStack(alignment: .leading, spacing: 10) {
Text("Still can't see it? Check:").font(.subheadline.weight(.semibold))
Tip(icon: "wifi", text: "The Frame and this \(model.deviceName) are on the same Wi-Fi.")
Tip(icon: "hammer", text: "Developer Mode is on: on the Frame, Steam Settings → System → Enable Developer Mode.")
Tip(icon: "network", text: "Local Network is allowed for Frame Control: \(model.deviceName) Settings → Apps → Frame Control.")
}
.padding(.top, 4)
}
}
}
// MARK: step 2
@ViewBuilder private var passwordStep: some View {
SecureField("Developer Mode password", text: $password)
.textContentType(.password)
.submitLabel(.go)
.focused($passwordFocused)
.onSubmit(connect)
.padding(12)
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.")
.font(.footnote).foregroundStyle(Color.frameMuted)
Button(action: connect) {
Text(finder.found == nil ? "Connect to \(host)" : "Connect")
.fontWeight(.semibold).frame(maxWidth: .infinity).padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(password.isEmpty)
}
// MARK: everything else, out of the way
private var otherWays: some View {
DisclosureGroup(isExpanded: $showOther) {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
.onSubmit { finder.start(fallback: manualHost) }
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
TextField("User", text: $user)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
Text("Typing an address here uses it instead of searching.").font(.caption).foregroundStyle(Color.frameMuted)
}
VStack(alignment: .leading, spacing: 6) {
Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.")
.font(.footnote).foregroundStyle(Color.frameMuted)
HStack {
Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine }
Spacer()
Button("Connect with the key") {
let (h, u) = (host, user)
Task { await model.useKey(host: h, user: u) }
}
}
}
if let saved = model.settings {
Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
}
}
.padding(.top, 10)
} label: {
Text("Other ways to connect").foregroundStyle(Color.frameMuted)
}
.onChange(of: manualHost) { _, value in
// A typed address replaces the search.
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
}
}
private func connect() {
guard !password.isEmpty else { passwordFocused = true; return }
let (h, u, p) = (host, user, password)
password = ""
finder.stop()
Task { await model.pair(host: h, user: u, password: p) }
}
}
/// A numbered step with a tick once it's done.
struct StepCard<Content: View>: View {
let number: Int
let title: String
let done: Bool
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
ZStack {
Circle().fill(done ? Color.green : Color.frameBlue).frame(width: 26, height: 26)
if done { Image(systemName: "checkmark").font(.caption.bold()) } else { Text("\(number)").font(.subheadline.bold()) }
}
.foregroundStyle(.white)
Text(title).font(.headline)
}
content
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 14))
}
}
struct Tip: View {
let icon: String
let text: String
var body: some View {
Label { Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: icon).foregroundStyle(Color.frameBlue) }
}
}
+195
View File
@@ -0,0 +1,195 @@
import SwiftUI
import UIKit
import WebKit
/// The Frame Control page, served by the server on the headset, in a web view.
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
/// the page use the phone: clipboard, saving images, other apps, install links.
struct WebShell: UIViewRepresentable {
let url: URL
@ObservedObject var model: AppModel
func makeCoordinator() -> Coordinator { Coordinator(model: model) }
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
let content = WKUserContentController()
content.addUserScript(WKUserScript(source: Self.bridge, injectionTime: .atDocumentStart, forMainFrameOnly: true))
content.addScriptMessageHandler(context.coordinator, contentWorld: .page, name: "frameApp")
config.userContentController = content
config.allowsInlineMediaPlayback = true
let web = WKWebView(frame: .zero, configuration: config)
web.navigationDelegate = context.coordinator
web.uiDelegate = context.coordinator
web.isOpaque = false
web.backgroundColor = UIColor(red: 0.055, green: 0.078, blue: 0.106, alpha: 1)
web.scrollView.backgroundColor = web.backgroundColor
web.scrollView.contentInsetAdjustmentBehavior = .never // the page pads for the safe area itself
web.allowsBackForwardNavigationGestures = false
#if DEBUG
web.isInspectable = true
#endif
context.coordinator.web = web
web.load(URLRequest(url: url))
return web
}
func updateUIView(_ web: WKWebView, context: Context) {
if context.coordinator.loaded != url {
context.coordinator.loaded = url
web.load(URLRequest(url: url))
}
context.coordinator.deliverInstallLinks()
}
static let bridge = """
(() => {
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
let installCb = null;
window.frameApp = {
platform: "ios",
readClipboard: () => call("readClipboard"),
setUpConnection: () => call("setUpConnection"),
open: (what) => call("open", what),
saveImages: (images) => call("saveImages", images),
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
};
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
})();
"""
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate {
let model: AppModel
weak var web: WKWebView?
var loaded: URL?
private var installReady = false
init(model: AppModel) { self.model = model }
// MARK: bridge
@MainActor
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void) {
guard let body = message.body as? [String: Any], let name = body["name"] as? String else {
return replyHandler(nil, "bad message")
}
let arg = body["arg"]
switch name {
case "readClipboard":
replyHandler(UIPasteboard.general.string ?? "", nil)
case "setUpConnection":
model.showSetup()
replyHandler(nil, nil)
case "open":
let result = open(arg as? String ?? "")
replyHandler(result.message.map { ["message": $0] }, result.error)
case "saveImages":
let images = (arg as? [[String: Any]] ?? []).compactMap { item -> UIImage? in
guard let b64 = item["data"] as? String, let data = Data(base64Encoded: b64) else { return nil }
return UIImage(data: data)
}
guard !images.isEmpty else { return replyHandler(nil, "No images to save") }
share(images)
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
case "installLinkReady":
installReady = true
deliverInstallLinks()
replyHandler(nil, nil)
default:
replyHandler(nil, "unknown request \(name)")
}
}
@MainActor
func deliverInstallLinks() {
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
let links = model.pendingInstallLinks
model.pendingInstallLinks = []
for link in links {
let req = ["kind": link.kind.rawValue, "target": link.target]
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
}
}
/// SSH, SFTP, Steam Link and remote desktop open in the apps that handle them.
@MainActor
private func open(_ what: String) -> (message: String?, error: String?) {
guard let s = model.settings else { return (nil, "Not paired with a Frame") }
let host = s.host.contains(":") ? "[\(s.host)]" : s.host
let target: (url: String, app: String, store: String)
switch what {
case "terminal": target = ("ssh://\(s.user)@\(host):\(s.port)", "an SSH app such as Blink Shell or Termius", "https://apps.apple.com/search?term=ssh")
case "sftp": target = ("sftp://\(s.user)@\(host):\(s.port)", "an SFTP app such as Termius or Secure ShellFish", "https://apps.apple.com/search?term=sftp")
case "steamlink": target = ("steamlink://", "Steam Link", "https://apps.apple.com/app/steam-link/id1246969117")
case "rdp": target = ("rdp://full%20address=s:\(s.host):3389", "Windows App (Microsoft Remote Desktop)", "https://apps.apple.com/app/windows-app/id714464092")
default: return (nil, "Can't open \(what) on this \(model.deviceName)")
}
guard let url = URL(string: target.url) else { return (nil, "Bad address") }
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
return ("Opening \(target.app)", nil)
}
if let store = URL(string: target.store) { UIApplication.shared.open(store) }
return (nil, "Install \(target.app) to open this; opening the App Store")
}
@MainActor
private func share(_ images: [UIImage]) {
guard let web, let root = web.window?.rootViewController else { return }
let sheet = UIActivityViewController(activityItems: images, applicationActivities: nil)
sheet.popoverPresentationController?.sourceView = web
sheet.popoverPresentationController?.sourceRect = CGRect(x: web.bounds.midX, y: web.bounds.midY, width: 1, height: 1)
(root.presentedViewController ?? root).present(sheet, animated: true)
}
#if DEBUG
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
}
#endif
// MARK: navigation: the app's page stays here; other sites open in Safari
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url else { return decisionHandler(.cancel) }
if url.host == "127.0.0.1" || url.scheme == "about" || url.scheme == "blob" || url.scheme == "data" {
return decisionHandler(.allow)
}
UIApplication.shared.open(url)
decisionHandler(.cancel)
}
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = action.request.url { UIApplication.shared.open(url) } // target="_blank" links
return nil
}
// MARK: alert() and confirm(), which the page uses before removing things
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
present(message, actions: [UIAlertAction(title: "OK", style: .default) { _ in completionHandler() }], fallback: completionHandler)
}
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
present(message, actions: [
UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) },
UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) },
], fallback: { completionHandler(false) })
}
private func present(_ message: String, actions: [UIAlertAction], fallback: @escaping () -> Void) {
guard let root = web?.window?.rootViewController else { return fallback() }
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
actions.forEach(alert.addAction)
(root.presentedViewController ?? root).present(alert, animated: true)
}
}
}
@@ -0,0 +1,56 @@
import CryptoKit
import XCTest
@testable import Frame_Control
final class InstallLinkTests: XCTestCase {
func testAcceptsManifestAndURLLinks() {
XCTAssertEqual(InstallLink("frame-control://install?manifest=https://example.com/app.json"),
InstallLink("frame-control://install/?manifest=https://example.com/app.json"))
XCTAssertEqual(InstallLink("frame-control://install?url=https://example.com/a.apk")?.kind, .url)
XCTAssertEqual(InstallLink("FRAME-CONTROL://install?manifest=https://example.com/m.json")?.target, "https://example.com/m.json")
}
func testRejectsAnythingElse() {
for raw in ["frame-control://other?url=https://example.com/a.apk",
"frame-control://install?url=ftp://example.com/a.apk",
"frame-control://install?url=https://user:pw@example.com/a.apk",
"frame-control://install?url=https://example.com/a&manifest=https://example.com/b",
"frame-control://install?url=https://a.example/x&url=https://b.example/y",
"frame-control://install?url=",
"frame-control://install/deeper?url=https://example.com/a.apk",
"https://example.com/?url=https://example.com/a.apk",
"frame-control://install?url=https://example.com/" + String(repeating: "a", count: 2100)] {
XCTAssertNil(InstallLink(raw), raw)
}
}
}
final class HeadsetServerTests: XCTestCase {
func testReadsThePortTheServerPrints() {
XCTAssertEqual(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:41234 (alias: frame; Ctrl-C to stop)\n"), 41234)
XCTAssertNil(HeadsetServer.port(in: "Traceback (most recent call last):"))
XCTAssertNil(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:4")) // more digits may follow
XCTAssertNil(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:99999 "))
}
func testBundleIsInTheApp() throws {
let bundle = try HeadsetServer.Bundle.fromApp()
XCTAssertGreaterThan(bundle.data.count, 100_000)
XCTAssertEqual(bundle.version.count, 16)
}
}
final class KeyTests: XCTestCase {
func testAuthorizedKeysLine() {
let line = DeviceKey.authorizedKeysLine(Curve25519.Signing.PrivateKey(), comment: "frame-control@iPhone")
let parts = line.split(separator: " ")
XCTAssertEqual(parts.count, 3)
XCTAssertEqual(parts[0], "ssh-ed25519")
XCTAssertEqual(Data(base64Encoded: String(parts[1]))?.count, 51) // string "ssh-ed25519" + 32-byte key
XCTAssertEqual(parts[2], "frame-control@iPhone")
}
func testShellQuote() {
XCTAssertEqual(shellQuote("it's"), "'it'\\''s'")
}
}
+79
View File
@@ -0,0 +1,79 @@
name: FrameControl
options:
bundleIdPrefix: com.saphid
deploymentTarget:
iOS: "17.0"
createIntermediateGroups: true
packages:
Citadel:
url: https://github.com/orlandos-nl/Citadel.git
exactVersion: 0.12.1
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
targets:
FrameControl:
type: application
platform: iOS
sources:
- path: FrameControl
dependencies:
- package: Citadel
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.saphid.framecontrol
PRODUCT_NAME: Frame Control
TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: FrameControl/Info.plist
ENABLE_USER_SCRIPT_SANDBOXING: NO
info:
path: FrameControl/Info.plist
properties:
CFBundleDisplayName: Frame Control
UILaunchScreen:
UIColorName: ""
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
UIUserInterfaceStyle: Dark
NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it.
NSBonjourServices: [_steamos-devkit._tcp]
NSPhotoLibraryAddUsageDescription: Frame Control saves headset captures and screenshots to your photo library when you ask it to.
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
CFBundleURLTypes:
- CFBundleURLName: com.saphid.framecontrol.install
CFBundleURLSchemes: [frame-control]
preBuildScripts:
- name: Pack the Frame bundle
# The server, headset helpers and catalogue, as the app copies them to the Frame.
script: |
mkdir -p "${DERIVED_FILE_DIR}"
python3 "${SRCROOT}/scripts/make_frame_bundle.py" "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" > "${DERIVED_FILE_DIR}/frame-bundle.version"
mkdir -p "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
cp "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" "${DERIVED_FILE_DIR}/frame-bundle.version" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/"
basedOnDependencyAnalysis: false
FrameControlTests:
type: bundle.unit-test
platform: iOS
sources:
- path: FrameControlTests
dependencies:
- target: FrameControl
settings:
base:
GENERATE_INFOPLIST_FILE: YES
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control"
BUNDLE_LOADER: "$(TEST_HOST)"
schemes:
FrameControl:
build:
targets:
FrameControl: all
FrameControlTests: [test]
test:
targets: [FrameControlTests]
+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1a9fff"/>
<stop offset="1" stop-color="#6f42c1"/>
</linearGradient>
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dfe8f5"/>
</linearGradient>
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
<mask id="nose">
<rect width="1024" height="1024" fill="#fff"/>
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
</mask>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<g transform="translate(512 512) scale(1.2427) translate(-512 -512)">
<g filter="url(#shadow)">
<rect x="222" y="350" width="580" height="320" rx="130" fill="url(#visor)" mask="url(#nose)"/>
</g>
<rect x="300" y="430" width="160" height="124" rx="50" fill="#13233a"/>
<rect x="564" y="430" width="160" height="124" rx="50" fill="#13233a"/>
<rect x="320" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
<rect x="584" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Pack what Frame Control's server needs to run on the Frame itself (the files the
desktop app ships, plus ui/local-bin) into one reproducible .tar.gz.
The iPhone app copies it to ~/.cache/frame-control/<version> on the Frame and
starts ui/server.py there. <version> is the SHA-256 of the archive, so a new
build replaces an old one and an unchanged one isn't copied again.
Usage: make_frame_bundle.py OUT.tar.gz (prints the version)
"""
import gzip
import hashlib
import io
import sys
import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js"]
def files():
found = set()
for pattern in PATTERNS:
for p in ROOT.glob(pattern):
if p.is_file() and "__pycache__" not in p.parts:
found.add(p)
return sorted(found)
def build():
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as tar:
for p in files():
info = tarfile.TarInfo(str(p.relative_to(ROOT)))
data = p.read_bytes()
info.size, info.mtime, info.uid, info.gid, info.uname, info.gname = len(data), 0, 0, 0, "", ""
info.mode = 0o755 if p.stat().st_mode & 0o111 else 0o644
tar.addfile(info, io.BytesIO(data))
out = io.BytesIO()
with gzip.GzipFile(fileobj=out, mode="wb", mtime=0) as gz:
gz.write(raw.getvalue())
return out.getvalue()
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
data = build()
Path(sys.argv[1]).write_bytes(data)
print(hashlib.sha256(data).hexdigest()[:16])
Executable
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env zsh
# Linux host with Docker: run the end-to-end tests against the fake Frame.
# Builds the fake Frame and host images (tests/fakeframe), starts them with
# docker compose, runs tests/e2e in the host container, prints the results
# and takes everything down again. Exits with the tests' status (2 if the
# harness itself didn't come up). See docs/testing.md.
#
# Usage: scripts/e2e.sh [TEST...] e.g. scripts/e2e.sh test_titles test_faults.Faults.test_disk_full
# Env: FAKEFRAME_BASE base image (default: archlinux:base, or Valve's Holo Core aarch64 on arm64)
# FAKEFRAME_KEEP=1 leave the containers running afterwards
set -uo pipefail
root=${0:A:h:h}
cd "$root" || exit 2
case $(uname -m) in
x86_64|amd64) base=archlinux:base ;;
aarch64|arm64) base=registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest ;;
*) print -u2 "No Arch Linux base image known for $(uname -m); set FAKEFRAME_BASE"; exit 2 ;;
esac
base=${FAKEFRAME_BASE:-$base}
compose=(docker compose -p fakeframe-e2e -f tests/fakeframe/compose.yaml)
started=$SECONDS
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
logs() {
print "\n==> Fake Frame logs"
$compose logs --no-color --tail 80 fakeframe
$compose exec -T fakeframe sh -c 'for f in /var/log/fakeframe/*.log; do echo "--- $f"; tail -n 40 "$f"; done' 2>/dev/null
print "\n==> ui/server.py log"
$compose exec -T host sh -c 'tail -n 80 /tmp/fakeframe-e2e-server.log' 2>/dev/null
}
finish() {
if [[ ${FAKEFRAME_KEEP:-0} == 1 ]]; then
print "==> Left running: ${(j: :)compose} exec host bash"
else
$compose down -v --remove-orphans >/dev/null 2>&1
fi
}
trap finish EXIT
$compose down -v --remove-orphans >/dev/null 2>&1
print "==> Starting the fake Frame and the host"
if ! $compose up -d --wait; then
logs
exit 2
fi
print "==> Up after $(( SECONDS - started )) s; running tests/e2e"
if (( $# )); then
args=(-v "$@")
else
args=(discover -v -s .)
fi
tests_started=$SECONDS
$compose exec -T -w /repo/tests/e2e host python3 -m unittest "${args[@]}"
rc=$?
(( rc == 0 )) || logs
print "\n==> tests/e2e: $([[ $rc == 0 ]] && echo passed || echo "FAILED (exit $rc)") in $(( SECONDS - tests_started )) s" \
"($(( SECONDS - started )) s with builds)"
exit $rc
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env zsh
# Mac or Linux: the headset smoke test. Installs, launches and removes tiny
# test titles on the Frame (the `frame` alias) and records the results with
# its BUILD_ID under tests/smoke/results/. See docs/testing.md.
#
# Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve)
set -euo pipefail
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""Check Frame Control's heart-rate readings against a reference, on your computer.
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
python3 scripts/heart-check.py compare ours.csv reference.csv
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
`listen` receives our integer-BPM OSC messages (send them here with
`tracking-on-frame.py heart --osc <this computer's IP> 9000`) and shows each
reading live, so you can watch it next to a reference such as your Apple
Watch. `--out` also records `unix_seconds,bpm` to a new private file.
`compare` lines up two recordings by time and reports how far apart they are.
The reference can be:
- a CSV whose first column is a time (unix seconds or ISO 8601) and whose
second is BPM, such as our own log, `listen --out`, or the test strap's
output (a third `flags` column marks skin-contact loss as "no reading");
- an Apple Health export (`export.zip` or `export.xml`, from Health → your
profile → Export All Health Data). Only heart-rate records within the
recording's time range are read.
Everything stays on this computer. Nothing is uploaded.
"""
import argparse
import bisect
import csv
from datetime import datetime
import io
import os
from pathlib import Path
import re
import socket
import struct
import sys
import time
import zipfile
from xml.etree import ElementTree
ADDRESS = "/avatar/parameters/HeartRate"
def parse_osc(packet):
"""Return (address, values) for a single OSC message with i/f arguments."""
def string(offset):
end = packet.index(b"\0", offset)
return packet[offset:end].decode("utf-8"), (end + 4) & ~3
address, offset = string(0)
tags, offset = string(offset)
if not tags.startswith(","):
raise ValueError("not an OSC message")
values = []
for tag in tags[1:]:
if tag not in "if" or offset + 4 > len(packet):
raise ValueError("unsupported OSC argument")
values.append(struct.unpack(">i" if tag == "i" else ">f", packet[offset:offset + 4])[0])
offset += 4
return address, values
def listen(port, address, out, seconds, clock=time.time, stream=sys.stdout):
log = None
if out:
log = os.fdopen(os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w")
log.write("unix_seconds,bpm\n")
received = 0
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("0.0.0.0", port))
sock.settimeout(0.5)
print(f"Listening for {address} on UDP port {port}. Ctrl-C stops.", file=stream, flush=True)
end = clock() + seconds if seconds else None
try:
while end is None or clock() < end:
try:
packet = sock.recv(1024)
except socket.timeout:
continue
try:
path, values = parse_osc(packet)
except (ValueError, UnicodeDecodeError):
continue
if path != address or len(values) != 1:
continue
now = clock()
bpm = int(values[0])
received += 1
print(f"{time.strftime('%H:%M:%S', time.localtime(now))} {bpm:3d} bpm", file=stream, flush=True)
if log:
log.write(f"{now:.3f},{bpm}\n")
log.flush()
except KeyboardInterrupt:
pass
finally:
if log:
log.close()
return received
def parse_time(text):
text = text.strip()
if re.fullmatch(r"\d+(\.\d+)?", text):
return float(text)
# Apple Health uses "2026-09-29 09:12:03 +1000".
text = re.sub(r" ([+-]\d{2}):?(\d{2})$", r"\1\2", text).replace("Z", "+0000")
for pattern in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
try:
return datetime.strptime(text, pattern).timestamp()
except ValueError:
pass
raise ValueError(f"unrecognised time {text!r}")
def read_csv(path):
"""[(time, bpm or None)], sorted. A header row is skipped."""
samples = []
with open(path, newline="") as source:
for row in csv.reader(source):
if len(row) < 2:
continue
try:
when, bpm = parse_time(row[0]), int(float(row[1]))
except (ValueError, OverflowError):
continue # header or unparseable line
try:
flags = int(float(row[2])) if len(row) > 2 else None
except (ValueError, OverflowError):
flags = None # an unrecognised flag leaves the reading as it is
if flags is not None and flags & 4 and not flags & 2:
bpm = None # contact supported and not detected: no reading
samples.append((when, bpm))
return sorted(samples, key=lambda s: s[0])
def read_health(path, start, end):
"""Heart-rate records from an Apple Health export between start and end."""
samples = []
def scan(source):
for _, element in ElementTree.iterparse(source):
if element.tag == "Record" and element.get("type") == "HKQuantityTypeIdentifierHeartRate":
try:
when = parse_time(element.get("startDate") or "")
value = round(float(element.get("value") or ""))
except (ValueError, OverflowError):
continue
if start <= when <= end:
samples.append((when, value))
element.clear()
if zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as archive:
names = [n for n in archive.namelist() if n.endswith("/export.xml") or n == "export.xml"]
if not names:
raise ValueError("no export.xml in that archive; use Health's Export All Health Data")
with archive.open(names[0]) as source:
scan(source)
else:
with open(path, "rb") as source:
scan(source)
return sorted(samples)
def read_any(path, start=None, end=None):
path = str(path)
if path.endswith((".zip", ".xml")):
return read_health(path, start, end)
return read_csv(path)
def value_at(samples, when, hold, times=None):
"""Our reading at a moment: the latest sample no older than `hold` seconds.
`times` is the samples' time column, if the caller has already built it."""
times = times if times is not None else [s[0] for s in samples]
i = bisect.bisect_right(times, when) - 1
if i < 0 or when - times[i] > hold:
return None
return samples[i][1]
def compare(ours, reference, max_lag=10.0, hold=5.0):
"""Compare our readings with the reference at each reference moment.
`lag` is the delay added to reference times before looking up ours (our
readings arrive after the sensor's). The best lag within ±max_lag is used.
"""
real = [(t, b) for t, b in reference if b is not None]
if not real or not any(b is not None for _, b in ours):
raise ValueError("both recordings need at least one reading")
best = None
ours_times = [t for t, _ in ours]
steps = int(max_lag * 4)
for step in range(-steps, steps + 1):
lag = step / 4
pairs = [(value_at(ours, t + lag, hold, ours_times), b) for t, b in real]
pairs = [(o, r) for o, r in pairs if o is not None]
if not pairs:
continue
error = sum(abs(o - r) for o, r in pairs) / len(pairs)
key = (-len(pairs), error, abs(lag))
if best is None or key < best[0]:
best = (key, lag, pairs)
if best is None:
raise ValueError("the recordings do not overlap in time")
_, lag, pairs = best
differences = [o - r for o, r in pairs]
# While the reference says "no reading" (lost skin contact), we must not
# produce new readings. Count ours that arrive during such a stretch.
gaps = [t for t, b in reference if b is None]
reference_times = [t for t, _ in reference]
shown_in_gaps = 0
for t, bpm in ours:
i = bisect.bisect_right(reference_times, t - lag) - 1
if bpm is not None and i >= 0 and reference[i][1] is None:
shown_in_gaps += 1
return {
"reference_readings": len(real),
"matched": len(pairs),
"lag_seconds": lag,
"mean_abs_error": sum(abs(d) for d in differences) / len(differences),
"bias": sum(differences) / len(differences),
"max_abs_error": max(abs(d) for d in differences),
"within_5": sum(1 for d in differences if abs(d) <= 5) / len(differences),
"exact": sum(1 for d in differences if d == 0) / len(differences),
"no_contact_moments": len(gaps),
"shown_during_no_contact": shown_in_gaps,
}
def report(result, tolerance, stream=sys.stdout):
print(f"Reference readings: {result['reference_readings']}, matched: {result['matched']}", file=stream)
print(f"Best alignment: ours {result['lag_seconds']:+.2f} s after the reference", file=stream)
print(f"Mean absolute difference: {result['mean_abs_error']:.2f} BPM "
f"(bias {result['bias']:+.2f}, worst {result['max_abs_error']})", file=stream)
print(f"Within ±5 BPM: {result['within_5']:.0%}; identical: {result['exact']:.0%}", file=stream)
if result["no_contact_moments"]:
print(f"No-contact moments: {result['no_contact_moments']}, where we showed a stale "
f"reading: {result['shown_during_no_contact']}", file=stream)
coverage = result["matched"] / result["reference_readings"]
passed = (result["mean_abs_error"] <= tolerance and coverage >= 0.8
and not result["shown_during_no_contact"])
print(("PASS" if passed else "FAIL") + f" (mean difference ≤ {tolerance} BPM, ≥80% of reference "
f"readings matched, nothing shown without contact)", file=stream)
return passed
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
commands = parser.add_subparsers(dest="command", required=True)
heard = commands.add_parser("listen", help="show and optionally record our OSC readings live")
heard.add_argument("--port", type=int, default=9000)
heard.add_argument("--address", default=ADDRESS)
heard.add_argument("--out", help="new private CSV file")
heard.add_argument("--seconds", type=float, default=0, help="stop after this long (default: until Ctrl-C)")
check = commands.add_parser("compare", help="compare our recording with a reference")
check.add_argument("ours", type=Path)
check.add_argument("reference", type=Path)
check.add_argument("--tolerance", type=float, default=5.0, help="allowed mean difference in BPM (default 5)")
check.add_argument("--max-lag", type=float, default=10.0, help="largest time offset to search, seconds")
args = parser.parse_args(argv)
if args.command == "listen":
count = listen(args.port, args.address, args.out, args.seconds)
print(f"Received {count} readings.")
return 0 if count else 3
try:
ours = read_csv(args.ours)
if not ours:
raise ValueError("our recording has no readings")
reference = read_any(args.reference, ours[0][0] - 60, ours[-1][0] + 60)
result = compare(ours, reference, args.max_lag)
except (ValueError, OSError, csv.Error, ElementTree.ParseError, zipfile.BadZipFile) as error:
print(f"Could not compare: {error}")
return 1
return 0 if report(result, args.tolerance) else 1
if __name__ == "__main__":
raise SystemExit(main())
+100
View File
@@ -0,0 +1,100 @@
// A synthetic Bluetooth heart-rate strap for testing, run on the Mac.
//
// swiftc -O scripts/heart-test-strap.swift -o /tmp/heart-test-strap
// /tmp/heart-test-strap [seconds] # default 60
//
// Advertises the standard Heart Rate Service (180d) as "FC Test Strap" and,
// while a central is subscribed, sends one Heart Rate Measurement (2a37) per
// second from a fixed, known sequence. Each sent value is printed to stdout as
// `unix_seconds,bpm,flags` so scripts/heart-check.py can compare what the
// Frame received with what was sent. Every value is synthetic; this is not a
// sensor. macOS asks for Bluetooth permission the first time it runs.
import CoreBluetooth
import Foundation
let hrs = CBUUID(string: "180D")
let measurement = CBUUID(string: "2A37")
/// The known sequence: an 8-bit ramp, 16-bit encodings, a skin-contact loss
/// (which must show as no reading) and a recovery.
func packet(_ second: Int) -> (bpm: Int, flags: UInt8) {
switch second % 60 {
case 0..<30: return (60 + second % 60 * 4, 0x06) // 60…176, contact detected
case 30..<40: return (180 - (second % 60 - 30) * 3, 0x07) // 16-bit value
case 40..<45: return (150, 0x04) // contact lost
default: return (72 + (second % 60 - 45), 0x06)
}
}
final class Strap: NSObject, CBPeripheralManagerDelegate {
let seconds: Int
var manager: CBPeripheralManager!
var characteristic: CBMutableCharacteristic!
var subscribed = false
var sent = 0
init(seconds: Int) {
self.seconds = seconds
super.init()
manager = CBPeripheralManager(delegate: self, queue: nil)
}
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
guard peripheral.state == .poweredOn else {
FileHandle.standardError.write("Bluetooth state \(peripheral.state.rawValue)\n".data(using: .utf8)!)
if peripheral.state == .unauthorized || peripheral.state == .unsupported || peripheral.state == .poweredOff {
FileHandle.standardError.write("Bluetooth unavailable (state \(peripheral.state.rawValue))\n".data(using: .utf8)!)
exit(1)
}
return
}
characteristic = CBMutableCharacteristic(type: measurement, properties: [.notify], value: nil, permissions: [])
let service = CBMutableService(type: hrs, primary: true)
service.characteristics = [characteristic]
peripheral.add(service)
}
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
if let error { fail("add service: \(error.localizedDescription)") }
peripheral.startAdvertising([CBAdvertisementDataLocalNameKey: "FC Test Strap",
CBAdvertisementDataServiceUUIDsKey: [hrs]])
}
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
if let error { fail("advertise: \(error.localizedDescription)") }
FileHandle.standardError.write("Advertising FC Test Strap\n".data(using: .utf8)!)
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.tick() }
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
subscribed = true
FileHandle.standardError.write("Central subscribed\n".data(using: .utf8)!)
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
subscribed = false
FileHandle.standardError.write("Central unsubscribed\n".data(using: .utf8)!)
}
func tick() {
guard subscribed else { return }
if sent >= seconds { exit(0) }
let (bpm, flags) = packet(sent)
var bytes: [UInt8] = [flags, UInt8(bpm & 0xff)]
if flags & 1 != 0 { bytes.append(UInt8(bpm >> 8)) }
if manager.updateValue(Data(bytes), for: characteristic, onSubscribedCentrals: nil) {
print(String(format: "%.3f,%d,%d", Date().timeIntervalSince1970, bpm, flags))
fflush(stdout)
sent += 1
}
}
func fail(_ message: String) -> Never {
FileHandle.standardError.write("\(message)\n".data(using: .utf8)!)
exit(1)
}
}
let seconds = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 60 : 60
let strap = Strap(seconds: seconds)
RunLoop.main.run()
+4
View File
@@ -59,9 +59,13 @@ colordepth=32
quality=9 quality=9
viewonly=0 viewonly=0
showcursor=1 showcursor=1
scale=1
viewmode=1
window_maximize=1
EOF EOF
echo \"wrote \$d/mac-screen-sharing.remmina\" echo \"wrote \$d/mac-screen-sharing.remmina\"
" "
print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >" print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >"
print " enable 'VNC viewers may control screen with password' and set one." print " enable 'VNC viewers may control screen with password' and set one."
print "Remmina may ask for your Mac account name + login password instead (Apple auth)."
fi fi
+40
View File
@@ -0,0 +1,40 @@
-- Hammerspoon: draw a ring around the Mac pointer so it shows in the VNC
-- mirror on the Frame. macOS Screen Sharing leaves the pointer out of the
-- framebuffer; a real on-screen window is captured like anything else.
--
-- Install: brew install --cask hammerspoon, then in ~/.hammerspoon/init.lua:
-- dofile("/path/to/frame-control/scripts/mac-cursor-ring.lua")
-- Toggle: ctrl+alt+cmd+M. Polls the pointer position, so no Accessibility
-- permission is needed.
local SIZE, WIDTH = 34, 3
local COLOR = { red = 1, green = 0.2, blue = 0.2, alpha = 0.9 }
local ring = hs.canvas.new({ x = 0, y = 0, w = SIZE, h = SIZE })
ring:appendElements({
type = "circle", action = "stroke",
strokeColor = COLOR, strokeWidth = WIDTH,
radius = (SIZE - WIDTH) / 2,
})
ring:level(hs.canvas.windowLevels.cursor)
ring:behavior({ "canJoinAllSpaces", "stationary", "ignoresCycle" })
local last = {}
local function follow()
local p = hs.mouse.absolutePosition()
if p.x ~= last.x or p.y ~= last.y then
ring:topLeft({ x = p.x - SIZE / 2, y = p.y - SIZE / 2 })
last = p
end
end
frameCursorRing = { canvas = ring, timer = hs.timer.new(1 / 60, follow) }
local function show() follow(); ring:show(); frameCursorRing.timer:start() end
local function hide() frameCursorRing.timer:stop(); ring:hide() end
hs.hotkey.bind({ "ctrl", "alt", "cmd" }, "M", function()
if ring:isShowing() then hide() else show() end
end)
show()
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Install or run our local-only tracking tools on the Frame.
python3 scripts/tracking-on-frame.py install
python3 scripts/tracking-on-frame.py gaze --seconds 10
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
python3 scripts/tracking-on-frame.py heart --device AA:BB:CC:DD:EE:FF --panel
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show # experimental
FRAME_ALIAS overrides the SSH alias (default: frame). Runs in the foreground;
Ctrl-C stops the reader. No service, autostart, sudo or SteamVR settings changes.
"""
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
REMOTE = '"$HOME/.local/share/frame-control/tracking"'
INSTALL = '''set -eu
base="$HOME/.local/share/frame-control/tracking"
mkdir -p "$base"
stage=$(mktemp -d "$base/.install.XXXXXX")
trap 'rm -rf "$stage"' EXIT
tar -xf - -C "$stage"
cc -O2 -Wall -Wextra -Werror "$stage/gaze.c" \\
-L/opt/steamvr/bin/linuxarm64 -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \\
-lopenxr_loader -o "$stage/gaze"
chmod 700 "$stage/gaze" "$stage/tracking.py"
chmod 600 "$stage/pulse.py"
mv "$stage/gaze" "$stage/tracking.py" "$stage/pulse.py" "$base/"
echo 'Installed Frame Control tracking tools (no service started).'
'''
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ("install", "gaze", "heart", "pulse"):
print(__doc__)
return 2
host = os.environ.get("FRAME_ALIAS", "frame")
if not host or host.startswith("-"):
raise ValueError("invalid SSH alias")
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", host]
if sys.argv[1] == "install":
import tempfile
source = Path(__file__).resolve().parents[1] / "frame" / "tracking"
with tempfile.TemporaryFile() as archive:
with tarfile.open(fileobj=archive, mode="w") as tar:
for name in ("gaze.c", "tracking.py", "pulse.py"):
tar.add(source / name, arcname=name)
archive.seek(0)
return subprocess.call(ssh + ["bash -c " + shlex.quote(INSTALL)], stdin=archive)
# Allocate a tty so SSH forwards Ctrl-C and hangup to the foreground process.
command = 'exec python3 ' + REMOTE + '/tracking.py ' + shlex.join(sys.argv[1:])
return subprocess.call(ssh[:-1] + ["-tt", host, command])
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -153,7 +153,7 @@ section.alt { background: var(--bg-2); border-block: 1px solid var(--line); }
/* ---- footer ---- */ /* ---- footer ---- */
footer { border-top: 1px solid var(--line); padding: 48px 0 56px; color: var(--dim); font-size: 14px; } footer { border-top: 1px solid var(--line); padding: 48px 0 56px; color: var(--dim); font-size: 14px; }
footer .wrap { display: flex; flex-wrap: wrap; gap: 24px 48px; justify-content: space-between; } footer .wrap { display: flex; flex-wrap: wrap; gap: 24px 48px; justify-content: space-between; }
footer .cols { display: flex; gap: 28px; flex-wrap: wrap; } footer .cols { display: flex; gap: 12px 28px; flex-wrap: wrap; }
footer a { color: var(--muted); } footer a { color: var(--muted); }
footer .legal { flex-basis: 100%; font-size: 13px; } footer .legal { flex-basis: 100%; font-size: 13px; }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+1 -1
View File
@@ -163,7 +163,7 @@
<h2>The fiddly bits, done for you</h2> <h2>The fiddly bits, done for you</h2>
<p>Open an SSH session or SFTP, start Steam Link or remote desktop, change the volume, or put the headset to sleep, all from one tab.</p> <p>Open an SSH session or SFTP, start Steam Link or remote desktop, change the volume, or put the headset to sleep, all from one tab.</p>
</div> </div>
<img src="/img/tools.jpg" width="1600" height="1000" loading="lazy" alt="The Tools tab: SSH, SFTP, Steam Link, remote desktop and power controls."> <img src="/img/tools.jpg" width="1600" height="600" loading="lazy" alt="The Tools tab: SSH, SFTP, Steam Link, remote desktop and power controls.">
</div> </div>
</div> </div>
</section> </section>
+204
View File
@@ -0,0 +1,204 @@
"""Plumbing for the end-to-end tests against the fake Frame (tests/fakeframe).
scripts/e2e.sh runs these inside the compose `host` container, where
`ssh frame` reaches the fake Frame and FAKEFRAME_CTL is its control port.
Each test starts from `fakeframe-ctl reset` and drives the real ui/server.py
(started once, on a free port) over HTTP, then checks the fake's state.
Without FRAME_E2E=1 every test here is skipped.
"""
import atexit
import http.client
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
ENABLED = os.environ.get('FRAME_E2E') == '1'
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
HOME = '/home/steamos'
SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
def require():
"""Call at module level: skips the module unless the fake Frame is up."""
if not ENABLED:
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
# ---- the fake Frame's control port --------------------------------------------
def _ctl_request(path, body=None, timeout=60):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(CTL + path, data=data, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def ctl(*args):
out = _ctl_request('/ctl', {'args': list(args)})
if 'error' in out:
raise AssertionError(f'fakeframe-ctl {" ".join(args)}: {out["error"]}')
return out
def state():
return _ctl_request('/state')
def calls(tool=None):
return _ctl_request('/calls' + (f'?{tool}' if tool else ''))
def wait_for(check, timeout=30, what='a condition', every=0.3):
"""Poll check() until it returns something truthy; returns that."""
deadline = time.monotonic() + timeout
last = None
while time.monotonic() < deadline:
last = check()
if last:
return last
time.sleep(every)
raise AssertionError(f'timed out after {timeout}s waiting for {what} (last: {last!r})')
def reset():
ctl('reset')
wait_for(lambda: _ctl_request('/ping')['ok'], 30, 'the fake Frame to come back after reset')
def ssh(cmd, check=True, timeout=30):
"""Run cmd on the fake Frame through the `frame` alias, as Frame Control does."""
r = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame', cmd],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=timeout)
if check and r.returncode != 0:
raise AssertionError(f'ssh frame {cmd!r} exited {r.returncode}: {r.stderr.strip()}')
return r.stdout
def exists(path):
return ssh(f'test -e {path} && echo yes || echo no').strip() == 'yes'
# ---- the real server ----------------------------------------------------------
class Server:
proc = None
port = None
@classmethod
def start(cls):
if cls.proc and cls.proc.poll() is None:
return
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
cls.port = s.getsockname()[1]
env = dict(os.environ, FRAME_CONTROL_LOCAL_LINKS='1')
log = open(SERVER_LOG, 'ab')
cls.proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'server.py'), '--port', str(cls.port)],
cwd=str(ROOT), env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=log)
log.close()
atexit.register(cls.stop)
wait_for(lambda: cls._up(), 20, 'ui/server.py to listen')
@classmethod
def _up(cls):
try:
return api('GET', '/api/host')[0] == 200
except OSError:
return False
@classmethod
def stop(cls):
if cls.proc and cls.proc.poll() is None:
cls.proc.terminate()
try:
cls.proc.wait(10)
except subprocess.TimeoutExpired:
cls.proc.kill()
def api(method, path, body=None, raw=None, headers=None, timeout=120):
"""One request to the server with the headers its guards want. -> (status, JSON or bytes, headers)."""
conn = http.client.HTTPConnection('127.0.0.1', Server.port, timeout=timeout)
try:
hdrs = {'X-Frame-UI': '1', **(headers or {})} # Host is 127.0.0.1:<port>, which it accepts
data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
if data is not None and 'Content-Type' not in hdrs:
hdrs['Content-Type'] = 'application/json'
conn.request(method, path, body=data, headers=hdrs)
r = conn.getresponse()
payload = r.read()
if r.getheader('Content-Type', '').startswith('application/json'):
payload = json.loads(payload)
return r.status, payload, dict(r.getheaders())
finally:
conn.close()
def ok(method, path, body=None, **kw):
status, out, _ = api(method, path, body, **kw)
if status != 200:
raise AssertionError(f'{method} {path} -> {status}: {out}')
return out
def finished(started, timeout=60):
"""Wait for a background job (server.start_job's {"job": id}); returns its final state."""
return wait_for(lambda: (lambda j: j['done'] and j)(ok('GET', f"/api/job?id={started['job']}")),
timeout, f"job {started['job']}")
def upload(path, mode, name=None):
with open(path, 'rb') as f:
data = f.read()
return api('POST', '/api/upload', raw=data, headers={
'X-Filename': name or os.path.basename(path), 'X-Mode': mode, 'Content-Type': 'application/octet-stream'})
def wait_title_job(token, timeout=180):
def done():
job = ok('GET', f'/api/titles/job?token={token}')
return job if job['done'] else None
return wait_for(done, timeout, f'title install job {token}', every=0.5)
def install_title(path, **options):
"""Upload (or, for a folder, inspect by path) and install; returns (plan, finished job)."""
if os.path.isdir(path):
staged = ok('POST', '/api/titles', {'action': 'inspect', 'path': path})
else:
status, staged, _ = upload(path, 'title')
if status != 200:
raise AssertionError(f'upload -> {status}: {staged}')
started = ok('POST', '/api/titles', {'action': 'install', 'token': staged['token'], **options})
return staged['plan'], wait_title_job(started['job'])
def launches(kind=None):
return [r for r in state()['launches'] if kind is None or r['kind'] == kind]
class FrameTestCase(unittest.TestCase):
"""Starts the server once and the fake Frame afresh for every test."""
@classmethod
def setUpClass(cls):
Server.start()
def setUp(self):
reset()
self.tmp = tempfile.mkdtemp(prefix='fakeframe-e2e-')
self.addCleanup(subprocess.run, ['rm', '-rf', self.tmp])
def path(self, *parts):
return os.path.join(self.tmp, *parts)
+76
View File
@@ -0,0 +1,76 @@
"""An APK as its own Lepton instance (ui/frame_android.py): the shortcut goes in
through the fake Steam client's DevTools port, the launch through `steam`,
Lepton's launcher and podman."""
import unittest
import harness
from harness import HOME, exists, ok, state, upload, wait_for
from test_frame_apk import apk, manifest, resources
harness.require()
PKG = 'com.example.fakeframe'
APP_DIR = f'{HOME}/Applications/Android/{PKG}'
class AndroidApps(harness.FrameTestCase):
def build_apk(self, min_sdk=26):
arsc = resources({(1, '', 0): {0: 1, 1: 2}, (2, '', 640): {0: 4}})
data = apk({'AndroidManifest.xml': manifest(PKG, 0x7f010000, 0x7f010001, min_sdk),
'resources.arsc': arsc, 'res/icon_hi.png': b'\x89PNG fake icon',
'lib/arm64-v8a/libgame.so': b''})
path = self.path('fake-app.apk')
with open(path, 'wb') as f:
f.write(data)
return path
def test_install_launch_stop_remove(self):
status, out, _ = upload(self.build_apk(), 'apk')
self.assertEqual(status, 200, out)
meta = out['app']
self.assertEqual((meta['package'], meta['label'], meta['version']), (PKG, 'App label', '2.1'))
shortcut = next(s for s in state()['steam']['shortcuts'] if s['appid'] == meta['shortcut'])
self.assertEqual(shortcut['name'], 'App label')
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
self.assertEqual(shortcut['start_dir'], APP_DIR)
self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png')
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'):
self.assertTrue(exists(f'{APP_DIR}/{f}'), f)
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
ok('POST', '/api/android', {'action': 'launch', 'package': PKG})
ctr = f"lepton-steamlaunch-{meta['instance']}"
running = wait_for(lambda: state()['lepton'].get(ctr), 20, 'the Lepton instance')
self.assertTrue(running['flatscreen'])
self.assertGreaterEqual(running['port'], 5556)
call = next(c for c in harness.calls('lepton') if 'env' in c)
self.assertEqual(call['env']['SteamAppId'], str(meta['instance']))
self.assertTrue(call['env']['STEAM_COMPAT_DATA_PATH'].startswith(f'{HOME}/.local/share/Steam/'))
apps = ok('GET', '/api/android')['apps']
self.assertEqual([(a['package'], a['running']) for a in apps], [(PKG, True)])
ok('POST', '/api/android', {'action': 'stop', 'package': PKG})
wait_for(lambda: ctr not in state()['lepton'], 15, 'the instance to stop')
ok('POST', '/api/android', {'action': 'remove', 'package': PKG})
self.assertEqual(state()['steam']['shortcuts'], [])
self.assertFalse(exists(APP_DIR))
self.assertFalse(exists(f"{HOME}/.local/share/Steam/steamapps/compatdata/{meta['instance']}"))
def test_reinstall_reuses_the_shortcut(self):
first = upload(self.build_apk(), 'apk')[1]['app']
second = upload(self.build_apk(), 'apk')[1]['app']
self.assertEqual(first['shortcut'], second['shortcut'])
self.assertEqual(len(state()['steam']['shortcuts']), 1)
def test_launch_without_lepton_installed_fails_on_the_frame(self):
meta = upload(self.build_apk(), 'apk')[1]['app']
harness.ctl('runtime', 'lepton', 'missing')
ok('POST', '/api/android', {'action': 'launch', 'package': PKG})
run = wait_for(lambda: next((r for r in state()['launches'] if r.get('appid') == meta['shortcut']
and r.get('exit') is not None), None), 20, 'launch.sh to exit')
self.assertEqual(run['exit'], 1) # launch.sh: "Lepton isn't installed (Steam app 3056000)"
self.assertEqual(state()['lepton'], {})
if __name__ == '__main__':
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
"""Status, Steam library, volume, clipboard, Flatpaks and the desktop capture,
through the server against the fake Frame."""
import unittest
import harness
from harness import api, ctl, finished, launches, ok, state, wait_for
harness.require()
class Device(harness.FrameTestCase):
def test_status(self):
s = ok('GET', '/api/status')
self.assertEqual(s['hostname'], 'frame')
self.assertEqual(s['os'], {'version': '0.3.0', 'build': '20260922.6101926', 'variant': 'vr'})
b = s['battery']
self.assertEqual((b['percent'], b['status'], b['health']), (76, 'Charging', 'Good'))
self.assertAlmostEqual(b['watts'], 9.62, places=1)
self.assertAlmostEqual(b['tempC'], 31.2, places=3)
self.assertEqual(s['power'], {'type': 'C PD [PD_PPS]', 'watts': 20.0})
self.assertEqual(s['temp'], 41.5)
self.assertEqual(s['wifi'], {'ssid': 'Fake:Frame Wi-Fi', 'signal': 72})
self.assertEqual(s['volume'], {'level': 0.4, 'muted': False})
self.assertEqual(s['services'], {'steamvr': True, 'desktop': True, 'lepton': False, 'rdp': False})
# Runtimes and Lepton are Steam "apps" too; the status hides them.
self.assertEqual([g['name'] for g in s['games']], ['Beat Saber'])
self.assertIsNotNone(s['disk']['home'])
ctl('battery', 'capacity=15', 'status=Discharging', 'current_now=-900000')
b = ok('GET', '/api/status')['battery']
self.assertEqual((b['percent'], b['status']), (15, 'Discharging'))
self.assertLess(b['watts'], 0)
def test_volume_and_mute(self):
ok('POST', '/api/volume', {'level': 0.55, 'muted': True})
self.assertEqual(state()['volume'], {'level': 0.55, 'muted': True})
self.assertEqual(ok('GET', '/api/status')['volume'], {'level': 0.55, 'muted': True})
status, out, _ = api('POST', '/api/volume', {'level': 2})
self.assertEqual(status, 400, out)
def test_clipboard_goes_to_klipper(self):
text = 'héllo from the e2e tests\nline two, with a trailing newline\n'
out = ok('POST', '/api/clipboard', {'text': text})
# ${#text} counts bytes or characters depending on the session's locale.
self.assertRegex(out['message'], r'^copied via Klipper \(\d+ chars\)$')
self.assertEqual(state()['clipboard'], [text])
def test_flatpak_install_and_remove(self):
# Installs run as background jobs (server.start_job); uninstall answers at once.
job = finished(ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'install'}))
self.assertIsNone(job['error'], job)
self.assertEqual([f['id'] for f in ok('GET', '/api/status')['flatpaks']], ['org.videolan.VLC'])
ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'uninstall'})
self.assertEqual(state()['flatpaks'], [])
job = finished(ok('POST', '/api/flatpak', {'id': 'org.example.missing', 'action': 'install'}))
self.assertIn('Nothing matches org.example.missing', job['error'])
def test_desktop_capture(self):
status, png, headers = api('GET', '/api/screenshot')
self.assertEqual(status, 200, png)
self.assertTrue(png.startswith(b'\x89PNG\r\n\x1a\n'))
self.assertEqual(headers.get('X-Capture-Source'), 'gamescope')
def test_steam_library_and_installs(self):
owned = ok('GET', '/api/steam/owned')
self.assertEqual(owned['country'], 'AU')
games = {g['id']: g for g in owned['games']}
self.assertEqual(set(games), {2379780, 274190, 620980})
self.assertEqual((games[2379780]['frame'], games[620980]['installed']), (3, True))
# Balatro queues at once; Broforce stops at the options dialog, which
# frame_steam.py accepts with ContinueInstall().
for appid, name in ((2379780, 'Balatro'), (274190, 'Broforce')):
out = ok('POST', '/api/steam', {'appid': appid, 'action': 'install'})
self.assertEqual(out, {'state': 'downloading', 'message': f'{name} is queued to download on the Frame'})
self.assertEqual(ok('GET', '/api/steam/owned')['download']['appid'], 274190)
def test_launch_and_store_page(self):
ok('POST', '/api/launch', {'appid': 620980})
run = wait_for(lambda: launches('rungameid'), 10, 'the launch to reach Steam')[-1]
self.assertEqual((run['appid'], run['started']), (620980, True))
ok('POST', '/api/steam', {'appid': 1145360, 'action': 'store'})
wait_for(lambda: state()['steam']['pages'], 10, 'the store page')
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
if __name__ == '__main__':
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
"""What Frame Control does when the headset misbehaves: Steam not running,
the headset asleep or with sshd off, and a full disk."""
import os
import subprocess
import sys
import time
import unittest
import zipfile
import harness
import tiny_programs
from harness import HOME, ROOT, api, ctl, exists, install_title, ok, state, wait_for
harness.require()
class Faults(harness.FrameTestCase):
def exe(self):
return tiny_programs.write(self.tmp, 'exe')
def test_steam_not_running_then_install_again(self):
ctl('steam', 'off')
_, job = install_title(self.exe())
# steam-client-create-shortcut's own words, passed on by frame_titles.
self.assertEqual(job['error'], "Uploaded, but Steam didn't register it: The Steam client is not running. "
"Registration did not complete. With Steam running on the Frame, "
"install it again.")
self.assertTrue(exists(f'{HOME}/devkit-game/fc_smoke_exe/fc-smoke-exe.exe')) # the files stay
self.assertEqual(state()['devkit_games'], {})
status, out, _ = api('GET', '/api/steam/owned')
self.assertEqual(status, 502)
self.assertIn("Steam's UI isn't answering", out['error'])
ctl('steam', 'on')
wait_for(lambda: harness._ctl_request('/ping')['ok'], 20, 'Steam to start')
_, job = install_title(self.exe())
self.assertIsNone(job['error'], job)
self.assertIn('fc_smoke_exe', state()['devkit_games'])
def test_launch_with_steam_stopped(self):
_, job = install_title(self.exe())
self.assertIsNone(job['error'], job)
ctl('steam', 'off')
status, out, _ = api('POST', '/api/titles', {'action': 'launch', 'id': 'fc_smoke_exe'})
self.assertEqual(status, 502, out)
self.assertIn('steam.pid', out['error'])
self.assertEqual(harness.launches(), [])
def test_headset_asleep(self):
ok('GET', '/api/status') # a shared connection is up
ctl('sleep', 'on')
t0 = time.monotonic()
status, out, _ = api('GET', '/api/status', timeout=90)
took = time.monotonic() - t0
self.assertEqual(status, 502, out)
self.assertRegex(out['error'], r'[Tt]imed out')
self.assertLess(took, 40) # ConnectTimeout, not a hang
ctl('sleep', 'off')
# The next request after waking gets through again.
wait_for(lambda: api('GET', '/api/status', timeout=60)[0] == 200, 60, 'status after waking')
def test_sshd_stopped(self):
ok('GET', '/api/titles') # the server's shared connection is up
ctl('sshd', 'off')
# Stopping sshd keeps open sessions (Arch's sshd.service kills only the
# listener), so the server carries on over its shared connection...
self.assertEqual(api('GET', '/api/titles')[0], 200)
# ...while anything that connects afresh is refused.
out = subprocess.run([sys.executable, str(ROOT / 'ui' / 'frame_titles.py'), 'list'],
capture_output=True, text=True, timeout=60)
self.assertEqual(out.returncode, 1)
self.assertIn('Connection refused', out.stderr)
ctl('sshd', 'on')
wait_for(lambda: subprocess.run([sys.executable, str(ROOT / 'ui' / 'frame_titles.py'), 'list'],
capture_output=True, timeout=60).returncode == 0, 30, 'sshd to be back')
def test_disk_full(self):
path = self.path('Big Game.zip')
with zipfile.ZipFile(path, 'w') as z:
z.writestr('Big Game/BigGame.exe', tiny_programs.pe_x86_64())
z.writestr('Big Game/data.pak', os.urandom(2 * 1024 * 1024))
ctl('disk-full', 'on')
_, job = install_title(path)
self.assertIn('No space left on device', job['error'] or '', job)
# A first install that failed part-way leaves nothing behind.
self.assertFalse(exists(f'{HOME}/devkit-game/Big_Game'))
self.assertEqual(state()['devkit_games'], {})
ctl('disk-full', 'off')
_, job = install_title(path)
self.assertIsNone(job['error'], job)
if __name__ == '__main__':
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
"""Setting up the connection: ui/frame_connect.py against Valve's real
steamos-devkit-service on the fake Frame, and its password fallback."""
import json
import os
import signal
import stat
import subprocess
import sys
import unittest
import urllib.request
import harness
from harness import FAKE_HOST, ROOT, ctl, state, wait_for
harness.require()
class Pairing(harness.FrameTestCase):
def setUp(self):
super().setUp()
ctl('keys', 'none') # a computer the headset doesn't know yet
def connect(self, askpass=None):
"""Start frame_connect.py FAKE_HOST with no terminal, as the app's setup window would."""
env = {k: v for k, v in os.environ.items() if not k.startswith('SSH_ASKPASS')}
if askpass:
script = self.path('askpass')
with open(script, 'w') as f:
f.write(f'#!/bin/sh\necho {askpass}\n')
os.chmod(script, stat.S_IRWXU)
env.update(SSH_ASKPASS=script, SSH_ASKPASS_REQUIRE='force')
proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'frame_connect.py'), FAKE_HOST],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, env=env, start_new_session=True)
self.addCleanup(self.stop, proc) # a failed test mustn't leave it pairing into the next one
return proc
@staticmethod
def stop(proc):
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL) # frame_connect.py and its ssh children
except OSError:
pass
proc.communicate()
def finish(self, proc, timeout=120):
out, _ = proc.communicate(timeout=timeout)
return proc.returncode, out
def authorized_keys(self):
return ctl('authorized-keys')['text']
def test_service_properties_and_announcement(self):
# docs/ssh.md: properties.json answers "login": "steamos" on the Frame.
with urllib.request.urlopen(f'http://{FAKE_HOST}:32000/properties.json', timeout=10) as r:
props = json.load(r)
self.assertEqual(props['login'], 'steamos')
self.assertEqual(props['devkit1'], ['devkit-1'])
# At start the service announces _steamos-devkit._tcp under the Frame's hostname.
ctl('devkit-service', 'off')
ctl('devkit-service', 'on')
reg = wait_for(lambda: [c for c in harness.calls('resolve1') if c['method'] == 'RegisterService'],
15, 'the mDNS registration')
self.assertEqual(reg[0]['args'][:3], ['frame', 'frame', '_steamos-devkit._tcp'])
self.assertEqual(reg[0]['args'][3], 32000)
def test_pairing_mode_refusal_then_approval(self):
proc = self.connect()
# The first /register is refused: "Pair new host" isn't open.
wait_for(lambda: any(r['answer'] == 'not in pairing mode' for r in state()['pairing_requests']),
30, 'a refused pairing request')
ctl('pairing', 'on') # the user opens Settings > Developer > Pair new host
rc, out = self.finish(proc)
self.assertEqual(rc, 0, out)
self.assertIn('paired; key login OK', out)
answers = [r['answer'] for r in state()['pairing_requests']]
self.assertEqual(answers[-1], 'approve')
self.assertIn('not in pairing mode', answers)
self.assertIn('frame-control@', state()['pairing_requests'][-1]['request'])
# The hook turned sshd on and installed the RSA key for steamos.
self.assertTrue(harness.calls('steamos-enable-sshd'))
keys = self.authorized_keys()
self.assertRegex(keys, r'(?m)^ssh-rsa \S+ frame-control@\S+$')
self.assertNotIn('900b919520e4cf601998a71eec318fec', keys) # the magic phrase isn't stored
def test_denied_request_falls_back_to_the_password(self):
ctl('pairing', 'on')
ctl('answer', 'deny')
rc, out = self.finish(self.connect()) # no password to give: the fallback can't finish
self.assertEqual(rc, 1, out)
self.assertIn('devkit pairing failed: the pairing request was denied', out)
self.assertIn('falling back to the password', out)
self.assertNotIn('ssh-rsa', self.authorized_keys())
def test_service_down_uses_the_password(self):
ctl('devkit-service', 'off')
rc, out = self.finish(self.connect(askpass='frame'))
self.assertEqual(rc, 0, out)
self.assertIn('devkit service not reachable on port 32000', out)
self.assertIn('key login OK', out)
with open(os.path.expanduser('~/.ssh/id_ed25519_frame.pub')) as f:
ours = f.read().split()[1]
self.assertIn(ours, self.authorized_keys())
def test_prompt_left_unanswered_times_out(self):
# approve-ssh-key waits 30 s for Steam, then says so.
ctl('pairing', 'on')
ctl('answer', 'timeout')
rc, out = self.finish(self.connect(), timeout=150)
self.assertEqual(rc, 1, out)
self.assertIn('timeout - Steam did not respond to the pairing request', out)
def test_steam_not_running(self):
ctl('pairing', 'on')
ctl('steam', 'off')
rc, out = self.finish(self.connect())
self.assertEqual(rc, 1, out)
self.assertIn('devkit pairing failed: Steam is not running', out)
if __name__ == '__main__':
unittest.main()
+193
View File
@@ -0,0 +1,193 @@
"""Sideloaded titles (ui/frame_titles.py) through the server's HTTP API, against
Valve's devkit-utils talking to the fake Steam client."""
import hashlib
import http.server
import json
import os
import platform
import subprocess
import sys
import threading
import unittest
import zipfile
import harness
import tiny_programs
from harness import HOME, ROOT, ctl, exists, install_title, launches, ok, ssh, state, wait_for
harness.require()
GAMES = f'{HOME}/devkit-game'
# The host container shares the fake Frame's kernel, so a program of this machine's
# architecture really runs there. The other one fails to exec, unless QEMU is
# registered with binfmt_misc, which runs it emulated.
NATIVE = {'aarch64': 'arm64', 'arm64': 'arm64', 'x86_64': 'x86_64'}.get(platform.machine())
class Titles(harness.FrameTestCase):
def game_zip(self, name='Cool Game-v1.2-win64.zip', extra=b''):
path = self.path(name)
with zipfile.ZipFile(path, 'w') as z:
z.writestr('Cool Game/CoolGame.exe', tiny_programs.pe_x86_64())
z.writestr('Cool Game/CoolGame_Data/level0', b'level data' + extra)
return path
def folder(self, kind, name):
os.makedirs(self.path(name))
return tiny_programs.write(self.path(name), kind), self.path(name)
def assert_installed(self, gid, runtime, target):
game = state()['devkit_games'][gid]
self.assertEqual(game['settings']['compat_tool'], runtime)
self.assertEqual(game['argv'], [target])
steam = state()['steam']
shortcut = next(s for s in steam['shortcuts'] if s['devkit_gameid'] == gid)
self.assertEqual(steam['compat_tools'][str(shortcut['appid'])], runtime)
self.assertEqual(ssh(f'stat -c %a {GAMES}/{gid}/{target}').strip(), '755')
listed = {t['id']: t for t in ok('GET', '/api/titles')['titles']}
self.assertEqual(listed[gid]['runtime'], runtime)
self.assertTrue(listed[gid]['frame_control'])
return shortcut
def test_zip_with_a_windows_exe(self):
plan, job = install_title(self.game_zip())
self.assertEqual((plan['name'], plan['id'], plan['target']), ('Cool Game', 'Cool_Game', 'CoolGame.exe'))
self.assertEqual(plan['runtime'], 'proton-experimental')
self.assertIsNone(job['error'], job)
self.assertEqual(job['title']['runtime_label'], 'Proton Experimental')
self.assert_installed('Cool_Game', 'proton-experimental', 'CoolGame.exe')
game = state()['devkit_games']['Cool_Game']
self.assertEqual(game['settings'], {'steam_play': '1', 'steam_play_debug': '0',
'steam_play_debug_version': '2019', 'compat_tool': 'proton-experimental'})
self.assertTrue(exists(f'{GAMES}/Cool_Game/CoolGame_Data/level0'))
self.assertTrue(exists(f'{HOME}/devkit-utils/.frame-control-stamp'))
def test_folder_with_an_arm64_build_runs_natively(self):
_, folder = self.folder('arm64', 'Tiny Arm Game')
plan, job = install_title(folder)
self.assertEqual(plan['runtime'], 'SteamLinuxRuntime_4-arm64')
self.assertIsNone(job['error'], job)
self.assert_installed('Tiny_Arm_Game', 'SteamLinuxRuntime_4-arm64', 'fc-smoke-arm64')
ok('POST', '/api/titles', {'action': 'launch', 'id': 'Tiny_Arm_Game'})
run = launches('devkit')[-1]
# No runtime prefix: Steam ran the aarch64 build directly on the Frame.
self.assertEqual(run['command'], f'{GAMES}/Tiny_Arm_Game/fc-smoke-arm64')
if NATIVE == 'arm64':
self.assertIsNotNone(run['pid'], run)
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
30, 'the arm64 test program to exit')
self.assertEqual(done['exit'], 0)
def test_single_exe_upload_launch_and_remove(self):
exe = tiny_programs.write(self.tmp, 'exe')
plan, job = install_title(exe)
self.assertEqual(plan['id'], 'fc_smoke_exe')
self.assertIsNone(job['error'], job)
shortcut = self.assert_installed('fc_smoke_exe', 'proton-experimental', 'fc-smoke-exe.exe')
ok('POST', '/api/titles', {'action': 'launch', 'id': 'fc_smoke_exe'})
run = launches('devkit')[-1]
self.assertTrue(run['started'])
self.assertEqual(run['command'], f'proton waitforexitandrun "{GAMES}/fc_smoke_exe/fc-smoke-exe.exe"')
prefix = f"{HOME}/.local/share/Steam/steamapps/compatdata/{shortcut['appid']}"
self.assertTrue(exists(prefix))
ok('POST', '/api/titles', {'action': 'remove', 'id': 'fc_smoke_exe'})
after = state()
self.assertNotIn('fc_smoke_exe', after['devkit_games'])
self.assertEqual(after['steam']['shortcuts'], [])
for gone in (f'{GAMES}/fc_smoke_exe', prefix, *(f'{GAMES}/fc_smoke_exe-{k}.json'
for k in ('argv', 'env', 'settings', 'framecontrol'))):
self.assertFalse(exists(gone), gone)
self.assertEqual(ok('GET', '/api/titles')['titles'], [])
def test_names_steam_would_refuse_are_made_safe(self):
# Steam's create-shortcut takes ^[A-Za-z_][A-Za-z0-9_.]+$ only (device, 2026-09-27).
exe = self.path('2048-Deluxe.exe')
with open(exe, 'wb') as f:
f.write(tiny_programs.pe_x86_64())
plan, job = install_title(exe)
self.assertEqual(plan['id'], '_2048_Deluxe')
self.assertIsNone(job['error'], job)
self.assert_installed('_2048_Deluxe', 'proton-experimental', '2048-Deluxe.exe')
def test_x86_64_linux_build_needs_a_runtime_the_frame_lacks(self):
_, folder = self.folder('x86_64', 'Tiny PC Game')
plan, job = install_title(folder)
self.assertEqual(plan['runtime'], 'SteamLinuxRuntime_4')
self.assertIsNone(job['error'], job)
appid = self.assert_installed('Tiny_PC_Game', 'SteamLinuxRuntime_4', 'fc-smoke-x86_64')['appid']
# Steam answers the launch, then doesn't start it (docs/sideloading.md).
ok('POST', '/api/titles', {'action': 'launch', 'id': 'Tiny_PC_Game'})
run = launches('devkit')[-1]
self.assertFalse(run['started'])
self.assertEqual(run['message'], f'Tool 4183110 "Steam Linux Runtime 4.0" is found for appID {appid}, '
'but is not installed')
# The headset smoke test finds this in Steam's logs, where compat_log.txt has
# binary bytes in it: only grep -a returns the line (Frame, 2026-09-27).
self.assertIn(run['message'], ssh('grep -arshF "but is not installed" ~/.local/share/Steam/logs/'))
# With the runtime installed, it starts.
ctl('runtime', 'SteamLinuxRuntime_4', 'installed')
ok('POST', '/api/titles', {'action': 'launch', 'id': 'Tiny_PC_Game'})
run = launches('devkit')[-1]
self.assertTrue(run['started'])
if NATIVE == 'x86_64':
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
30, 'the x86-64 test program to exit')
self.assertEqual(done['exit'], 0)
def test_reinstall_with_another_runtime_keeps_one_shortcut(self):
zip_path = self.game_zip()
_, first = install_title(zip_path)
self.assertIsNone(first['error'], first)
appid = state()['devkit_games']['Cool_Game']['appid']
_, second = install_title(zip_path, runtime='proton-stable')
self.assertIsNone(second['error'], second)
self.assert_installed('Cool_Game', 'proton-stable', 'CoolGame.exe')
steam = state()['steam']
self.assertEqual([s['appid'] for s in steam['shortcuts']], [appid])
meta = json.loads(ssh(f'cat {GAMES}/Cool_Game-framecontrol.json'))
self.assertEqual(meta['runtime'], 'proton-stable')
def test_install_link_with_a_local_manifest(self):
# frame-control://install?manifest=... as a website would link it, served from
# this computer (FRAME_CONTROL_LOCAL_LINKS=1 lets http://127.0.0.1 through).
site = self.path('site')
os.makedirs(site)
with open(self.game_zip('linkgame-win64.zip'), 'rb') as f:
data = f.read()
with open(os.path.join(site, 'linkgame-win64.zip'), 'wb') as f:
f.write(data)
class Files(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args):
super().__init__(*args, directory=site)
def log_message(self, *args):
pass
httpd = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Files)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
self.addCleanup(httpd.shutdown)
base = f'http://127.0.0.1:{httpd.server_address[1]}'
with open(os.path.join(site, 'manifest.json'), 'w') as f:
json.dump({'schema': 'framedrop.install/v1', 'name': 'Link Game',
'files': [{'url': f'{base}/linkgame-win64.zip', 'sha256': hashlib.sha256(data).hexdigest(),
'size': len(data), 'exe': 'Cool Game/CoolGame.exe'}]}, f)
check = ok('POST', '/api/webinstall/check', {'manifest': f'{base}/manifest.json'})
self.assertEqual((check['name'], check['kind'], check['size']), ('Link Game', 'title', len(data)))
job_id = ok('POST', '/api/webinstall/start', {'id': check['id']})['job']
job = wait_for(lambda: (lambda j: j if j['phase'] in ('done', 'error') else None)(
ok('GET', f'/api/webinstall/job?id={job_id}')), 120, 'the link install')
self.assertEqual(job['phase'], 'done', job)
self.assert_installed('Link_Game', 'proton-experimental', 'CoolGame.exe')
def test_command_line_lists_what_the_app_installed(self):
install_title(self.game_zip())
out = subprocess.run([sys.executable, str(ROOT / 'ui' / 'frame_titles.py'), 'list'],
capture_output=True, text=True, timeout=60)
self.assertEqual(out.returncode, 0, out.stderr)
self.assertEqual([t['id'] for t in json.loads(out.stdout)], ['Cool_Game'])
if __name__ == '__main__':
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
# The fake Steam Frame: Arch Linux (SteamOS's base) with sshd, rsync, python3,
# Valve's steamos-devkit-service and hooks, a fake Steam client and stubs for
# the Frame-only commands Frame Control runs. See docs/testing.md.
#
# BASE: archlinux:base on x86_64; on arm64, Valve's Holo Core aarch64 preview
# (registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel), the
# Arch Linux ARM64 port the Frame's SteamOS is built on (docs/recovery-and-images.md).
# scripts/e2e.sh picks one from `uname -m`.
ARG BASE=archlinux:base
FROM ${BASE}
RUN (pacman-key --init && pacman-key --populate) >/dev/null 2>&1; \
pacman -Syu --noconfirm --needed openssh rsync python nodejs curl iproute2 procps-ng util-linux \
&& pacman -Scc --noconfirm
# The Frame's user is steamos (docs/ssh.md). Its password stands in for the
# Developer Mode password.
RUN useradd -m -u 1000 -s /bin/bash steamos \
&& echo 'steamos:frame' | chpasswd \
&& ssh-keygen -A
# Valve's service and hooks where the service looks for them. It runs as
# steamos, so it lists one user and properties.json says "login": "steamos",
# as the Frame's does (docs/ssh.md, verified 2026-09-26, BUILD_ID 20260922.6101926).
COPY steamos-devkit-service/src/steamos-devkit-service.py /usr/lib/steamos-devkit/
COPY steamos-devkit-service/hooks/ /usr/share/steamos-devkit/hooks/
COPY rootfs/ /
# /etc/os-release, pointed at /usr/lib/os-release, carries the Frame's
# VERSION_ID 0.3.0, VARIANT_ID vr and BUILD_ID 20260922.6101926 (docs/apks.md).
RUN ln -sf ../usr/lib/os-release /etc/os-release \
&& chmod 755 /usr/share/steamos-devkit/hooks/approve-ssh-key /usr/share/steamos-devkit/hooks/install-ssh-key \
/usr/share/steamos-devkit/hooks/devkit-1-identify /usr/local/bin/* /usr/local/lib/fakeframe/*.py \
/usr/bin/steamos-polkit-helpers/steamos-enable-sshd \
&& mkdir -p /usr/local/lib/fakeframe/bin /var/lib/fakeframe /var/log/fakeframe /home/steamos/devkit-game \
&& cp /usr/bin/sleep /usr/local/lib/fakeframe/bin/vrserver \
&& cp /usr/bin/sleep /usr/local/lib/fakeframe/bin/plasmashell \
&& chmod 1777 /var/lib/fakeframe /var/log/fakeframe \
&& chown -R steamos:steamos /home/steamos
EXPOSE 22 32000 9999
CMD ["python3", "/usr/local/lib/fakeframe/init.py"]
+54
View File
@@ -0,0 +1,54 @@
# The fake Frame and the computer Frame Control runs on, for tests/e2e.
# scripts/e2e.sh builds the two images, brings this up, runs the tests in
# `host` and takes it down again.
name: fakeframe-e2e
services:
fakeframe:
image: fakeframe-frame
pull_policy: never
hostname: frame # the Frame's default hostname (docs/ssh.md)
init: true
tmpfs:
# Small, so `fakeframe-ctl disk-full on` can fill it.
- /home/steamos/devkit-game:size=64m,mode=0755,exec
volumes:
- keys:/keys
# frame_status.py reads the battery and thermal zones from /sys, which is
# read-only in a container (and docker's AppArmor profile refuses writes
# under /sys even to a mount there). So each folder is a volume mounted
# twice: over /sys/class/... for reading, and under /var/lib/fakeframe/sys
# where the supervisor writes it (fakeframe-ctl battery).
- power:/sys/class/power_supply
- power:/var/lib/fakeframe/sys/power_supply
- thermal:/sys/class/thermal
- thermal:/var/lib/fakeframe/sys/thermal
environment:
FAKEFRAME_PAIRING_MODE: ${FAKEFRAME_PAIRING_MODE:-0}
healthcheck:
test: ["CMD", "fakeframe-ctl", "ping"]
interval: 2s
timeout: 10s
retries: 60
host:
image: fakeframe-host
pull_policy: never
init: true
depends_on:
fakeframe:
condition: service_healthy
volumes:
- ../..:/repo:ro
- keys:/keys:ro
environment:
FAKEFRAME_CTL: http://fakeframe:9999
FAKEFRAME_HOST: fakeframe
FRAME_E2E: "1"
healthcheck:
test: ["CMD", "test", "-f", "/home/tester/.ready"]
interval: 1s
timeout: 5s
retries: 120
volumes:
keys:
power:
thermal:
+14
View File
@@ -0,0 +1,14 @@
# The computer Frame Control runs on, for the e2e tests: Python 3.9 (the
# oldest the app supports), the OpenSSH client and rsync, and nothing else.
# The repository is mounted read-only at /repo (compose.yaml). OpenSSH reads
# ~/.ssh/config from the passwd home, not $HOME, which is why this is a
# container of its own rather than a HOME override on the machine running the tests.
FROM python:3.9-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client rsync procps \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1000 -s /bin/bash tester
COPY host/entrypoint.sh /usr/local/bin/fakeframe-host
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
USER tester
WORKDIR /repo
CMD ["fakeframe-host"]
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# The computer side of the harness: tester's ~/.ssh as Frame Control's setup
# leaves it (ui/frame_connect.py's own config block), pointing `frame` at the
# fake Frame, with the harness key the fake trusts.
set -euo pipefail
host=${FAKEFRAME_HOST:-fakeframe}
for _ in $(seq 1 240); do
[ -s /keys/id_ed25519_frame.pub ] && break
sleep 0.5
done
mkdir -p -m 700 ~/.ssh
install -m 600 /keys/id_ed25519_frame ~/.ssh/id_ed25519_frame
install -m 644 /keys/id_ed25519_frame.pub ~/.ssh/id_ed25519_frame.pub
python3 - "$host" > ~/.ssh/config <<'PY'
import sys
sys.path.insert(0, '/repo/ui')
import frame_connect
print('\n'.join(frame_connect.config_block(sys.argv[1])))
PY
chmod 600 ~/.ssh/config
until ssh-keyscan -T 2 "$host" > ~/.ssh/known_hosts 2>/dev/null && [ -s ~/.ssh/known_hosts ]; do
sleep 1
done
touch ~/.ready
exec sleep infinity
@@ -0,0 +1,21 @@
# The fake Frame's sshd. With Developer Mode on, the Frame takes key logins and
# the Developer Mode password for `steamos` (docs/ssh.md); the fake's password
# is "frame". MaxSessions leaves room for Frame Control's shared connection.
Port 22
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication yes
KbdInteractiveAuthentication no
UsePAM no
PrintMotd no
MaxSessions 64
MaxStartups 64:30:128
# OpenSSH 9.8+ penalises an address after failed logins by refusing it for a
# while. The pairing tests fail logins on purpose, so the fake turns that off.
# (Whether the Frame's sshd penalises is unchecked.)
PerSourcePenalties no
Subsystem sftp /usr/lib/ssh/sftp-server
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Stub for SteamOS's polkit helper, which approve-ssh-key runs once a pairing
is approved: asks the fake Frame's supervisor to (re)start sshd."""
import json
import sys
import urllib.request
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
fs.log('steamos-enable-sshd')
req = urllib.request.Request('http://127.0.0.1:9999/ctl', data=json.dumps({'args': ['sshd', 'on']}).encode(),
headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=10).read()
@@ -0,0 +1,9 @@
NAME="SteamOS"
PRETTY_NAME="SteamOS"
ID=steamos
ID_LIKE=arch
LOGO=steamos
HOME_URL="https://www.steampowered.com/"
VERSION_ID=0.3.0
VARIANT_ID=vr
BUILD_ID=20260922.6101926
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Flip the fake Frame's fault switches and read its state; `fakeframe-ctl help`.
Talks to the supervisor's control port, so it works the same over SSH
(`ssh frame fakeframe-ctl steam off`) and from the host container with
FAKEFRAME_CTL=http://fakeframe:9999.
"""
import json
import os
import sys
import urllib.error
import urllib.request
url = os.environ.get('FAKEFRAME_CTL', 'http://127.0.0.1:9999').rstrip('/')
req = urllib.request.Request(url + '/ctl', data=json.dumps({'args': sys.argv[1:]}).encode(),
headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=60) as r:
out = json.load(r)
except urllib.error.HTTPError as e:
out = json.load(e)
except OSError as e:
sys.exit(f'fakeframe-ctl: control port not answering ({e})')
if 'usage' in out:
print(out['usage'])
elif 'error' in out:
sys.exit(f"fakeframe-ctl: {out['error']}")
else:
print(json.dumps(out, indent=1))
if sys.argv[1:2] == ['ping'] and not out.get('ok'):
sys.exit(1)
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Stub for flatpak: --user installs and removals, and `list`, kept in the state file.
Nothing is downloaded; an app id containing "missing" fails like an unknown ref."""
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('flatpak', args=args)
words = [a for a in args if not a.startswith('-')]
cmd = words[0] if words else ''
if cmd == 'remote-add':
pass
elif cmd == 'install':
app = words[-1]
if 'missing' in app:
sys.exit(f'error: Nothing matches {app} in remote flathub')
with fs.update() as s:
if not any(f['id'] == app for f in s['flatpaks']):
s['flatpaks'].append({'id': app, 'name': app.rsplit('.', 1)[-1], 'version': '1.0', 'installation': 'user'})
print(f'Installing {app}\nInstallation complete.')
elif cmd == 'uninstall':
app = words[-1]
with fs.update() as s:
before = len(s['flatpaks'])
s['flatpaks'] = [f for f in s['flatpaks'] if f['id'] != app]
gone = len(s['flatpaks']) < before
if not gone:
sys.exit(f'error: {app}/*unspecified*/*unspecified* not installed')
print('Uninstall complete.')
elif cmd == 'list':
for f in fs.read()['flatpaks']:
print('\t'.join((f['id'], f['name'], f['version'], f['installation'])))
elif cmd == 'run':
pass
else:
sys.exit(f'flatpak stub: unsupported {args}')
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Stub for gamescopectl: `screenshot FILE` writes a small PNG, as gamescope does
(asynchronously on the Frame, which server.SCREENSHOT waits out)."""
import struct
import sys
import zlib
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('gamescopectl', args=args)
if len(args) != 2 or args[0] != 'screenshot':
sys.exit(f'gamescopectl stub: unsupported {args}')
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
w, h = 64, 36
rows = b''.join(b'\0' + b''.join(bytes((x * 4, y * 7, 160)) for x in range(w)) for y in range(h))
png = (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b''))
with open(args[1], 'wb') as f:
f.write(png)
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Stub for nmcli: the Wi-Fi network frame_status.py reads with
`nmcli -t -f active,ssid,signal dev wifi` (':' inside fields escaped as '\\:')."""
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
fs.log('nmcli', args=sys.argv[1:])
print('yes:Fake\\:Frame Wi-Fi:72')
print('no:Neighbours:31')
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Stub for podman, for the fake Lepton instances (lepton.py): ps, stop, exec.
`podman ps --format "{{.Names}} {{.Labels.adb_port}}"` lists what's running,
as frame_android.running_instances reads it (docs/apks.md)."""
import os
import sys
import time
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
def alive(c):
try:
os.kill(c['pid'], 0)
return True
except OSError:
return False
args = sys.argv[1:]
fs.log('podman', args=args)
cmd = args[0] if args else ''
containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
if cmd == 'ps':
for name, c in sorted(containers.items()):
print(f"{name} {c['port']}")
elif cmd == 'stop':
name = args[-1]
c = containers.get(name)
if not c:
sys.exit(f'Error: no container with name or ID "{name}" found: no such container')
os.kill(c['pid'], 15)
for _ in range(50):
if not alive(c):
break
time.sleep(0.1)
print(name)
elif cmd == 'exec':
name, rest = args[1], ' '.join(args[2:])
if name not in containers:
sys.exit(f'Error: no container with name or ID "{name}" found: no such container')
if 'pidof' in rest:
print(4242) # the app is "up"; there's no Android to ask
# logcat and anything else: nothing to report
else:
sys.exit(f'podman stub: unsupported {args}')
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
Frame Control sends text to the headset desktop's clipboard (server.PASTE,
verified 2026-09-25)."""
import os
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('qdbus6', args=args[:3], bus=os.environ.get('DBUS_SESSION_BUS_ADDRESS'))
if args[:3] == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.setClipboardContents'] and len(args) == 4:
if not os.environ.get('DBUS_SESSION_BUS_ADDRESS'):
sys.exit('Could not connect to D-Bus server')
with fs.update() as s:
s['clipboard'].append(args[3])
else:
sys.exit(f'qdbus6 stub: unsupported {args}')
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Stub for SteamOS's `steam` command: hands steam:// URLs to the running client.
On the Frame, `steam steam://rungameid/N` over SSH starts the game in the
running client (docs/apks.md, docs/steam-games.md, 2026-09-25). How the real
wrapper passes the URL on isn't documented; this writes it as a line on
~/.steam/steam.pipe, which fakesteam reads (guess).
"""
import os
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
running = fs.steam_pid() is not None
fs.log('steam', args=args, client_running=running)
if not running:
# The real command would start the Steam client; this one can't.
print('steam: the Steam client is not running', file=sys.stderr)
sys.exit(0)
fd = os.open(os.path.expanduser('~/.steam/steam.pipe'), os.O_RDWR)
try:
os.write(fd, (' '.join(args) + '\n').encode())
finally:
os.close(fd)
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Stub for PipeWire's wpctl: the default sink's volume and mute, kept in the state file.
Output format as wpctl prints it: "Volume: 0.40" plus " [MUTED]"."""
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('wpctl', args=args)
if len(args) == 2 and args[0] == 'get-volume':
v = fs.read()['volume']
print(f"Volume: {v['level']:.2f}" + (' [MUTED]' if v['muted'] else ''))
elif len(args) == 3 and args[0] == 'set-volume':
with fs.update() as s:
s['volume']['level'] = max(0.0, min(1.5, float(args[2])))
elif len(args) == 3 and args[0] == 'set-mute':
with fs.update() as s:
s['volume']['muted'] = args[2] == 'toggle' and not s['volume']['muted'] or args[2] == '1'
else:
sys.exit(f'wpctl stub: unsupported {args}')
@@ -0,0 +1,157 @@
// The fake Steam client's JavaScript context ("SharedJSContext"), for fakesteam's
// DevTools server. fakesteam sends one JSON request per line on stdin:
// {"id": N, "expression": "...", "awaitPromise": true, "steam": {...state...}}
// and gets one line back: {"id": N, "result": <CDP Runtime.evaluate result>, "steam": {...}}.
// The expression runs for real in a V8 context holding the objects below, so
// whatever JavaScript Frame Control sends (async functions, optional chaining,
// Map) behaves as it would in Steam's CEF; only the objects are stand-ins.
//
// Shapes copy what was seen through the Frame's DevTools port on 2026-09-25
// (docs/steam-games.md and docs/apks.md, BUILD_ID 20260922.6101926):
// appStore.allApps, a Map in downloadsStore.m_DownloadOverview keyed by client
// id with "0" for this machine, SteamClient.Installs.GetInstallManagerInfo /
// ContinueInstall, SteamClient.User.GetIPCountry, and SteamClient.Apps.AddShortcut
// + SetShortcutName / SetShortcutStartDir for non-Steam shortcuts.
'use strict';
const vm = require('vm');
const readline = require('readline');
const SHORTCUT_TYPE = 1073741824; // app_type of a non-Steam shortcut, as steam_shortcuts.py filters
function newShortcutId(steam) {
// Real shortcut ids are 32-bit with the top bit set (T3 Code's was 3130509679).
steam.next_shortcut = (steam.next_shortcut || 0) + 1;
return (0x80000000 + ((steam.next_shortcut * 2654435761) >>> 1)) >>> 0;
}
function build(steam) {
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
const gameOverview = a => ({
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1,
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
rt_last_time_played: a.last_played || 0,
local_per_client_data: {
installed: !!a.installed, display_status: a.display_status ?? (a.installed ? 1 : 0),
status_percentage: a.status_percentage ?? 0,
},
});
const shortcutOverview = s => ({
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
});
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
const im = () => steam.install_manager;
const queue = appid => {
// State 14: Steam queued the download (Balatro on the Frame, docs/steam-games.md).
const app = steam.apps.find(a => a.appid === appid);
Object.assign(im(), { eInstallState: 14, currentAppID: appid });
if (app) {
steam.download = { update_appid: appid, update_state: 'Downloading', paused: false,
update_is_install: true, overall_percent_complete: 0,
overall_estimated_time_remaining_sec: 7, update_network_bytes_per_second: 9500000 };
}
};
return {
appStore: {
get allApps() { return allApps(); },
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
},
downloadsStore: {
get m_DownloadOverview() { return steam.download ? new Map([['0', { ...steam.download }]]) : new Map(); },
},
SteamClient: {
Apps: {
async AddShortcut(name, exe, launchOptions, cmdLine) {
const appid = newShortcutId(steam);
const base = String(exe).split('/').pop();
steam.shortcuts.push({ appid, name: base, exe: String(exe), start_dir: '', icon: '',
launch_options: String(launchOptions || ''), devkit_gameid: null });
return appid;
},
SetShortcutName(id, name) { const s = findShortcut(id); if (s) s.name = String(name); },
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
RemoveShortcut(id) {
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
delete steam.compat_tools[String(id)];
},
},
Installs: {
async GetInstallManagerInfo() {
const i = im();
return { eInstallState: i.eInstallState, currentAppID: i.currentAppID,
nDiskSpaceRequired: i.nDiskSpaceRequired, nDiskSpaceAvailable: i.nDiskSpaceAvailable,
eAppError: i.eAppError ?? 0, errorDetail: i.errorDetail ?? '' };
},
// Broforce stopped at state 7 and ContinueInstall() queued it (docs/steam-games.md).
ContinueInstall() { if (im().eInstallState === 7) queue(im().currentAppID); },
CancelInstall() { Object.assign(im(), { eInstallState: 16 }); },
// Calling OpenInstallWizard directly did nothing on the Frame: the state stayed 0.
OpenInstallWizard() {},
},
User: {
async GetIPCountry() { return steam.country; },
},
},
};
}
// What CDP's Runtime.evaluate returns with returnByValue.
function remote(value) {
if (value === undefined) return { type: 'undefined' };
if (value === null) return { type: 'object', subtype: 'null', value: null };
const type = typeof value;
if (type === 'object') return { type: 'object', value: JSON.parse(JSON.stringify(value)) };
if (type === 'function') return { type: 'function', description: String(value) };
return { type, value, description: String(value) };
}
function exception(err, inPromise) {
// Chrome puts the stack in description; its first line is enough here.
const description = err && err.name ? `${err.name}: ${err.message}` : String(err);
return {
result: { type: 'object', subtype: 'error', className: (err && err.name) || 'Error', description },
exceptionDetails: {
exceptionId: 1, text: inPromise ? 'Uncaught (in promise)' : 'Uncaught', lineNumber: 0, columnNumber: 0,
exception: { type: 'object', subtype: 'error', className: (err && err.name) || 'Error', description },
},
};
}
async function evaluate(req) {
const steam = req.steam;
const ctx = vm.createContext(build(steam));
let value;
try {
value = vm.runInContext(req.expression, ctx, { timeout: 5000 });
} catch (err) {
return exception(err, false);
}
if (req.awaitPromise && value && typeof value.then === 'function') {
try {
value = await value;
} catch (err) {
return exception(err, true);
}
}
try {
return { result: remote(value) };
} catch (err) {
return exception(err, false);
}
}
// One request at a time: fakesteam holds the state lock around each.
const rl = readline.createInterface({ input: process.stdin });
let chain = Promise.resolve();
rl.on('line', line => {
chain = chain.then(async () => {
const req = JSON.parse(line);
const result = await evaluate(req);
process.stdout.write(JSON.stringify({ id: req.id, result, steam: req.steam }) + '\n');
});
});
@@ -0,0 +1,183 @@
"""Shared state of the fake Frame: one JSON file plus a log of stub calls.
Every fake part (the supervisor, fakesteam, the command stubs) reads and
writes /var/lib/fakeframe/state.json through update(), which holds an
exclusive flock, so separate processes never lose each other's changes.
Tests read the file over SSH (`fakeframe-ctl state`) or the control port.
"""
import contextlib
import fcntl
import json
import os
import time
DIR = '/var/lib/fakeframe'
STATE = os.path.join(DIR, 'state.json')
CALLS = os.path.join(DIR, 'calls.jsonl')
LOCK = os.path.join(DIR, 'state.lock')
HOME = '/home/steamos'
STEAM_ROOT = HOME + '/.local/share/Steam'
DEVKIT_GAMES = HOME + '/devkit-game'
LEPTON = STEAM_ROOT + '/steamapps/common/Lepton/lepton'
# Compat tools and the Steam app ids of their depots. 4183110 is the id Steam
# printed on the Frame for "Steam Linux Runtime 4.0" (docs/sideloading.md,
# BUILD_ID 20260922.6101926); Lepton Development is 3056000 (docs/apks.md).
# The others are placeholders: nothing in Frame Control reads them.
RUNTIME_APPIDS = {'proton-experimental': 1493710, 'proton-stable': 3658110,
'SteamLinuxRuntime_4-arm64': 4183120, 'SteamLinuxRuntime_4': 4183110,
'lepton': 3056000}
RUNTIME_NAMES = {'proton-experimental': 'Proton Experimental', 'proton-stable': 'Proton 11.0',
'SteamLinuxRuntime_4-arm64': 'Steam Linux Runtime 4.0 (arm64)',
'SteamLinuxRuntime_4': 'Steam Linux Runtime 4.0', 'lepton': 'Lepton Development'}
def default_state():
return {
'switches': {
'pairing_mode': False, # Steam Settings > Developer > Pair new host open or not
'pairing_answer': 'approve', # approve | deny | timeout
'steam': True, # Steam client running
'asleep': False, # headset asleep: ports 22 and 32000 accept but never answer
'sshd': True,
'devkit_service': True,
'disk_full': False,
},
# Which compat tools are installed. On the Frame the x86-64 Steam Linux
# Runtime 4.0 wasn't, and a devkit title didn't install it (docs/sideloading.md,
# 2026-09-26, BUILD_ID 20260922.6101926).
'runtimes': {'proton-experimental': True, 'proton-stable': True,
'SteamLinuxRuntime_4-arm64': True, 'SteamLinuxRuntime_4': False, 'lepton': True},
# /sys/class/power_supply values, in the units the kernel uses (µV, µA, tenths
# of °C), as frame_status.py reads them (paths verified on build 20260922; its
# docstring gives the charger type 'C PD [PD_PPS]' at 20 W as seen on the Frame).
'battery': {'capacity': 76, 'status': 'Charging', 'voltage_now': 7700000, 'current_now': 1250000,
'time_to_full_now': 2520, 'temp': 312, 'health': 'Good',
'charger': {'online': 1, 'usb_type': 'C PD [PD_PPS]', 'voltage_now': 9000000,
'current_now': 2220000}},
'thermal_mc': [41500, 38250], # thermal_zone*/temp, millidegrees C
'volume': {'level': 0.4, 'muted': False},
'flatpaks': [],
'clipboard': [],
'steam': {
'country': 'AU', # GetIPCountry() answered "AU" (docs/steam-games.md)
'next_shortcut': 0,
# A few owned games. Balatro went straight to install state 14 and
# Broforce stopped at 7 on the Frame (docs/steam-games.md, 2026-09-25,
# BUILD_ID 20260922.6101926); `wizard` makes the fake do the same.
'apps': [
{'appid': 2379780, 'display_name': 'Balatro', 'installed': False, 'size': 67000000,
'packed': 3 << 8 | 3, 'vr': False, 'wizard': 14},
{'appid': 274190, 'display_name': 'Broforce', 'installed': False, 'size': 600000000,
'packed': 0 << 8 | 2, 'vr': False, 'wizard': 7},
{'appid': 620980, 'display_name': 'Beat Saber', 'installed': True, 'size': 4200000000,
'packed': 3 << 8 | 1, 'vr': True, 'vr_only': True, 'wizard': 14},
],
'shortcuts': [], # {appid, name, exe, start_dir, icon, devkit_gameid}
'compat_tools': {}, # shortcut appid (str) -> compat tool alias (CompatToolMapping)
'install_manager': {'eInstallState': 0, 'currentAppID': 0, 'nDiskSpaceRequired': 0,
'nDiskSpaceAvailable': 0},
'download': None,
'pages': [], # extra DevTools targets, e.g. a store page
},
'devkit_games': {}, # gameid -> what create-shortcut registered
'launches': [], # every launch Steam was asked for, and what it did
'pairing_requests': [],
'lepton': {}, # container name -> {port, pid, package dir}
}
def _share(fd):
# Files are made by root or steamos, whichever comes first; both write them (umask aside).
try:
os.fchmod(fd, 0o666)
except OSError:
pass # not ours: whoever made it already did this
def _ensure_dir():
os.makedirs(DIR, exist_ok=True)
@contextlib.contextmanager
def _locked(kind):
_ensure_dir()
fd = os.open(LOCK, os.O_RDWR | os.O_CREAT, 0o666)
_share(fd)
try:
fcntl.flock(fd, kind)
yield
finally:
os.close(fd)
def _load():
try:
with open(STATE) as f:
return json.load(f)
except (OSError, ValueError):
return default_state()
def _save(state):
tmp = f'{STATE}.{os.getpid()}.tmp'
with open(tmp, 'w') as f:
json.dump(state, f, indent=1, sort_keys=True)
os.chmod(tmp, 0o666) # the supervisor (root) and steamos both write it
os.replace(tmp, STATE)
def read():
with _locked(fcntl.LOCK_SH):
return _load()
@contextlib.contextmanager
def update():
"""with update() as s: change s; it's written back when the block ends without an error."""
with _locked(fcntl.LOCK_EX):
state = _load()
yield state
_save(state)
def reset():
with _locked(fcntl.LOCK_EX):
_save(default_state())
with open(CALLS, 'w'):
pass
os.chmod(CALLS, 0o666)
def log(tool, **fields):
"""Append one call record to calls.jsonl."""
_ensure_dir()
line = json.dumps({'time': round(time.time(), 3), 'tool': tool, **fields}) + '\n'
fd = os.open(CALLS, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o666)
_share(fd)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
os.write(fd, line.encode())
finally:
os.close(fd)
def calls(tool=None):
try:
with open(CALLS) as f:
out = [json.loads(line) for line in f if line.strip()]
except OSError:
return []
return [c for c in out if tool is None or c['tool'] == tool]
def steam_pid():
"""The fake Steam client's pid if it's running, as devkit_utils.validate_steam_client checks."""
try:
with open(HOME + '/.steam/steam.pid') as f:
pid = int(f.read())
os.kill(pid, 0)
return pid
except (OSError, ValueError):
return None
+506
View File
@@ -0,0 +1,506 @@
#!/usr/bin/env python3
"""A stand-in for the Frame's Steam client, run as steamos by the supervisor.
What it copies, and from where (BUILD_ID 20260922.6101926 unless noted):
- The devkit IPC Valve's devkit-utils and the devkit service's hooks use:
~/.steam/steam.pid (validate_steam_client), ~/.steam/steam.token, and
"devkit-1 steam://devkit-1/<token>/<command>?<query>" lines on the
~/.steam/steam.pipe FIFO, answered by writing <response> or
<response>.error (with <response>.lock while writing), as
devkit_utils.wait_on_file_response describes. Commands: approve-ssh-key,
create-shortcut, run-game, list-shortcuts and delete-shortcut (all that
devkit-utils and the hooks send).
- steam:// URLs that the `steam` wrapper forwards (rungameid, install, store).
- The DevTools endpoint on 127.0.0.1:8080 with a SharedJSContext target
(docs/steam-games.md, docs/apks.md). Expressions run in node against
cef_shim.js.
State lives in fakeframe_state (the "steam", "devkit_games", "launches" and
"pairing_requests" keys). Anything not seen on a headset is marked "guess".
"""
import base64
import hashlib
import json
import os
import re
import secrets
import signal
import struct
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fakeframe_state as fs # noqa: E402
HOME = fs.HOME
STEAM_DIR = HOME + '/.steam'
PID_FILE, TOKEN_FILE, PIPE = (f'{STEAM_DIR}/steam.{n}' for n in ('pid', 'token', 'pipe'))
CONSOLE_LOG = fs.STEAM_ROOT + '/logs/console_log.txt' # guess: which file Steam logs devkit launches to
# Steam logs compat tool lookups here, and on the Frame the file has binary bytes
# in it, so plain grep only says "binary file matches" (headset smoke test,
# 2026-09-27, BUILD_ID 20260922.6101926). The fake starts it with a NUL to match.
COMPAT_LOG = fs.STEAM_ROOT + '/logs/compat_log.txt'
DEVTOOLS_PORT = 8080
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
GAME_LOGS = '/var/log/fakeframe'
# The 403 text /register returned while Steam wasn't on "Pair new host"
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
# client's error file through as {"error": ...}, so this is what Steam writes.
PAIRING_MODE_ERROR = 'please put the Steam client in pairing mode: Settings -> Developer -> Pair new host'
# Guess: what Steam writes when the prompt is declined hasn't been seen.
PAIRING_DENIED = 'the pairing request was denied on the device'
# Steam refused create-shortcut for ids like "fc-smoke-exe" with this text, and
# took the same program as "FCSmokeProbe" (headset smoke test, 2026-09-27,
# BUILD_ID 20260922.6101926). Valve's client only allows ids matching
# GAMEID_ALLOWED_PATTERN (devkit_client/gui2/gui2.py), so the fake checks that.
INVALID_ARGUMENTS = 'missing/invalid arguments\n'
GAMEID_ALLOWED = re.compile(r'[A-Za-z_][A-Za-z0-9_.]+')
_lock = threading.RLock() # one state change at a time within this process (the file lock covers others)
TOKEN = secrets.token_hex(16)
def console(line):
os.makedirs(os.path.dirname(CONSOLE_LOG), exist_ok=True)
with open(CONSOLE_LOG, 'a') as f:
f.write(time.strftime('[%Y-%m-%d %H:%M:%S] ') + line + '\n')
def compat(line):
os.makedirs(os.path.dirname(COMPAT_LOG), exist_ok=True)
with open(COMPAT_LOG, 'ab') as f:
if f.tell() == 0:
f.write(b'\0\n')
f.write((time.strftime('[%Y-%m-%d %H:%M:%S] ') + line + '\n').encode())
def respond(path, text=None, error=None):
"""Answer a devkit request the way devkit_utils.wait_on_file_response expects."""
lock = path + '.lock'
open(lock, 'w').close()
with open(path + '.error' if error is not None else path, 'w') as f:
f.write(error if error is not None else text)
os.unlink(lock)
# ---- the Node side of the DevTools endpoint ----------------------------------
class JS:
def __init__(self):
self.proc = None
self.next = 0
def _start(self):
shim = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cef_shim.js')
self.proc = subprocess.Popen(['node', shim], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
def evaluate(self, expression, await_promise):
with _lock:
if not self.proc or self.proc.poll() is not None:
self._start()
self.next += 1
with fs.update() as state:
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
self.proc.stdin.flush()
reply = json.loads(self.proc.stdout.readline())
state['steam'] = reply['steam']
return reply['result']
JS_WORKER = JS()
# ---- devkit commands ----------------------------------------------------------
def shortcut_for(state, gameid):
return next((s for s in state['steam']['shortcuts'] if s.get('devkit_gameid') == gameid), None)
def new_shortcut_id(steam):
steam['next_shortcut'] = steam.get('next_shortcut', 0) + 1
return (0x80000000 + ((steam['next_shortcut'] * 2654435761 & 0xFFFFFFFF) >> 1)) & 0xFFFFFFFF
def read_json(path, default=None):
try:
with open(path) as f:
return json.load(f)
except (OSError, ValueError):
return default
def cmd_approve_ssh_key(q):
with fs.update() as state:
sw = state['switches']
answer = sw['pairing_answer'] if sw['pairing_mode'] else 'not in pairing mode'
state['pairing_requests'].append({'time': time.time(), 'request': q.get('request', ''), 'answer': answer})
if answer == 'not in pairing mode':
respond(q['response'], error=PAIRING_MODE_ERROR)
elif answer == 'approve':
respond(q['response'], text='approved') # guess: the hook only checks that the file appears
elif answer == 'deny':
respond(q['response'], error=PAIRING_DENIED)
# 'timeout': never answer; the hook gives up after 30 s.
def cmd_create_shortcut(q):
gameid = q.get('gameid', '')
folder = q.get('directory') or fs.DEVKIT_GAMES
path = os.path.join(folder, gameid)
if not GAMEID_ALLOWED.fullmatch(gameid):
respond(q['response'], error=INVALID_ARGUMENTS)
return
if not os.path.isdir(path):
respond(q['response'], error=f'no devkit game folder {path}') # guess: wording
return
argv = read_json(f'{folder}/{gameid}-argv.json', [])
settings = read_json(f'{folder}/{gameid}-settings.json', {})
env = read_json(f'{folder}/{gameid}-env.json', {})
with fs.update() as state:
steam = state['steam']
sc = shortcut_for(state, gameid)
if not sc:
sc = {'appid': new_shortcut_id(steam), 'name': gameid, 'exe': '', 'start_dir': path, 'icon': '',
'launch_options': '', 'devkit_gameid': gameid}
steam['shortcuts'].append(sc)
sc['exe'] = argv[0] if argv else ''
tool = settings.get('compat_tool')
# Steam maps the title to the chosen compat tool (CompatToolMapping and
# compat_log.txt on the Frame, docs/sideloading.md, 2026-09-26).
if tool:
steam['compat_tools'][str(sc['appid'])] = tool
else:
steam['compat_tools'].pop(str(sc['appid']), None)
state['devkit_games'][gameid] = {'appid': sc['appid'], 'directory': path, 'argv': argv,
'settings': settings, 'env': env, 'registered': time.time()}
console(f'devkit create-shortcut: registered devkit game "{gameid}"')
respond(q['response'], text='') # Steam's answer was empty on the Frame (2026-09-27)
def cmd_list_shortcuts(q):
# The reply format devkit_utils.resolve.resolve_shortcuts asserts.
with fs.update() as state:
ids = sorted(state['devkit_games'])
respond(q['response'], text=json.dumps({'version': 2, 'gameids': ids}))
def cmd_delete_shortcut(q):
gameid = q.get('gameid', '')
with fs.update() as state:
sc = shortcut_for(state, gameid)
state['devkit_games'].pop(gameid, None)
if sc:
state['steam']['shortcuts'].remove(sc)
state['steam']['compat_tools'].pop(str(sc['appid']), None)
# Remove also deleted the title's Proton prefix on the Frame (docs/sideloading.md).
subprocess.run(['rm', '-rf', f"{fs.STEAM_ROOT}/steamapps/compatdata/{sc['appid']}"])
console(f'devkit delete-shortcut: removed devkit game "{gameid}"')
respond(q['response'], text=f'deleted {gameid}\n')
def cmd_run_game(q):
gameid = q.get('gameid', '')
with fs.update() as state:
game = state['devkit_games'].get(gameid)
tool = game and state['steam']['compat_tools'].get(str(game['appid']))
runtimes = state['runtimes']
if not game:
respond(q['response'], error=f'unknown devkit game "{gameid}"') # guess: wording
return
target = game['argv'][0] if game['argv'] else ''
full = os.path.join(game['directory'], target.strip('"'))
record = {'kind': 'devkit', 'gameid': gameid, 'appid': game['appid'], 'tool': tool, 'time': time.time()}
if tool and not runtimes.get(tool, False):
# Seen for the x86-64 runtime (docs/sideloading.md, 2026-09-26): Steam
# logs this and the game doesn't start.
name = fs.RUNTIME_NAMES.get(tool, tool)
record.update(started=False, message=f'Tool {fs.RUNTIME_APPIDS.get(tool, 0)} "{name}" is found for '
f'appID {game["appid"]}, but is not installed')
compat(record['message'])
elif tool and tool.startswith('proton'):
# How Steam ran a sideloaded .exe on the Frame (docs/sideloading.md).
prefix = f"{fs.STEAM_ROOT}/steamapps/compatdata/{game['appid']}/pfx"
os.makedirs(prefix, exist_ok=True)
record.update(started=True, command=f'proton waitforexitandrun "{full}"', prefix=prefix)
else:
# An aarch64 title ran natively, without SteamLinuxRuntime_4-arm64's
# _v2-entry-point prefix, even though Steam recorded the mapping
# (docs/sideloading.md, 2026-09-26). So does the fake, for real.
record.update(started=True, command=full)
record['run'] = (full, game['directory'], {**game.get('env', {})}, f'devkit-{gameid}')
add_launch(record)
console(record['message'] if not record['started'] else f'devkit run-game: started devkit game "{gameid}"')
respond(q['response'], text='OK\n') # guess: steam-devkit-rpc only checks that it arrives
DEVKIT = {'approve-ssh-key': cmd_approve_ssh_key, 'create-shortcut': cmd_create_shortcut,
'list-shortcuts': cmd_list_shortcuts, 'delete-shortcut': cmd_delete_shortcut,
'run-game': cmd_run_game}
def add_launch(record):
"""Log a launch in the state. record['run'] = (path, cwd, env, log name) also starts the
program the way Steam would, and notes its pid and, once it ends, its exit status."""
run, proc = record.pop('run', None), None
if run:
path, cwd, env, label = run
os.makedirs(GAME_LOGS, exist_ok=True)
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
try:
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
record['pid'] = proc.pid
except OSError as e:
record.update(pid=None, exec_error=str(e))
with fs.update() as state: # before the reaper looks for it, however fast the program is
record['n'] = len(state['launches'])
state['launches'].append(record)
if proc:
def reap():
code = proc.wait()
with fs.update() as state:
mine = state['launches'][record['n']:record['n'] + 1]
if mine and mine[0].get('pid') == proc.pid: # not a reset's fresh list
mine[0]['exit'] = code
threading.Thread(target=reap, daemon=True).start()
# ---- steam:// URLs from the `steam` wrapper ----------------------------------
def url_rungameid(gid):
gid = int(gid)
record = {'kind': 'rungameid', 'gameid': gid, 'time': time.time()}
if gid >= 1 << 32:
# A non-Steam shortcut: (appid << 32) | 0x02000000 (docs/apks.md).
appid = gid >> 32
with fs.update() as state:
sc = next((s for s in state['steam']['shortcuts'] if s['appid'] == appid), None)
if not sc:
record.update(started=False, message=f'no shortcut {appid}')
else:
# Steam sets STEAM_FOSSILIZE_DUMP_PATH for shortcut launches but not
# STEAM_COMPAT_SHADER_PATH (docs/apks.md, 2026-09-25).
env = {'SteamAppId': str(appid), 'SteamGameId': str(gid),
'STEAM_FOSSILIZE_DUMP_PATH': f'{fs.STEAM_ROOT}/steamapps/shadercache/{appid}/fozpipelinesv6'}
record.update(appid=appid, started=True, command=sc['exe'],
run=(sc['exe'], sc.get('start_dir') or HOME, env, f'shortcut-{appid}'))
else:
with fs.update() as state:
app = next((a for a in state['steam']['apps'] if a['appid'] == gid), None)
record.update(appid=gid, started=bool(app and app['installed']),
message=None if app and app['installed'] else 'not installed')
add_launch(record)
def url_install(appid):
appid = int(appid)
free = os.statvfs(HOME)
with fs.update() as state:
steam = state['steam']
app = next((a for a in steam['apps'] if a['appid'] == appid), None)
im = steam['install_manager']
if not app:
return # not owned: Steam shows a store or license dialog, state stays 0
im.update(currentAppID=appid, nDiskSpaceRequired=app['size'],
nDiskSpaceAvailable=free.f_bavail * free.f_frsize, eInstallState=app.get('wizard', 14))
if im['eInstallState'] == 14:
steam['download'] = {'update_appid': appid, 'update_state': 'Downloading', 'paused': False,
'update_is_install': True, 'overall_percent_complete': 0,
'overall_estimated_time_remaining_sec': 7,
'update_network_bytes_per_second': 9500000}
def url_store(appid):
# A store page showed up in the DevTools page list (docs/steam-games.md).
names = {1145360: 'Hades'}
with fs.update() as state:
state['steam']['pages'].append({'title': f"{names.get(int(appid), 'App ' + appid)} on Steam",
'url': f'https://store.steampowered.com/app/{appid}/'})
URLS = [(re.compile(r'steam://rungameid/(\d+)$'), url_rungameid),
(re.compile(r'steam://install/(\d+)$'), url_install),
(re.compile(r'steam://store/(\d+)$'), url_store)]
def handle_line(line):
line = line.strip()
if not line:
return
fs.log('steam.pipe', line=line)
try:
if line.startswith('devkit-1 '):
m = re.fullmatch(r'steam://devkit-1/([^/]*)/([^?]*)\??(.*)', line.split(' ', 1)[1])
if not m or m[1] != TOKEN:
console('devkit-1: rejected a command with a bad token')
return
cmd = m[2].rstrip('/') # steam-devkit-rpc sends "run-game/?..."
q = {k: v[0] for k, v in parse_qs(m[3], keep_blank_values=True).items()}
handler = DEVKIT.get(cmd)
if not handler:
console(f'devkit-1: unknown command {cmd}')
if 'response' in q:
respond(q['response'], error=f'unknown command {cmd}')
return
handler(q)
return
for pat, fn in URLS:
m = pat.match(line.split()[-1])
if m:
fn(*m.groups())
return
console(f'ignored: {line}')
except Exception as e: # keep reading the pipe whatever one command did
console(f'error handling {line!r}: {type(e).__name__}: {e}')
def read_pipe():
# O_RDWR: there is always a writer, so reads never hit EOF between clients.
fd = os.open(PIPE, os.O_RDWR)
with os.fdopen(fd, 'rb', buffering=0) as f:
buf = b''
while True:
chunk = f.read(4096)
buf += chunk
while b'\n' in buf:
line, buf = buf.split(b'\n', 1)
threading.Thread(target=handle_line, args=(line.decode('utf-8', 'replace'),), daemon=True).start()
# ---- DevTools HTTP + WebSocket -------------------------------------------------
def targets():
base = {'type': 'page', 'description': '', 'faviconUrl': ''}
out = [{**base, 'id': TARGET_ID, 'title': 'SharedJSContext',
'url': 'https://steamloopback.host/index.html',
'devtoolsFrontendUrl': f'/devtools/inspector.html?ws=127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{TARGET_ID}',
'webSocketDebuggerUrl': f'ws://127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{TARGET_ID}'}]
for i, p in enumerate(fs.read()['steam'].get('pages', [])):
tid = f'{i + 1:032X}'
out.append({**base, 'id': tid, 'title': p['title'], 'url': p['url'],
'webSocketDebuggerUrl': f'ws://127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{tid}'})
return out
class DevTools(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, fmt, *args):
pass
def do_GET(self):
path = self.path.split('?')[0].rstrip('/')
if self.headers.get('Upgrade', '').lower() == 'websocket':
if path != f'/devtools/page/{TARGET_ID}':
self.send_error(404)
return
self.websocket()
return
if path in ('/json', '/json/list'):
body = json.dumps(targets(), indent=2).encode()
elif path == '/json/version':
body = json.dumps({'Browser': 'Chrome/126.0.6478.183', 'Protocol-Version': '1.3',
'User-Agent': 'Valve Steam Client (fakeframe)'}).encode()
else:
self.send_error(404)
return
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=UTF-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def websocket(self):
key = self.headers.get('Sec-WebSocket-Key', '')
accept = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest())
self.wfile.write(b'HTTP/1.1 101 WebSocket Protocol Handshake\r\nUpgrade: WebSocket\r\n'
b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + b'\r\n\r\n')
self.wfile.flush()
self.close_connection = True
try:
while True:
op, data = self.read_frame()
if op == 8:
return
if op == 9:
self.send_frame(data, 10)
continue
if op != 1:
continue
msg = json.loads(data)
reply = {'id': msg.get('id')}
if msg.get('method') == 'Runtime.evaluate':
params = msg.get('params') or {}
reply['result'] = JS_WORKER.evaluate(str(params.get('expression', '')),
bool(params.get('awaitPromise')))
else:
reply['error'] = {'code': -32601, 'message': f"'{msg.get('method')}' wasn't found"}
self.send_frame(json.dumps(reply).encode(), 1)
except (EOFError, OSError, ValueError):
return
def read_exact(self, n):
data = self.rfile.read(n)
if len(data) < n:
raise EOFError
return data
def read_frame(self):
b0, b1 = self.read_exact(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack('>H', self.read_exact(2))[0]
elif n == 127:
n = struct.unpack('>Q', self.read_exact(8))[0]
mask = self.read_exact(4) if b1 & 0x80 else None
data = self.read_exact(n)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return b0 & 0x0F, data
def send_frame(self, data, op):
n = len(data)
head = bytes([0x80 | op]) + (bytes([n]) if n < 126 else
bytes([126]) + struct.pack('>H', n) if n < 1 << 16 else
bytes([127]) + struct.pack('>Q', n))
self.wfile.write(head + data)
self.wfile.flush()
def main():
os.makedirs(STEAM_DIR, exist_ok=True)
if not os.path.exists(PIPE):
os.mkfifo(PIPE, 0o600)
with open(TOKEN_FILE, 'w') as f:
f.write(TOKEN)
with open(PID_FILE, 'w') as f:
f.write(str(os.getpid()))
def stop(*_):
# Guess: whether Steam removes steam.pid on exit. Either way
# validate_steam_client then says Steam isn't running.
try:
os.unlink(PID_FILE)
except OSError:
pass
if JS_WORKER.proc:
JS_WORKER.proc.kill()
os._exit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
httpd = ThreadingHTTPServer(('127.0.0.1', DEVTOOLS_PORT), DevTools)
httpd.daemon_threads = True
threading.Thread(target=httpd.serve_forever, daemon=True).start()
console('fakesteam: started (-cef-enable-debugging on 127.0.0.1:8080)')
read_pipe()
if __name__ == '__main__':
main()
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env python3
"""The fake Frame's supervisor, run as root under docker's init.
It starts and stops sshd, Valve's steamos-devkit-service (as steamos),
fakesteam (as steamos) and a few named placeholder processes the status page
looks for, following the switches in the state file. It also serves the
control port (9999) that fakeframe-ctl and the e2e tests use, so a test can
flip a fault switch even while SSH is down.
Control: GET /state, GET /calls, GET /ping, POST /ctl {"args": ["pairing", "on"]}.
See `fakeframe-ctl help` for the commands.
"""
import glob
import json
import os
import pwd
import shutil
import socket
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fakeframe_state as fs # noqa: E402
LIB = os.path.dirname(os.path.abspath(__file__))
USER = 'steamos'
PW = pwd.getpwnam(USER)
HOME = fs.HOME
KEYS = '/keys' # shared with the host container
HARNESS_KEY = KEYS + '/id_ed25519_frame'
AUTH_KEYS = HOME + '/.ssh/authorized_keys'
BALLAST = fs.DEVKIT_GAMES + '/.fakeframe-ballast'
# Written here; compose mounts the same volumes over /sys/class/power_supply and /sys/class/thermal.
POWER = '/var/lib/fakeframe/sys/power_supply'
THERMAL = '/var/lib/fakeframe/sys/thermal'
CONTROL_PORT = 9999
LOGS = '/var/log/fakeframe'
USAGE = """fakeframe-ctl COMMAND
pairing on|off Steam's "Pair new host" screen open or not
answer approve|deny|timeout how the pairing prompt is answered
steam on|off Steam client running (steam.pid, pipe, DevTools on 8080)
sleep on|off headset asleep: 22 and 32000 accept but never answer
sshd on|off sshd running (off: connection refused)
devkit-service on|off steamos-devkit-service on 32000
disk-full on|off fill the small ~/devkit-game filesystem
runtime NAME installed|missing NAME: proton-experimental, proton-stable,
SteamLinuxRuntime_4-arm64, SteamLinuxRuntime_4, lepton
battery KEY=VALUE... e.g. capacity=15 status=Discharging current_now=-900000
keys harness|none authorized_keys: only the harness key, or empty
authorized-keys what ~/.ssh/authorized_keys holds now
reset default state, nothing installed, harness key only
state | calls [TOOL] | ping"""
_lock = threading.RLock()
_procs = {}
_blackhole = {'socks': [], 'conns': []}
def log(msg):
print(time.strftime('%H:%M:%S ') + msg, flush=True)
def as_user():
env = {'HOME': HOME, 'USER': USER, 'LOGNAME': USER, 'SHELL': '/bin/bash',
'PATH': '/usr/local/bin:/usr/bin:/bin', 'XDG_RUNTIME_DIR': f'/run/user/{PW.pw_uid}',
'LANG': 'C.UTF-8'}
return {'user': PW.pw_uid, 'group': PW.pw_gid, 'extra_groups': [], 'env': env, 'cwd': HOME}
def chown(path):
os.chown(path, PW.pw_uid, PW.pw_gid)
# ---- processes ------------------------------------------------------------------
def spawn(name, argv, user=True, env=None):
os.makedirs(LOGS, exist_ok=True)
out = open(f'{LOGS}/{name}.log', 'ab')
kw = as_user() if user else {'env': dict(os.environ)}
kw['env'].update(env or {})
_procs[name] = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out, stderr=out,
start_new_session=True, **kw)
out.close()
log(f'started {name} (pid {_procs[name].pid})')
def stop(name, sig=15):
p = _procs.pop(name, None)
if p and p.poll() is None:
p.send_signal(sig)
try:
p.wait(5)
except subprocess.TimeoutExpired:
p.kill()
p.wait()
log(f'stopped {name}')
def running(name):
p = _procs.get(name)
return bool(p and p.poll() is None)
def kill_ssh_sessions():
"""Drop every SSH connection, as losing Wi-Fi does."""
for comm_file in glob.glob('/proc/[0-9]*/comm'):
try:
with open(comm_file) as f:
comm = f.read().strip()
if comm.startswith('sshd'):
os.kill(int(comm_file.split('/')[2]), 9)
except (OSError, ValueError):
pass
class Blackhole:
"""Accept on a port and never answer, so ssh waits and times out. An unreachable
Frame times out rather than refusing (seen over Tailscale on 2026-09-27);
a closed port would fail at once, which hides timeout bugs."""
@staticmethod
def start(port):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', port))
s.listen(64)
_blackhole['socks'].append(s)
def accept():
while True:
try:
c, _ = s.accept()
except OSError:
return
_blackhole['conns'].append(c)
threading.Thread(target=accept, daemon=True).start()
@staticmethod
def stop():
for s in _blackhole['socks'] + _blackhole['conns']:
try:
s.shutdown(socket.SHUT_RDWR)
except OSError:
pass
s.close()
_blackhole['socks'].clear()
_blackhole['conns'].clear()
def reconcile():
"""Make the running processes match the switches."""
with _lock:
sw = fs.read()['switches']
asleep = sw['asleep']
if asleep and not _blackhole['socks']:
stop('sshd')
stop('devkit-service')
kill_ssh_sessions()
Blackhole.start(22)
Blackhole.start(32000)
if not asleep and _blackhole['socks']:
Blackhole.stop()
want = {'sshd': sw['sshd'] and not asleep, 'devkit-service': sw['devkit_service'] and not asleep,
'steam': sw['steam'], 'vrserver': True, 'plasmashell': True}
for name, on in want.items():
if on and not running(name):
start(name)
elif not on and running(name):
stop(name)
def start(name):
if name == 'sshd':
spawn('sshd', ['/usr/bin/sshd', '-D', '-e'], user=False)
elif name == 'devkit-service':
# Valve's service, unmodified, with a stand-in dbus module (no systemd-resolved here).
spawn('devkit-service', ['python3', '/usr/lib/steamos-devkit/steamos-devkit-service.py'],
env={'PYTHONPATH': f'{LIB}/pystubs'})
elif name == 'steam':
spawn('steam', ['python3', f'{LIB}/fakesteam.py'])
else:
# frame_status.py looks for these by process name (vrserver: SteamVR,
# plasmashell: the headset desktop, which /api/clipboard also needs).
spawn(name, [f'{LIB}/bin/{name}', 'infinity'],
env={'DBUS_SESSION_BUS_ADDRESS': f'unix:path=/run/user/{PW.pw_uid}/bus'})
# ---- the device's files -----------------------------------------------------------
def write(path, text, owner=True):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
f.write(text)
if owner:
chown(path)
def sysfs(state):
"""Battery, charger and thermal zones, in the files frame_status.py reads.
/sys is read-only in a container, so compose mounts a volume over each of
the two folders and again here, where it can be written
(tests/fakeframe/compose.yaml); without those this does nothing.
"""
b = state['battery']
if not os.path.isdir(POWER) or not os.path.isdir(THERMAL):
log('no fake /sys: see the volumes in tests/fakeframe/compose.yaml')
return
try:
for d in glob.glob(POWER + '/*') + glob.glob(THERMAL + '/thermal_zone*'):
shutil.rmtree(d, ignore_errors=True)
bat = POWER + '/max1720x_battery' # the fuel gauge frame_status.py was written against
for k in ('capacity', 'status', 'voltage_now', 'current_now', 'time_to_full_now', 'temp', 'health'):
write(f'{bat}/{k}', f'{b[k]}\n', owner=False)
write(f'{bat}/type', 'Battery\n', owner=False)
c = b.get('charger') or {}
usb = POWER + '/usb'
write(f'{usb}/type', 'USB\n', owner=False)
write(f'{usb}/online', f"{c.get('online', 0)}\n", owner=False)
for k in ('usb_type', 'voltage_now', 'current_now'):
if k in c:
write(f'{usb}/{k}', f'{c[k]}\n', owner=False)
for i, t in enumerate(state['thermal_mc']):
write(f'{THERMAL}/thermal_zone{i}/temp', f'{t}\n', owner=False)
except OSError as e:
log(f'no fake /sys ({e}); see the volumes in tests/fakeframe/compose.yaml')
def runtimes(state):
"""Installed compat tools as Steam app manifests, and the Lepton launcher itself."""
apps = fs.STEAM_ROOT + '/steamapps'
for alias, installed in state['runtimes'].items():
acf = f'{apps}/appmanifest_{fs.RUNTIME_APPIDS[alias]}.acf'
if installed:
write(acf, '"AppState"\n{\n\t"appid"\t\t"%d"\n\t"name"\t\t"%s"\n\t"SizeOnDisk"\t\t"%d"\n}\n'
% (fs.RUNTIME_APPIDS[alias], fs.RUNTIME_NAMES[alias], 900000000))
elif os.path.exists(acf):
os.unlink(acf)
if state['runtimes'].get('lepton'):
os.makedirs(os.path.dirname(fs.LEPTON), exist_ok=True)
shutil.copy(f'{LIB}/lepton.py', fs.LEPTON)
os.chmod(fs.LEPTON, 0o755)
elif os.path.exists(fs.LEPTON):
os.unlink(fs.LEPTON)
for app in state['steam']['apps']:
acf = f"{apps}/appmanifest_{app['appid']}.acf"
if app['installed']:
write(acf, '"AppState"\n{\n\t"appid"\t\t"%d"\n\t"name"\t\t"%s"\n\t"SizeOnDisk"\t\t"%d"\n}\n'
% (app['appid'], app['display_name'], app['size']))
subprocess.run(['chown', '-R', f'{USER}:{USER}', fs.STEAM_ROOT])
def disk_full(on):
if on:
if os.path.ismount(fs.DEVKIT_GAMES) is False:
raise ValueError(f'disk-full needs {fs.DEVKIT_GAMES} to be its own small filesystem (compose tmpfs)')
st = os.statvfs(fs.DEVKIT_GAMES)
size = max(0, st.f_bavail * st.f_frsize - 64 * 1024)
fd = os.open(BALLAST, os.O_WRONLY | os.O_CREAT, 0o600)
try:
os.posix_fallocate(fd, 0, size)
finally:
os.close(fd)
elif os.path.exists(BALLAST):
os.unlink(BALLAST)
def set_keys(which):
os.makedirs(os.path.dirname(AUTH_KEYS), mode=0o700, exist_ok=True)
chown(os.path.dirname(AUTH_KEYS))
text = ''
if which == 'harness':
with open(HARNESS_KEY + '.pub') as f:
text = f.read()
write(AUTH_KEYS, text)
os.chmod(AUTH_KEYS, 0o600)
def harness_key():
"""The key the host container logs in with, shared through the /keys volume."""
os.makedirs(KEYS, exist_ok=True)
if not os.path.exists(HARNESS_KEY):
subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'fakeframe-harness',
'-f', HARNESS_KEY], check=True)
os.chmod(HARNESS_KEY, 0o644) # the host container copies it into its own ~/.ssh with 0600
def clean_home():
"""Everything Frame Control or a test put on the "headset"."""
for c in list(fs.read()['lepton'].values()):
try:
os.kill(c['pid'], 15)
except (OSError, KeyError, TypeError):
pass
for pattern in (fs.DEVKIT_GAMES + '/*', fs.DEVKIT_GAMES + '/.[!.]*', HOME + '/devkit-utils',
HOME + '/.devkit-utils.frame-control', HOME + '/Applications', HOME + '/Downloads/*',
fs.STEAM_ROOT + '/steamapps/compatdata', fs.STEAM_ROOT + '/steamapps/shadercache',
fs.STEAM_ROOT + '/logs', '/var/log/fakeframe/devkit-*', '/var/log/fakeframe/shortcut-*'):
for p in glob.glob(pattern):
subprocess.run(['rm', '-rf', p])
os.makedirs(HOME + '/Downloads', exist_ok=True)
chown(HOME + '/Downloads')
chown(fs.DEVKIT_GAMES)
def apply_files():
state = fs.read()
sysfs(state)
runtimes(state)
def reset():
with _lock:
stop('steam')
clean_home()
fs.reset()
env_switches()
set_keys('harness')
disk_full(False)
apply_files()
reconcile()
def env_switches():
"""FAKEFRAME_PAIRING_MODE=1 and the like set a switch's value at start."""
with fs.update() as s:
for k in s['switches']:
v = os.environ.get('FAKEFRAME_' + k.upper())
if v is not None:
s['switches'][k] = v if k == 'pairing_answer' else v in ('1', 'on', 'true', 'yes')
# ---- commands ----------------------------------------------------------------------
ON_OFF = {'on': True, 'off': False}
SWITCH = {'pairing': 'pairing_mode', 'steam': 'steam', 'sleep': 'asleep', 'sshd': 'sshd',
'devkit-service': 'devkit_service'}
def command(args):
if not args or args[0] == 'help':
return {'usage': USAGE}
cmd, rest = args[0], args[1:]
if cmd == 'state':
return fs.read()
if cmd == 'calls':
return fs.calls(rest[0] if rest else None)
if cmd == 'ping':
return ping()
if cmd == 'reset':
reset()
return {'ok': True}
if cmd in SWITCH and len(rest) == 1 and rest[0] in ON_OFF:
with fs.update() as s:
s['switches'][SWITCH[cmd]] = ON_OFF[rest[0]]
reconcile()
return {'ok': True, SWITCH[cmd]: ON_OFF[rest[0]]}
if cmd == 'answer' and rest and rest[0] in ('approve', 'deny', 'timeout'):
with fs.update() as s:
s['switches']['pairing_answer'] = rest[0]
return {'ok': True}
if cmd == 'disk-full' and len(rest) == 1 and rest[0] in ON_OFF:
with _lock:
disk_full(ON_OFF[rest[0]])
with fs.update() as s:
s['switches']['disk_full'] = ON_OFF[rest[0]]
return {'ok': True}
if cmd == 'runtime' and len(rest) == 2 and rest[1] in ('installed', 'missing'):
with fs.update() as s:
if rest[0] not in s['runtimes']:
raise ValueError(f'unknown runtime {rest[0]}')
s['runtimes'][rest[0]] = rest[1] == 'installed'
apply_files()
return {'ok': True}
if cmd == 'battery' and rest:
with fs.update() as s:
for kv in rest:
k, _, v = kv.partition('=')
if k not in s['battery'] or k == 'charger':
raise ValueError(f'unknown battery field {k}')
s['battery'][k] = int(v) if v.lstrip('-').isdigit() else v
apply_files()
return {'ok': True}
if cmd == 'keys' and rest and rest[0] in ('harness', 'none'):
set_keys(rest[0])
return {'ok': True}
if cmd == 'authorized-keys':
with open(AUTH_KEYS) as f:
return {'text': f.read()}
raise ValueError(f'unknown command {" ".join(args)!r}; try help')
def port_open(port):
try:
with socket.create_connection(('127.0.0.1', port), timeout=1):
return True
except OSError:
return False
def ping():
sw = fs.read()['switches']
checks = {}
if sw['sshd'] and not sw['asleep']:
checks['sshd'] = port_open(22)
if sw['devkit_service'] and not sw['asleep']:
checks['devkit_service'] = port_open(32000)
if sw['steam']:
checks['devtools'] = port_open(8080) and fs.steam_pid() is not None
return {'ok': all(checks.values()), 'checks': checks}
class Control(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def reply(self, obj, status=200):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path, _, query = self.path.partition('?')
args = {'/state': ['state'], '/calls': ['calls'] + ([query] if query else []), '/ping': ['ping']}.get(path)
if not args:
self.reply({'error': 'not found'}, 404)
return
self.reply(command(args))
def do_POST(self):
if self.path != '/ctl':
self.reply({'error': 'not found'}, 404)
return
try:
body = json.loads(self.rfile.read(int(self.headers.get('Content-Length') or 0)) or b'{}')
self.reply(command([str(a) for a in body.get('args', [])]))
except (ValueError, OSError) as e:
self.reply({'error': str(e)}, 400)
def main():
os.umask(0o022)
harness_key()
os.makedirs(fs.DIR, mode=0o777, exist_ok=True)
os.chmod(fs.DIR, 0o777)
os.makedirs(f'/run/user/{PW.pw_uid}', exist_ok=True)
chown(f'/run/user/{PW.pw_uid}')
reset()
httpd = ThreadingHTTPServer(('0.0.0.0', CONTROL_PORT), Control)
httpd.daemon_threads = True
threading.Thread(target=httpd.serve_forever, daemon=True).start()
log(f'fake Frame up; control on :{CONTROL_PORT}')
while True: # restart anything that died unasked, as systemd would
time.sleep(1)
try:
reconcile()
except Exception as e: # keep supervising
log(f'reconcile: {type(e).__name__}: {e}')
if __name__ == '__main__':
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Stand-in for Lepton's launcher (~/.local/share/Steam/steamapps/common/Lepton/lepton).
frame/android/lepton-app.sh runs it as `lepton waitforexitandrun -- APK` with
the Steam compat variables set. Like the real one (docs/apks.md, verified
2026-09-25, BUILD_ID 20260922.6101926) it:
- dies with "unbound variable" when STEAM_COMPAT_SHADER_PATH isn't set;
- needs STEAM_COMPAT_DATA_PATH under ~/.local/share/Steam (only that tree is
mounted in the container; elsewhere the app crashes with ENOENT);
- runs a "steamlaunch" container named lepton-steamlaunch-<SteamAppId> when
SteamAppId is set, else Lepton Development (lepton-dev);
- opens ADB on 0.0.0.0: 5555 for Lepton Development, the next free port for
each own instance (README security notes, 2026-09-25);
- shows a flat window only when lepton-show-flatscreen sits next to the APK.
There's no Android inside: it records the launch and holds the port until
`podman stop` (the podman stub) ends it.
"""
import json
import os
import signal
import socket
import sys
import time
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
ENV = ('SteamAppId', 'STEAM_COMPAT_INSTALL_PATH', 'STEAM_COMPAT_DATA_PATH', 'STEAM_COMPAT_SHADER_PATH',
'STEAM_FOSSILIZE_DUMP_PATH', 'IS_PARENT')
def main():
args = sys.argv[1:]
env = {k: os.environ.get(k) for k in ENV}
fs.log('lepton', args=args, env=env)
for k in ('STEAM_COMPAT_SHADER_PATH', 'STEAM_COMPAT_DATA_PATH', 'STEAM_COMPAT_INSTALL_PATH'):
if not env[k]:
sys.exit(f'{sys.argv[0]}: line 1: {k}: unbound variable')
if not os.path.realpath(env['STEAM_COMPAT_DATA_PATH']).startswith(fs.STEAM_ROOT + '/'):
sys.exit('ENOENT: STEAM_COMPAT_DATA_PATH is outside ~/.local/share/Steam, which is all the container sees')
apk = args[-1] if args else ''
if not apk.endswith('.apk') or not os.path.isfile(apk):
sys.exit(f'lepton: no APK at {apk!r}')
steamlaunch = bool(env['SteamAppId'])
name = f"lepton-steamlaunch-{env['SteamAppId']}" if steamlaunch else 'lepton-dev'
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
with fs.update() as state:
if name in state['lepton']:
sys.exit(f'lepton: {name} is already running')
used = {c['port'] for c in state['lepton'].values()}
for port in ([5555] if not steamlaunch else range(5556, 5600)):
if port in used:
continue
try:
sock.bind(('0.0.0.0', port))
break
except OSError:
continue
else:
sys.exit('lepton: no free ADB port')
sock.listen(8)
os.makedirs(os.path.join(env['STEAM_COMPAT_DATA_PATH'], 'internal'), exist_ok=True)
state['lepton'][name] = {'port': port, 'pid': os.getpid(), 'apk': apk, 'started': time.time(),
'flatscreen': os.path.exists(os.path.join(os.path.dirname(apk),
'lepton-show-flatscreen'))}
def stop(*_):
with fs.update() as state:
state['lepton'].pop(name, None)
fs.log('lepton', stopped=name)
os._exit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print(json.dumps({'container': name, 'adb_port': port}), flush=True)
while True:
conn, _ = sock.accept() # nothing speaks ADB here; just close
conn.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,50 @@
"""Stand-in for dbus-python, just enough for Valve's steamos-devkit-service.
The service advertises _steamos-devkit._tcp through systemd-resolved's
RegisterService over the system bus (on the Frame, `frame` answered mDNS,
docs/ssh.md, 2026-09-26). A container has no system bus or resolved, so
this records the call in the fake Frame's log instead.
"""
import sys
from . import exceptions # noqa: F401
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
class Array(list):
def __init__(self, items=(), signature=None):
super().__init__(items)
class Dictionary(dict):
def __init__(self, items=(), signature=None):
super().__init__(items)
def UInt16(v):
return int(v)
def _plain(v):
if isinstance(v, dict):
return {k: _plain(x) for k, x in v.items()}
if isinstance(v, list):
if all(isinstance(x, int) for x in v):
return bytes(v).decode('utf-8', 'replace')
return [_plain(x) for x in v]
return v
class _Object:
def get_dbus_method(self, name, interface=None):
def call(*args):
fs.log('resolve1', method=name, args=_plain(list(args)))
return f'/org/freedesktop/resolve1/dnssd/{args[0]}' if name == 'RegisterService' else None
return call
class SystemBus:
def get_object(self, bus_name, path):
return _Object()
@@ -0,0 +1,3 @@
class DBusException(Exception):
def get_dbus_name(self):
return None
@@ -0,0 +1,504 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
SteamOS Devkit Service
Copyright (C) 2022 Valve Software inc.
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random
Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!
@@ -0,0 +1,21 @@
# Valve's steamos-devkit-service (vendored, for the fake Frame only)
Unmodified copy of [steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service):
the HTTP service on port 32000 (`src/`) and its hooks (`hooks/`), which the
fake Frame image installs at `/usr/lib/steamos-devkit/` and
`/usr/share/steamos-devkit/hooks/`, where the service looks for them.
- Source: commit `74cf8fe` (2025-09-16, tag `v0.20250916.0`).
- Licence: the repository's `LICENSE` is the LGPL 2.1 (copied here).
`src/steamos-devkit-service.py` itself carries an MIT header, and
`hooks/devkit-1-identify` and `hooks/install-ssh-key` carry LGPL-2.1+
headers. Nothing here ships in Frame Control; it's only built into the
test image.
The service imports `dbus` to advertise itself over mDNS through
systemd-resolved, which a container doesn't have. The image puts a small
stand-in `dbus` module on its `PYTHONPATH` (`rootfs/usr/local/lib/fakeframe/pystubs`)
that records the registration instead. Everything else runs as Valve wrote it.
To update: copy `src/`, `hooks/` and `LICENSE` from a newer checkout and update
the commit line above.
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import sys
import os
import logging
import tempfile
from urllib.parse import quote_plus as urllib_quote_plus
import subprocess
import json
ENABLE_SSHD = '/usr/bin/steamos-polkit-helpers/steamos-enable-sshd'
logger = logging.getLogger(os.path.realpath(__file__))
import devkit_utils
if __name__ == '__main__':
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
ch.setFormatter(logging.Formatter('%(name)s:%(message)s'))
logger.addHandler(ch)
request = open(sys.argv[1], 'rt').read()
requestIP = 'Unknown'
try:
requestIP = sys.argv[2]
except NameError:
logger.warning('request IP not given as argument to hook')
pass
key_identifier = request.split(' ')[2]
request = f'Development host at IP {requestIP} key: {key_identifier!r}'
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
msg = 'Steam is not running'
logger.warning(msg)
ret['error'] = msg
else:
with tempfile.TemporaryDirectory(prefix='approve-ssh-key') as tempdir:
logger.info('Sending pairing request to Steam')
response = os.path.join(tempdir, 'response')
cmd = 'approve-ssh-key?response={}&request={}'.format(
urllib_quote_plus(response),
urllib_quote_plus(request),
)
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response, timeout=30) as f:
logger.debug('Got response from Steam client')
# Make sure sshd is enabled to support development features
if os.path.exists(ENABLE_SSHD):
subprocess.check_call(ENABLE_SSHD)
else:
subprocess.check_call('sudo systemctl enable --now sshd', shell=True)
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam did not respond to the pairing request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# json dictionary response gets written to stdout
# NOTE: the devkit service unfortunately smashes stdout/stderr all together in a text response, but we can still work with that
sys.stdout.flush()
sys.stderr.flush()
print(json.dumps(ret))
retcode = 1 if 'error' in ret else 0
sys.exit(retcode)
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
# encoding: utf-8
# This file is part of steamos-devkit
# SPDX-License-Identifier: LGPL-2.1+
#
# Copyright 2017-2018 Collabora Ltd
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this package. If not, see
# <http://www.gnu.org/licenses/>.
# Sample script run to identify the machine we are running on. This
# is used by the devkit daemon to choose an identity for the machine,
# and can also be run directly.
#
# A script or binary (written in any language) named devkit-1-identify
# can be placed in the same directory as this sample, and it will be
# used preferentially.
#
# Execution environment:
# - Runs as root, or as an ordinary user who can run games
# - Part of a non-interactive secure shell session
# Input:
# - None
# Output:
# - Exit 0 on success or nonzero on error
# - On success, must print JSON to stdout containing one object with
# the following keys and string values, all of which are optional:
# - "machine_id": The hexadecimal systemd/D-Bus machine-id(5)
# (in the case of a conflict between systemd's /etc/machine-id and
# D-Bus' traditional /var/lib/dbus/machine-id, the systemd version
# should be preferred)
# - "hostname": The machine-readable hostname as set by sethostname(2)
# - "pretty_hostname": A human-friendly version of the hostname as
# found in systemd's machine-info(5)
# - "product_family", "product_name", "product_serial", "product_uuid",
# "sys_vendor": as for /sys/devices/virtual/dmi/id
# - "product": The best human-friendly description of the machine
# hardware we can devise, for example "Alienware ASM100"
# - "serial": The best hardware serial number we can devise
# - "machine_name": The best unique name for the machine that we can
# devise
# - Any diagnostic messages must be printed to stderr only
import json
import os
import re
import socket
import subprocess
import sys
_MACHINE_ID_RE = re.compile(r'^[0-9A-Fa-f]{32,32}$')
_USELESS_HOSTNAMES = frozenset((
'debian',
'host',
'localhost',
'steamos',
'ubuntu',
))
# The case combination sometimes varies, so this is lowercased and we
# use lower() to compare.
_USELESS_DMI_NAMES = frozenset((
'to be filled by o.e.m.',
))
if __name__ == '__main__':
info = {}
steam_serialnumber = None
for k in ('product_family', 'product_name', 'product_serial',
'product_uuid', 'sys_vendor'):
try:
with open('/sys/devices/virtual/dmi/id/{}'.format(k)) as reader:
v = reader.read().strip()
if v and v.lower() not in _USELESS_DMI_NAMES:
info[k] = v
except (IOError, OSError, UnicodeError):
pass
try:
for f in ('/etc/machine-id', '/var/lib/dbus/machine-id'):
with open(f) as reader:
v = reader.read().strip()
if _MACHINE_ID_RE.match(v):
info['machine_id'] = v
except (IOError, OSError, UnicodeError):
pass
if 'product_family' in info and 'sys_vendor' in info:
info['product'] = '{sys_vendor} {product_family}'.format(**info)
elif 'product_name' in info and 'sys_vendor' in info:
info['product'] = '{sys_vendor} {product_name}'.format(**info)
elif 'product_family' in info:
info['product'] = info['product_family']
elif 'product_name' in info:
info['product'] = info['product_name']
if os.access('/usr/bin/hostnamectl', os.X_OK):
try:
v = subprocess.check_output([
'hostnamectl', '--pretty',
]).decode('utf-8').strip()
except (subprocess.CalledProcessError, UnicodeError):
pass
else:
if v:
info['pretty_hostname'] = v
v = subprocess.check_output([
'hostnamectl', '--static',
]).decode('utf-8').strip()
if v:
info['hostname'] = v
if 'hostname' not in info:
try:
info['hostname'] = socket.gethostname()
except (IOError, OSError, UnicodeError):
pass
if steam_serialnumber:
info['serial'] = steam_serialnumber
elif 'product_serial' in info:
info['serial'] = info['product_serial']
if 'product' in info:
info['machine_name'] = info['product']
if 'hostname' in info and info['hostname'] not in _USELESS_HOSTNAMES:
info['machine_name'] = info['hostname']
if 'pretty_hostname' in info:
info['machine_name'] = info['pretty_hostname']
json.dump(info, sys.stdout, indent=4, sort_keys=True)
print()
@@ -0,0 +1,265 @@
#!/usr/bin/env python
# encoding: utf-8
"""Utility functions for the Steam client hook scripts"""
import sys
import os
import traceback
import tempfile
import json
import logging
import fcntl
import errno
import contextlib
import time
import fcntl
import logging as logging_module
logger = logging_module.getLogger(__name__)
@contextlib.contextmanager
def wrap_outputs(stderr_prefix):
# capture stderr to file to support debugging
stderr_fd = sys.stderr.fileno()
tf = tempfile.NamedTemporaryFile(
mode='w+',
prefix=stderr_prefix,
delete=True)
if sys.version_info >= (3, 4):
# this API in the os module is only available for python3
# but it does not seem to work with subprocess anyway
os.set_inheritable(tf.file.fileno(), True)
assert os.get_inheritable(tf.file.fileno())
sys.stderr = tf.file
# we can only write out a json response to stdout,
# so redirect stdout to stderr,
# and keep a handle on the original stdout for the response
stdout_fd = os.dup(sys.stdout.fileno())
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
ctx = {}
try:
yield ctx
except:
logger.error(traceback.format_exc())
finally:
tf.flush()
tf.seek(0)
os.write(stderr_fd, tf.read().encode('utf-8'))
if 'ret' in ctx:
os.write(stdout_fd, json.dumps(ctx['ret']).encode('utf-8'))
class SteamClientNotRunningException(Exception):
def __init__(self, error_message):
self.error_message = error_message
def __str__(self):
return self.error_message
def validate_steam_client():
"""Verify that the steam client is running, and permissions are adequate"""
pid_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pid')))
if not os.path.exists(pid_path):
raise SteamClientNotRunningException('{0} does not exist'.format(pid_path))
try:
pid = int(open(pid_path, 'rt').read())
except Exception:
raise SteamClientNotRunningException('{0} is invalid'.format(pid_path))
try:
os.kill(pid, 0)
except OSError:
raise SteamClientNotRunningException('{0} does not refer to a valid process'.format(pid_path))
logger.info('Found steam client pid %s', pid)
def execute_steam_client_command(cmd):
"""Send a command to the steam client over the IPC pipe"""
pipe_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pipe')))
try:
pipe = open(pipe_path, 'wb+', 0)
except IOError:
raise Exception('cannot open steam client pipe')
session_token = open(os.path.expanduser('~/.steam/steam.token')).read()
pipe_cmd = 'devkit-1 steam://devkit-1/{0}/{1}'.format(
session_token,
cmd
)
logger.debug('Sending command line:')
logger.debug(pipe_cmd)
pipe.write('{0}\n'.format(pipe_cmd).encode('utf-8'))
pipe.close()
def save_argv(gameid, argv):
"""Save command line and arguments if provided"""
if argv is None:
return
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "w") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
json.dump(argv, argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for writing: {0}".format(argvfile))
def obtain_argv(gameid, argv):
"""Obtain command line with arguments"""
# If present and not None or [], just return the local arguments
if argv:
return argv
# From here, expect arguments to have been saved previously
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "r") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
argv = json.load(argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for reading: {0}".format(argvfile))
return argv
def save_settings(gameid, data):
"""Save settings"""
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-settings.json")
settings = dict()
if data.get('clear_settings', False):
settings = {}
else:
try:
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
except IOError as e:
if (e.errno != errno.ENOENT):
raise
# Merge settings from new json
if 'settings' in data:
settings.update(data['settings'])
try:
with open(settingsfile, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
json.dump(settings, f)
fcntl.flock(f, fcntl.LOCK_UN)
except (IOError):
raise Exception(
"Unable to open settings file for writing: {0}".format(
settingsfile
))
return settings
def load_settings(gameid):
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game", gameid + '-settings.json')
if not os.path.isfile(settingsfile):
return None
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
return settings
class SteamResponse_Timeout(Exception):
pass
class SteamResponse_Error(Exception):
def __init__(self, error_response):
self.error_response = error_response
def __str__(self):
return self.error_response
@contextlib.contextmanager
def wait_on_file_response(path, timeout=5):
"""
The pipe to the Steam Client is one way.
Responses from the Steam Client are written to filesystem.
Protocol is as follows:
- Steam Client creates a 'path.lock' file
- Steam Client writes either 'path' or 'path.error' to indicate a problem
- Steam Client deletes 'path.lock'
- Caller (us) can then read the response
NOTE 1: this function is used as a context manager and will block until a response comes in or timeout.
NOTE 2: the files are created by Steam when responding to a command. If the files already exist the response protocol will break.
"""
lock_path = '{0}.lock'.format(path)
error_path = '{0}.error'.format(path)
max_count = timeout
while True:
time.sleep(1)
if os.path.exists(error_path) or os.path.exists(path) and not os.path.exists(lock_path):
if os.path.exists(error_path):
with open(error_path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
error_response = f.read()
fcntl.flock(f, fcntl.LOCK_UN)
raise SteamResponse_Error(error_response)
with open(path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
success_response = f.read()
yield success_response
fcntl.flock(f, fcntl.LOCK_UN)
return
max_count -= 1
if max_count > 0:
continue
raise SteamResponse_Timeout()
# Setting up as a context manager so we never miss the deletion
# Creating a temporary .lock file to guard the create operation
@contextlib.contextmanager
def create_pid(pid_path):
os.makedirs(os.path.dirname(pid_path), exist_ok=True)
lock_path = '{0}.lock'.format(pid_path)
try:
lock_file = os.open(lock_path, os.O_CREAT | os.O_EXCL)
except IOError as e:
logger.error('cannot create lock file %s for pid file %s', lock_path, pid_path)
logger.error('remove the lock file manually and run again if you are confident no other instance is active')
raise
pid_file = open(pid_path,'w')
pid_file.write(str(os.getpid()))
pid_file.flush()
os.close(lock_file)
os.unlink(lock_path)
try:
yield pid_file
finally:
pid_file.close()
# Assume that's atomic and all is well, no need for another .lock
os.unlink(pid_path)
@@ -0,0 +1,74 @@
#!/bin/sh
#
# This file is part of steamos-devkit
# SPDX-License-Identifier: LGPL-2.1+
#
# Copyright © 2017-2018 Collabora Ltd
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this package. If not, see
# <http://www.gnu.org/licenses/>.
# Sample script to install ssh keys that were approved by the
# approve-ssh-key script.
#
# A script or binary (written in any language) named install-ssh-key
# can be placed in the same directory as this sample, and it will be
# used preferentially.
#
# Execution environment:
# - Runs as root
# Input:
# - The public key is in a temporary file accessible via argv[1], in
# OpenSSH format
# - argv[2], ... are the users to which we should grant access
# Output:
# - Exit 0 to accept the key, or nonzero to reject or on error
#
# This sample implementation should be suitable for any machine that
# has the specified users. A production implementation would be similar
# or even identical.
set -e
public_key="$1"
shift
echo "Installing public key '$public_key' for users: $*"
for user in "$@"
do
if pwent="$(getent passwd "$user")"
then
home="$(echo "$pwent" | cut -d: -f6)"
group="$(id -gn "$user")"
install -d -m 0755 -o "$user" -g "$group" "$home/.ssh"
if ! [ -e "$home/.ssh/authorized_keys" ]
then
install -m 0600 -o "$user" -g "$group" "$public_key" "$home/.ssh/authorized_keys"
else
if ! ( grep -v '^#' "$home/.ssh/authorized_keys" | grep -qF "$(cut -d " " -f2 "$public_key")" )
then
if [ -n "$(tail -1c "$home/.ssh/authorized_keys")" ]
then
echo >> "$home/.ssh/authorized_keys"
fi
cat "$public_key" >> "$home/.ssh/authorized_keys"
fi
fi
fi
done
echo "Public key installed"
exit 0
@@ -0,0 +1,522 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# MIT License
#
# Copyright (c) 2022 Valve Software inc., Collabora Ltd
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from http.server import BaseHTTPRequestHandler
import configparser
import getpass
import json
import os
import platform
import socketserver
import subprocess
import tempfile
import urllib.parse
import argparse
import threading
import sys
import builtins
import signal
import dbus
# Print to stderr, which is unbuffered and easier to read in journalctl
STDOUT_PRINT = builtins.print
def stderr_print(*args, **kwargs):
if 'file' not in kwargs:
kwargs['file'] = sys.stderr
global STDOUT_PRINT
return STDOUT_PRINT(*args, **kwargs)
builtins.print = stderr_print
# Expect a SIGUSR1 signal to exit
# Could come at any time so we keep a global flag, but also support a callback
SHOULD_EXIT = False
EXIT_CALLBACK = None
def handle_sigusr1(signum, frame):
""" Handle SIGUSR1 signal
"""
global SHOULD_EXIT
global EXIT_CALLBACK
print("Received SIGUSR1, exiting.")
SHOULD_EXIT = True
if EXIT_CALLBACK:
EXIT_CALLBACK()
def set_exit_callback(callback):
""" Set a callback to be called when we receive a SIGUSR1 signal
"""
global SHOULD_EXIT
global EXIT_CALLBACK
EXIT_CALLBACK = callback
#print(f'Exit callback set to {EXIT_CALLBACK}')
if SHOULD_EXIT and EXIT_CALLBACK:
EXIT_CALLBACK()
signal.signal(signal.SIGUSR1, handle_sigusr1)
SERVICE_PORT = 32000
PACKAGE = "steamos-devkit-service"
DEVKIT_HOOKS_DIR = "/usr/share/steamos-devkit/hooks"
CURRENT_TXTVERS = '1'
ENTRY_POINT = "devkit-1"
# root until config is loaded and told otherwise, etc.
ENTRY_POINT_USER = "root"
DEVICE_USERS = []
PROPERTIES = {"txtvers": 1,
"login": ENTRY_POINT_USER,
"settings": "",
"devkit1": [
ENTRY_POINT
]}
# Forced mDNS republish currently disabled, was causing a bad interaction with org.freedesktop.resolve1
FORCE_PUBLISH_INTERVAL = 0
def write_file(data: bytes) -> str:
""" Write given bytes to a temporary file and return the filename
Return the empty string if unable to open temp file for some reason
"""
with tempfile.NamedTemporaryFile(mode='w', prefix='devkit-1', encoding='utf-8',
delete=False) as file:
file.write(data.decode())
return file.name
return ''
def write_key(post_body: bytes) -> str:
""" Write key to temp file and return filename if valid
Return the empty string if invalid
"""
length = len(post_body)
found_name = False
if length >= 64 * 1024:
print("Key length too long")
return ''
if not post_body.decode().startswith('ssh-rsa '):
print("Key doesn't start with ssh-rsa ")
return ''
# Get to the base64 bits
index = 8
while index < length and post_body[index] == ' ':
index = index + 1
# Make sure key is base64
body_decoded = post_body.decode()
while index < length:
if ((body_decoded[index] == '+') or (body_decoded[index] == '/') or
(body_decoded[index].isdigit()) or
(body_decoded[index].isalpha())):
index = index + 1
continue
if body_decoded[index] == '=':
index = index + 1
if (index < length) and (body_decoded[index] == ' '):
break
if (index < length) and (body_decoded[index] == '='):
index = index + 1
if (index < length) and (body_decoded[index] == ' '):
break
print("Found = but no space or = next, invalid key")
return ''
if body_decoded[index] == ' ':
break
print("Found invalid data, invalid key at "
f"index: {index} data: {body_decoded[index]}")
return ''
print(f"Key is valid base64, writing to temp file index: {index}")
while index < length:
if body_decoded[index] == ' ':
# it's a space, the rest is name or magic phrase, don't write to disk
if found_name:
print(f"Found name ending at index {index}")
length = index
else:
print(f"Found name ending index {index}")
found_name = True
if body_decoded[index] == '\0':
print("Found null terminator before expected")
return ''
if body_decoded[index] == '\n' and index != length - 1:
print("Found newline before expected")
return ''
index = index + 1
# write data to the file
data = body_decoded[:length]
filename = write_file(data.encode())
if filename:
print(f"Filename key written to: {filename}")
return filename
def find_hook(name: str) -> str:
""" Find a hook with the given name
Return the path to the hook if found. '' if not found
"""
test_path = f"{DEVKIT_HOOKS_DIR}/{name}"
if os.path.exists(test_path) and os.access(test_path, os.X_OK):
return test_path
print(f"Error:: Unable to find hook for {name}")
return ''
def get_machine_name() -> str:
""" Get the machine name and return it in a string
Use identify hook first, and if that fails just get the hostname.
"""
machine_name = ''
# Run devkit-1-identify hook to get hostname, otherwise use default platform.node()
identify_hook = find_hook("devkit-1-identify")
if identify_hook:
# Run hook and parse machine_name out
process = subprocess.Popen(identify_hook, shell=False, stdout=subprocess.PIPE)
output = ''
for line in process.stdout:
textline = line.decode(encoding='utf-8', errors="ignore")
output += textline
process.wait()
output_object = json.loads(output)
if 'machine_name' in output_object:
machine_name = output_object["machine_name"]
if not machine_name:
machine_name = platform.node()
return machine_name
class DevkitHandler(BaseHTTPRequestHandler):
""" Class to handle http requests on selected port for registration, getting properties.
"""
def _send_headers(self, code, content_type):
self.send_response(code)
self.send_header("Content-type", content_type)
self.end_headers()
def do_GET(self):
""" Handle GET requests
"""
print(f"GET request to path {self.path} from {self.client_address[0]}")
if self.path == "/login-name":
self._send_headers(200, "text/plain")
self.wfile.write(ENTRY_POINT_USER.encode())
return
if self.path == "/properties.json":
self._send_headers(200, "application/json")
self.wfile.write(json.dumps(PROPERTIES, indent=2).encode())
return
query = urllib.parse.parse_qs(self.path[2:])
print(f"query is {query}")
if len(query) > 0 and query["command"]:
command = query["command"][0]
if command == "ping":
self._send_headers(200, "text/plain")
self.wfile.write("pong\n".encode())
return
self._send_headers(404, "")
return
self._send_headers(404, "")
self.wfile.write("Unknown request\n".encode())
def do_POST(self):
""" Handle POST requests
"""
if self.path == "/register":
from_ip = self.client_address[0]
content_len = int(self.headers.get('Content-Length'))
post_body = self.rfile.read(content_len)
print(f"register request from {from_ip}")
filename = write_key(post_body)
if not filename:
self._send_headers(403, "text/plain")
self.wfile.write(json.dumps({'error':'Failed to write the ssh key'}).encode())
return
# Run approve script
approve_hook = find_hook("approve-ssh-key")
if not approve_hook:
self._send_headers(403, "text/plain")
self.wfile.write(json.dumps({'error':'Failed to find approve hook'}).encode())
os.unlink(filename)
return
# Run hook and parse output
approve_process = subprocess.Popen([approve_hook, filename, from_ip],
shell=False,
stdout=subprocess.PIPE)
approve_output = ''
for approve_line in approve_process.stdout:
approve_textline = approve_line.decode(encoding='utf-8', errors="ignore")
approve_output += approve_textline
approve_process.wait()
approve_object = json.loads(approve_output)
if "error" in approve_object:
self._send_headers(403, "text/plain")
self.wfile.write(approve_output.encode()) # is already a json {'error':} response
os.unlink(filename)
return
# Otherwise, assume it passed
install_hook = find_hook("install-ssh-key")
if not install_hook:
self._send_headers(403, "text-plain")
self.wfile.write(json.dumps({'error':'Failed to find install-ssh-key hook'}).encode())
os.unlink(filename)
return
command = [install_hook, filename]
# Append each user to command as separate arguments
for user in DEVICE_USERS:
command.append(user)
install_process = subprocess.Popen(command, shell=False, stdout=subprocess.PIPE)
install_output = ''
for install_line in install_process.stdout:
install_textline = install_line.decode(encoding='utf-8', errors="ignore")
install_output += install_textline
install_process.wait()
exit_code = install_process.returncode
if exit_code != 0:
self._send_headers(500, "text/plain")
self.wfile.write("install-ssh-key:\n".encode())
self.wfile.write(install_output.encode())
os.unlink(filename)
return
self._send_headers(200, "text/plain")
self.wfile.write("Registered\n".encode())
os.unlink(filename)
class DevkitService:
""" Class to run as service.
Parses configuration, creates handler, registers an entry in dynamic DNS, etc.
"""
def __init__(self):
global ENTRY_POINT_USER
global DEVICE_USERS
self.port = SERVICE_PORT
self.name = get_machine_name()
self.stype = "_steamos-devkit._tcp"
self.service_path = None
config = configparser.ConfigParser()
# Use str form to preserve case
config.optionxform = str
config.read(["/etc/steamos-devkit/steamos-devkit.conf",
"/usr/share/steamos-devkit/steamos-devkit.conf",
os.path.join(os.path.expanduser('~'), '.config', PACKAGE, PACKAGE + '.conf')])
self.settings = {}
if 'Settings' in config:
settings = config["Settings"]
self.settings = dict(settings)
if 'Port' in settings:
self.port = int(settings["Port"])
PROPERTIES["settings"] = json.dumps(self.settings)
# Parse users from configs
if os.geteuid() == 0:
# Running as root, maybe warn?
print("Running as root, Probably shouldn't be\n")
if 'Users' in config:
users = config["Users"]
if 'ShellUsers' in users:
DEVICE_USERS = users["ShellUsers"]
else:
if 'Users' in config:
users = config["Users"]
if 'ShellUsers' in users:
DEVICE_USERS = users["ShellUsers"]
else:
username = getpass.getuser()
print(f'Username: {username}')
DEVICE_USERS = []
DEVICE_USERS.append(username)
# If only one user, that's the entry point user
# Otherwise entry_point_user needs to be root to be able to switch between users
if len(DEVICE_USERS) == 1:
ENTRY_POINT_USER = DEVICE_USERS[0]
PROPERTIES["login"] = ENTRY_POINT_USER
# "an array of dictionaries mapping strings to byte arrays" .. biggest eyeroll
py_dict = {}
for key, value in [
('txtvers', CURRENT_TXTVERS),
('settings', json.dumps(self.settings)),
('login', ENTRY_POINT_USER),
('devkit1', ENTRY_POINT)
]:
py_dict[key] = dbus.Array([ord(c) for c in value], signature='y')
self.txt_records = dbus.Array( [dbus.Dictionary(py_dict, signature='say' )], signature='a{say}' )
self.dbus_bus = dbus.SystemBus()
self.resolve1_register = self.dbus_bus.get_object(
'org.freedesktop.resolve1',
'/org/freedesktop/resolve1'
).get_dbus_method(
'RegisterService',
'org.freedesktop.resolve1.Manager'
)
self.resolve1_unregister = self.dbus_bus.get_object(
'org.freedesktop.resolve1',
'/org/freedesktop/resolve1'
).get_dbus_method(
'UnregisterService',
'org.freedesktop.resolve1.Manager'
)
self.httpd = socketserver.TCPServer(("", self.port), DevkitHandler, bind_and_activate=False)
print(f"serving at port: {self.port}")
print(f"machine name: {self.name}")
self.httpd.allow_reuse_address = True
self.httpd.server_bind()
self.httpd.server_activate()
def publish(self, recursed=False, silent=False):
""" Publish ourselves on mdns as an available devkit device.
"""
# https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.resolve1.html
if not silent:
print(f'RegisterService {self.name} {self.stype} {self.port}')
self.unpublish(silent)
try:
self.service_path = self.resolve1_register(
# Passing '%H' should amount to the same thing
# (is expanded based on specifiers, see https://www.man7.org/linux/man-pages/man5/systemd.dnssd.5.html)
self.name,
# 'name_template' .. what is this?
# also referred to as 'service instance name' in the implementation
# can't be an empty string, gets expanded with the same specifier rules as name,
# gets random numbers appended to it for a new instance name in case of service collisions?
self.name,
self.stype,
dbus.UInt16(int(self.port)),
dbus.UInt16(10), # priority (see https://en.wikipedia.org/wiki/SRV_record)
dbus.UInt16(0), # weight
self.txt_records,
)
except dbus.exceptions.DBusException as e:
# services will persist, it's bad if we didn't properly unregister, but we can recover this
if not recursed and e.get_dbus_name() == 'org.freedesktop.resolve1.DnssdServiceExists':
print('Service is already registered! Trying to recover')
self.service_path = f'/org/freedesktop/resolve1/dnssd/{self.name}'
self.unpublish()
self.publish(True)
else:
raise e
def unpublish(self, silent=False):
""" Remove publishing of ourselves as devkit device since we are quitting.
"""
if self.service_path:
if not silent:
print(f'UnregisterService {self.service_path}')
self.resolve1_unregister(self.service_path)
self.service_path = None
def force_publish(self, exit_event):
while True:
exit_event.wait(timeout=FORCE_PUBLISH_INTERVAL)
if exit_event.is_set():
break
self.publish(False, True)
def on_signal_shutdown(self):
print('Shutting down the httpd server')
# This is blocking and needs to run in a thread, otherwise we'll deadlock
threading.Thread(target=self.httpd.shutdown, daemon=True).start()
def run_server(self):
""" Run server until keyboard interrupt or we are killed
"""
thread = None
exit_event = None
global FORCE_PUBLISH_INTERVAL
if 'ForcePublishInterval' in self.settings:
FORCE_PUBLISH_INTERVAL = int(self.settings['ForcePublishInterval'])
if FORCE_PUBLISH_INTERVAL != 0:
exit_event = threading.Event()
thread = threading.Thread(target=self.force_publish, args=[exit_event,], daemon=True).start()
set_exit_callback(self.on_signal_shutdown)
try:
self.httpd.serve_forever()
except KeyboardInterrupt:
pass
set_exit_callback(None)
if thread:
exit_event.set()
thread.join()
self.httpd.server_close()
print(f"done serving at port: {self.port}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('--hooks', required=False, action='store', help='hooks directory')
conf = parser.parse_args()
if conf.hooks is not None:
DEVKIT_HOOKS_DIR = conf.hooks
service = DevkitService()
service.publish()
service.run_server()
service.unpublish()
+13
View File
@@ -0,0 +1,13 @@
# A stand-in for the Frame's SSH surface, on Valve's Holo Core aarch64 base (the
# Arch Linux ARM64 port the Frame's SteamOS is built on).
FROM registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest
RUN pacman -Sy --noconfirm --needed openssh sudo python rsync procps-ng && pacman -Scc --noconfirm
# The Frame's user, with a Developer Mode style password and sudo, as on SteamOS.
RUN useradd -m -s /bin/bash steamos && echo 'steamos:frame-test-pw' | chpasswd \
&& echo 'steamos ALL=(ALL) ALL' > /etc/sudoers.d/steamos && chmod 440 /etc/sudoers.d/steamos
# SteamOS's sshd: keys and passwords, no keyboard-interactive.
RUN ssh-keygen -A && printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\n' > /etc/ssh/sshd_config.d/10-frame.conf
# No systemd here: a systemctl that records power requests instead of acting.
RUN printf '#!/bin/sh\necho "systemctl $*" >> /tmp/power-requests.log\n' > /usr/local/bin/systemctl && chmod +x /usr/local/bin/systemctl
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D", "-e"]
+41
View File
@@ -0,0 +1,41 @@
# Testing without the headset
## Valve's own Frame OS, from its recovery image
Valve publishes a Steam Frame recovery image at
https://steamdeck-images.steamos.cloud/recovery/ (`steamframe-oobe-repair-*.img.bz2`,
~4 GB). `frame-image.sh` extracts its `rootfs-A` partition (btrfs), mounts it
read-only with a throwaway writable layer, and starts the image's own sshd on
port 2223, so the iPhone app can pair with, and run its server on, the real
SteamOS for Frame userland (Python, sudo, sshd, PAM). It needs Linux with btrfs,
e.g. Colima's VM on a Mac:
```sh
colima start --arch aarch64 --vm-type vz
colima ssh -- sudo sh tests/frame-container/frame-image.sh ~/Downloads/steamframe-oobe-repair-<build>.img.bz2
# pair with 127.0.0.1:2223, user steamos, password frame-test-pw
```
The image's kernel is built for the Frame's Qualcomm chip, so this runs its
userland, not the whole OS: no SteamVR, Steam client, battery or Lepton.
## Holo Core stand-in
A lighter option: Valve and
Collabora's [Holo Core aarch64 preview](https://www.collabora.com/news-and-blog/news-and-events/building-an-arch-linux-aarch64-port-for-holo-core.html)
(the Arch Linux ARM64 base the Frame's SteamOS is built on) with the Frame's SSH
surface: a `steamos` user with a password and sudo, OpenSSH taking keys and
passwords, Python and rsync. `systemctl` only records what it's asked to do.
It exercises pairing with the password, the host-key pin, the server running on
the "Frame" (FRAME_LOCAL=1) and the power password check. It has no SteamVR,
Steam, battery, cameras or Lepton, so those panels are empty.
```sh
docker build --platform linux/arm64 -t frame-holo-test tests/frame-container
docker run -d --name frame-holo -p 127.0.0.1:2222:22 frame-holo-test
# password: frame-test-pw. In the iPhone app (Simulator), pair with 127.0.0.1:2222.
docker exec frame-holo cat /tmp/power-requests.log # what power actions asked for
```
On a Mac without Docker: `brew install colima docker && colima start --arch aarch64 --vm-type vz`.
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Run Valve's own Steam Frame OS, from its recovery image, as an SSH target for
# testing (see README.md). Run on a Linux host or VM with btrfs, as root:
# frame-image.sh steamframe-oobe-repair-<build>.img.bz2
# It extracts the rootfs-A partition, mounts it read-only, adds a throwaway
# writable layer, and starts the image's own sshd on port 2223 (user steamos,
# password frame-test-pw). systemctl only records what it's asked.
set -e
command -v bzcat >/dev/null && command -v python3 >/dev/null || {
command -v apt-get >/dev/null && apt-get install -y -qq bzip2 python3 >/dev/null; }
IMG=${1:?recovery .img.bz2}; RAW=${RAW:-$(dirname "$IMG")/frame-rootfs-A.img}
if [ ! -f "$RAW" ]; then
# Partition 3 (rootfs-A) from the image's GPT: start and size in 512-byte sectors.
set -- $(bzcat "$IMG" | head -c 1048576 | python3 -c '
import struct,sys; d=sys.stdin.buffer.read(); e=d[1024+2*128:1024+3*128]
a,b=struct.unpack("<QQ",e[32:48]); print(a, b-a+1)')
bzcat "$IMG" | tail -c +$(( $1 * 512 + 1 )) | head -c $(( $2 * 512 )) > "$RAW"
fi
R=/mnt/frame; O=/var/lib/frame-ovl; M=/srv/frame
mkdir -p $R $O/upper $O/work $M
mountpoint -q $R || mount -o ro -t btrfs "$(losetup -f --show -r "$RAW")" $R
mountpoint -q $M || mount -t overlay overlay -o lowerdir=$R,upperdir=$O/upper,workdir=$O/work $M
for d in proc sys dev dev/pts; do mountpoint -q $M/$d || mount --rbind /$d $M/$d; done
mountpoint -q $M/run || mount -t tmpfs tmpfs $M/run
mountpoint -q $M/tmp || mount -t tmpfs tmpfs $M/tmp
mkdir -p $M/run/sshd $M/home/steamos $M/usr/local/bin
chroot $M chown 1000:1000 /home/steamos
echo 'steamos:frame-test-pw' | chroot $M chpasswd
chroot $M ssh-keygen -A >/dev/null
# No systemd here: systemctl records power requests instead of acting.
printf '#!/bin/sh\necho "systemctl $*" >> /tmp/power-requests.log\n' > $M/usr/local/bin/systemctl
chmod +x $M/usr/local/bin/systemctl
pkill -f "[s]shd -p 2223" 2>/dev/null || true
chroot $M /usr/bin/sshd -p 2223 -E /tmp/sshd.log
echo up
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more