Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 45f720883a Docs: house style for announcing features and fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:22: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 48eedbc2eb Website: let the hero platform list wrap on phones so large text isn't clipped
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:31:39 +10:00
saphidandClaude Opus 5.5 2b89eeba1d Website: fix visual bugs found in a page-by-page review
- Gradient buttons showed a dark sliver on the right: the background shorthand reset
  background-origin, so the gradient repeated under the transparent border.
- Phone header: keep the brand on one line and drop the GitHub button under 480px.
- Hero pill: put the platform list on its own line on phones instead of orphaning one item.
- Privacy page: plain sections instead of open accordions whose x looked like a close button.
- Same header (GitHub button) and footer links on every page; legend spacing on the form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:26:43 +10:00
saphidandClaude Opus 5.5 75e92db2fa Website: point the donate buttons at ko-fi.com/alexsouthwell; add FUNDING.yml
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:18:12 +10:00
Alex Southwell 10f96656e3 Merge pull request #10 from saphid/website
Website, feedback form that opens issues, and pi's contributor gate
2026-09-28 16:07:23 +10:00
saphidandClaude Opus 5.5 a7663b3a5e Website feedback: double backslashes so escapes can't rebuild references; queue every approval
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:51:18 +10:00
saphidandClaude Opus 5.5 a6fff434a4 Website feedback: attribution first, escape <, wait out the fill timer; maintainers only in the approval queue
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:45:54 +10:00
saphidandClaude Opus 5.5 cd40a194ed Website feedback: escape & so entities can't rebuild mentions; queue only lgtm comments
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:39:54 +10:00
saphidandClaude Opus 5.5 0037b1ef4b Website feedback: fixes from review
- Rate limiting fails open on KV errors instead of dropping feedback
- Break owner/repo#1, GH-1 and github.com references in user text
- Time the form with the browser's monotonic clock, not wall-clock
- Serialize lgtm approvals and rebase before pushing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:33:42 +10:00
saphidandClaude Opus 5.5 7d328e20a5 Website with a feedback form that opens GitHub issues, and pi's contributor gate
- site/: landing page, /feedback/ and /privacy/ on Cloudflare Pages
  (frame-control.pages.dev). POST /api/feedback validates the form and opens
  a labelled issue with a fine-grained token; honeypot, minimum fill time and
  KV rate limits keep spam out. Ko-fi donate buttons appear once the page
  name is set in site/public/js/site.js.
- .github: the issue and PR gate from badlogic/pi-mono. New contributors'
  issues and PRs are auto-closed; a maintainer replying lgtmi/lgtm approves
  them via APPROVED_CONTRIBUTORS. Issue templates and CONTRIBUTING.md.
- CI runs the website tests; README points feedback at the form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:26:33 +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
saphidandClaude Opus 5.5 9110557e23 README: link the Frame Control trailer
A thumbnail under the screenshot opens the 66-second trailer, which is attached to the 'trailer' release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 22:07:44 +10: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
138 changed files with 11288 additions and 230 deletions

No files matched your search

+3
View File
@@ -27,6 +27,9 @@ desktop or panels.
| 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` |
| 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` | — |
Each script's usage is in its header comment. Read the header rather than
+9
View File
@@ -0,0 +1,9 @@
# GitHub handles approved to bypass contribution auto-close
# Format: <username> <capability>
# capability:
# issue future issues stay open
# pr future issues and PRs stay open
# Maintainers add people by replying `lgtmi` or `lgtm` on an issue
# (.github/workflows/approve-contributor.yml); editing this file by hand works too.
fbl100 pr
+1
View File
@@ -0,0 +1 @@
ko_fi: alexsouthwell
+51
View File
@@ -0,0 +1,51 @@
name: Bug report
description: Report something that's broken
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://frame-control.pages.dev/feedback/) skips that queue.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea
id: description
attributes:
label: What happened?
description: Be specific. Include error messages and the last lines of the server log (Frame → Show Server Log).
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug.
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: false
- type: input
id: version
attributes:
label: Frame Control version
description: e.g. v0.3.1
validations:
required: false
- type: input
id: os
attributes:
label: Computer and SteamOS build
description: e.g. Windows 11, SteamOS 20260922.6101926 (Steam Settings → System)
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Feedback form (no GitHub account needed, skips the queue)
url: https://frame-control.pages.dev/feedback/
about: Bugs, ideas and questions from the website become issues here without being auto-closed.
+36
View File
@@ -0,0 +1,36 @@
name: Idea or contribution proposal
description: Propose a change or feature (required for new contributors before opening a PR)
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea
id: what
attributes:
label: What do you want to change?
description: Be specific and concise.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why?
description: What problem does this solve?
validations:
required: true
- type: textarea
id: how
attributes:
label: How? (optional)
description: Brief technical approach, and whether you'd like to implement it yourself.
validations:
required: false
+238
View File
@@ -0,0 +1,238 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Approve Contributor
on:
issue_comment:
types: [created]
jobs:
approve:
# Only maintainers' comments that might approve someone join the queue, and
# they run one at a time so two lgtm replies can't race on APPROVED_CONTRIBUTORS.
# (The script below still checks for write access.)
if: >-
contains(github.event.comment.body, 'lgtm') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
concurrency:
group: approve-contributor
cancel-in-progress: false
queue: max
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
- name: Update contributor approval
id: update
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const issueAuthor = context.payload.issue.user.login;
const commenter = context.payload.comment.user.login;
const commentBody = (context.payload.comment.body || '').trim();
const approvalAtStartPattern = /^[\s.]*(?:@[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\s*,\s*|[.:]\s*|\s+))*(lgtmi|lgtm)(?=$|[\s]|[^\p{L}\p{N}_\s])/iu;
const approvalAtEndPattern = /(?:^|[\s.])(lgtmi|lgtm)\s*(?:[^\p{L}\p{N}_\s])?\s*$/iu;
const approvalMatch = commentBody.match(approvalAtStartPattern) ?? commentBody.match(approvalAtEndPattern);
if (!approvalMatch) {
console.log('Comment does not start or end with lgtm or lgtmi');
core.setOutput('status', 'skipped');
return;
}
const targetCapability = approvalMatch[1].toLowerCase() === 'lgtmi' ? 'issue' : 'pr';
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: commenter,
});
if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) {
console.log(`${commenter} does not have write access`);
core.setOutput('status', 'skipped');
return;
}
} catch {
console.log(`${commenter} does not have collaborator access`);
core.setOutput('status', 'skipped');
return;
}
function parseMentionedUsers(body) {
const users = [];
const seenUsers = new Set();
const mentionPattern = /(^|[^A-Za-z0-9_])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)(?![A-Za-z0-9-]|\/)/g;
for (const match of body.matchAll(mentionPattern)) {
const username = match[2];
const normalizedUser = username.toLowerCase();
if (seenUsers.has(normalizedUser)) {
continue;
}
seenUsers.add(normalizedUser);
users.push(username);
}
return users;
}
function parseApprovedUsers(content) {
const lines = content.split('\n');
const entries = [];
const users = new Map();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
entries.push({ type: 'other', line });
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const normalizedUser = username.toLowerCase();
const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability };
entries.push(entry);
users.set(normalizedUser, entry);
}
return { entries, users };
}
function stringifyApprovedUsers(entries) {
const normalizedEntries = [...entries];
while (normalizedEntries.length > 0) {
const lastEntry = normalizedEntries[normalizedEntries.length - 1];
if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') {
break;
}
normalizedEntries.pop();
}
return `${normalizedEntries
.map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line))
.join('\n')}\n`;
}
const content = fs.readFileSync(APPROVED_FILE, 'utf8');
const { entries, users } = parseApprovedUsers(content);
const mentionedUsers = parseMentionedUsers(commentBody);
const approvalTargets = mentionedUsers.length > 0 ? mentionedUsers : [issueAuthor];
const changedTargets = [];
const alreadyTargets = [];
for (const username of approvalTargets) {
const normalizedUser = username.toLowerCase();
const existingEntry = users.get(normalizedUser);
const existingCapability = existingEntry?.capability ?? null;
if (existingCapability === 'pr' || existingCapability === targetCapability) {
alreadyTargets.push(existingEntry?.username ?? username);
console.log(`${username} is already approved for ${existingCapability}`);
continue;
}
if (existingEntry) {
existingEntry.capability = targetCapability;
changedTargets.push(existingEntry.username);
} else {
const entry = { type: 'user', username, normalizedUser, capability: targetCapability };
entries.push(entry);
users.set(normalizedUser, entry);
changedTargets.push(username);
}
console.log(`Set ${username} capability to ${targetCapability}`);
}
core.setOutput('capability', targetCapability);
core.setOutput('changed_targets', JSON.stringify(changedTargets));
core.setOutput('already_targets', JSON.stringify(alreadyTargets));
if (changedTargets.length === 0) {
core.setOutput('status', 'already');
return;
}
fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
core.setOutput('status', 'changed');
- name: Commit and push
if: steps.update.outputs.status == 'changed'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/APPROVED_CONTRIBUTORS
git diff --staged --quiet || git commit -m "chore: approve contributors from issue #${{ github.event.issue.number }}"
# main may have moved since checkout; replay the approval on top of it.
git pull --rebase origin "${{ github.event.repository.default_branch }}"
git push
- name: Comment on issue
if: steps.update.outputs.status == 'changed' || steps.update.outputs.status == 'already'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
CAPABILITY: ${{ steps.update.outputs.capability }}
CHANGED_TARGETS: ${{ steps.update.outputs.changed_targets }}
ALREADY_TARGETS: ${{ steps.update.outputs.already_targets }}
with:
script: |
const capability = process.env.CAPABILITY;
const changedTargets = JSON.parse(process.env.CHANGED_TARGETS || '[]');
const alreadyTargets = JSON.parse(process.env.ALREADY_TARGETS || '[]');
const defaultBranch = context.payload.repository.default_branch;
const formatTargets = (targets) => targets.map((target) => `@${target}`).join(', ');
const bodyLines = [];
if (changedTargets.length > 0) {
if (capability === 'issue') {
bodyLines.push(`${formatTargets(changedTargets)} approved for issues. Future issues will not be auto-closed. PRs still require \`lgtm\` at the start of a maintainer reply (optionally after one or more \`@username\` mentions) or at the end.`);
} else {
bodyLines.push(`${formatTargets(changedTargets)} approved for issues and PRs. Future issues and PRs will not be auto-closed.`);
}
}
if (alreadyTargets.length > 0) {
const verb = alreadyTargets.length === 1 ? 'is' : 'are';
bodyLines.push(`${formatTargets(alreadyTargets)} ${verb} already approved.`);
}
bodyLines.push('', `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`);
const body = bodyLines.join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+29 -1
View File
@@ -20,6 +20,7 @@ jobs:
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
- name: Script syntax
run: |
sh -n ui/local-bin/ssh
for f in scripts/*.sh frame/*/*.sh; do
case "$(head -n 1 "$f")" in
*zsh*) zsh -n "$f" ;;
@@ -28,13 +29,15 @@ jobs:
done
- name: Python compiles
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.
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
- name: Server tests
run: python -m unittest discover -s tests -v
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
# The server runs on each desktop OS the app ships for, on the Python version
# the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one.
@@ -57,3 +60,28 @@ jobs:
python-version: ${{ matrix.python }}
- name: Server tests
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
+134
View File
@@ -0,0 +1,134 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Issue Gate
on:
issues:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Check issue author
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
const isBotAuthor = issueAuthor.endsWith('[bot]');
if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) {
console.log(`Skipping trusted bot: ${issueAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
const permission = await getPermission(issueAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(issueAuthor.toLowerCase());
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
console.log(`${issueAuthor} is approved for ${capability}`);
return;
}
const message = [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
'',
`Maintainers review auto-closed issues regularly and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'Just want to report a bug or share an idea? The [website feedback form](https://frame-control.pages.dev/feedback/) skips this queue.',
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message,
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['untriaged'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
+145
View File
@@ -0,0 +1,145 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Issue Triage Labels
on:
issues:
types: [reopened, labeled]
jobs:
update-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Update triage labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const UNTRIAGED_LABEL = 'untriaged';
const NO_ACTION_LABEL = 'no-action';
const LAST_READ_LABEL = 'last-read';
const TO_DISCUSS_LABEL = 'to-discuss';
const INPROGRESS_LABEL = 'inprogress';
function issueHasLabel(issue, labelName) {
return (issue.labels ?? []).some((label) => label.name === labelName);
}
async function removeLabelIfPresent(issueNumber, issue, labelName) {
if (!issueHasLabel(issue, labelName)) {
console.log(`Issue #${issueNumber} does not have ${labelName}`);
return;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: labelName,
});
console.log(`Removed ${labelName} from #${issueNumber}`);
} catch (error) {
if (error.status === 404) {
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
return;
}
throw error;
}
}
if (context.payload.action === 'reopened') {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
return;
}
if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
return;
}
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
console.log('Not a last-read label event');
return;
}
const currentIssueNumber = context.issue.number;
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: LAST_READ_LABEL,
per_page: 100,
});
const previousIssueNumbers = lastReadIssues
.filter((issue) => !issue.pull_request)
.map((issue) => issue.number)
.filter((issueNumber) => issueNumber !== currentIssueNumber);
if (previousIssueNumbers.length === 0) {
console.log('No previous last-read issue found');
return;
}
const previousIssueNumber = Math.max(...previousIssueNumbers);
if (currentIssueNumber <= previousIssueNumber) {
console.log(
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
);
return;
}
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: UNTRIAGED_LABEL,
per_page: 100,
});
const issuesToMark = untriagedIssues
.filter((issue) => !issue.pull_request)
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
.sort((a, b) => a.number - b.number);
if (issuesToMark.length === 0) {
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
return;
}
for (const issue of issuesToMark) {
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [NO_ACTION_LABEL],
});
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
console.log(`Closed #${issue.number} as not planned`);
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: UNTRIAGED_LABEL,
});
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
}
+131
View File
@@ -0,0 +1,131 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: PR Gate
on:
pull_request_target:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check if contributor is approved
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
const prAuthor = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
const isBotAuthor = prAuthor.endsWith('[bot]');
if (TRUSTED_BOT_AUTHORS.has(prAuthor)) {
console.log(`Skipping trusted bot: ${prAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
async function closePullRequest(message) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: message,
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed',
});
}
const permission = await getPermission(prAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${prAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(prAuthor.toLowerCase());
if (!isBotAuthor && capability === 'pr') {
console.log(`${prAuthor} is approved for PRs`);
return;
}
console.log(`${prAuthor} is not approved, closing PR`);
const message = [
'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first and ask a maintainer for approval.',
'',
`Maintainers review auto-closed issues regularly. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await closePullRequest(message);
@@ -0,0 +1,34 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Remove In Progress Label On Close
on:
issues:
types: [closed]
jobs:
remove-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove inprogress label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const labelName = 'inprogress';
const labels = context.payload.issue.labels ?? [];
const hasLabel = labels.some((label) => label.name === labelName);
if (!hasLabel) {
console.log(`Issue does not have ${labelName} label`);
return;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: labelName,
});
+1
View File
@@ -4,3 +4,4 @@ apk-catalog/data/cache/
apk-catalog/data/index-v2.json*
compat-db/.env.lakebed.server
compat-db/.lakebed/
tests/smoke/results/
+71
View File
@@ -0,0 +1,71 @@
# Contributing to Frame Control
This guide exists to save both sides time. The process is borrowed from
[pi](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md).
## Just want to report something?
Use the [feedback form](https://frame-control.pages.dev/feedback/). It needs no
GitHub account, and what you send becomes an issue here that stays open.
## The One Rule
**You must understand your code.** If you can't explain what your change does
and how it interacts with the rest of the app, your PR will be closed.
Using AI to write code is fine. Submitting AI-generated slop you don't
understand is not.
## Contribution gate
Issues and PRs opened on GitHub by new contributors are auto-closed by default.
A maintainer reviews auto-closed issues regularly and reopens worthwhile ones.
Issues that don't meet the quality bar below won't be reopened or get a reply.
Approval happens through maintainer replies on issues:
- `lgtmi`: your future issues won't be auto-closed
- `lgtm`: your future issues and PRs won't be auto-closed
The word must be at the start of the reply (optionally after one or more
`@username` mentions) or at the end. Only `lgtm` lets you open PRs. Approved
people are listed in [`.github/APPROVED_CONTRIBUTORS`](.github/APPROVED_CONTRIBUTORS).
## Quality bar for issues
Use one of the issue templates, and keep it short, concrete and worth reading.
- If it doesn't fit on one screen, it's too long.
- Write in your own voice. If you must use an LLM, say so in a clearly labelled
follow-up comment.
- State the bug or request clearly, and why it matters.
- For bugs, include your OS, your SteamOS build (Steam Settings → System), and
the server log (**Frame → Show Server Log** in the app).
- If you want to implement the change yourself, say so.
## Before opening a PR
Don't open a PR until a maintainer has approved you with `lgtm`. Open an
[idea or contribution proposal](https://github.com/saphid/frame-control/issues/new?template=idea.yml)
first.
Then check your change:
```sh
python3 -m unittest discover -s tests # server tests; no headset needed
node --test site/test/*.test.mjs # website feedback function
```
Say what you tested, and whether you tried it on a real Steam Frame.
## Blocking
If you ignore this document twice, or spam the tracker with agent-generated
issues, your GitHub account will be blocked from the repo.
## Why auto-close?
This is a hobby project with one maintainer. Auto-closing is a buffer against
burnout and tracker spam: issues get reviewed on the maintainer's schedule, and
the good ones are reopened. Short, concrete, reproducible reports and thoughtful
contributions are welcome.
+20 -4
View File
@@ -12,11 +12,16 @@ See what the headset sees, install games and Android apps, move files and text a
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
<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 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>
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
@@ -98,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** (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.
Google doesn't publish `adb` for arm64 Linux, so that build uses your
distribution's. If you already have `adb`, the app uses yours.
@@ -167,13 +175,17 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
## Feedback
This is a first public test, so reports are really useful, especially from
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
with:
Windows and Linux. The quickest way is the
[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account
needed, and it opens an issue here. Please include:
- what you tried and what happened
- your computer's OS and your SteamOS build (Steam Settings → System)
- the server log: **Frame → Show Server Log** in the app
Issues and PRs opened directly on GitHub by new contributors are auto-closed
until a maintainer approves them; see [CONTRIBUTING.md](CONTRIBUTING.md).
## Going further
This repo also holds the scripts behind the app and field notes on how the
@@ -190,6 +202,9 @@ 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 |
| [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 |
| [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 |
| [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 |
<details>
@@ -216,6 +231,7 @@ Frame's software fits together, all checked against a real headset and labelled
```sh
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
```
+1
View File
@@ -243,6 +243,7 @@ function fromUi(e) {
}
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
// 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
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can open Set Up Connection when the headset can't be reached.
// It 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.
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
+144
View File
@@ -0,0 +1,144 @@
# Announcing changes
How we tell people about Frame Control features and fixes as they merge. The
same few sentences feed the X post, the release notes and the website, so they
are written once, in the pull request, while the change is fresh.
## What gets announced
| Kind | Announce? | Example |
|---|---|---|
| **New** — something you can now do | Yes, its own post | Stream Mac windows into the Frame as panels |
| **Better** — something existing got noticeably easier, faster or wider | Yes, its own post or a roundup | APKs install without the Android SDK |
| **Fixed** — something broken that users hit | Yes if people reported it or it blocked a flow; otherwise the next roundup | Mac mirror showed a zoomed-in corner |
| **Release** — a tagged build | Always, one post linking the release | Frame Control 0.3.1 |
| Tests, refactors, CI, docs-only, website polish | No | Fake Frame tests, screenshot crop |
If a change isn't worth a sentence to someone who owns a Frame, it isn't
announced.
## The voice
Write it the way the README and release notes already read.
- **Lead with what the person can now do**, in their words: "Install older
versions of an app when the newest won't run on the Frame", not "Add APK
version fallback resolver".
- **Plain and specific.** Name the thing, give the number: "about 30 fps",
"4,500 apps", "up to 8 older versions". No "blazing", "game-changing",
"excited to announce", "huge", or exclamation marks.
- **Say where it works.** Platforms and what it was tested on, briefly:
"Tested on a real Frame from macOS 27." Don't claim what wasn't tested.
- **Say the catch.** If it needs a setup step, an unsigned build, or only works
on one OS, say so in the same post.
- **Sentence case**, full sentences, British spelling to match the docs.
Contractions are fine.
- **No emoji in the text.** One image, GIF or short clip carries the tone
instead. The only symbol is the kind label below.
- **Unofficial, always.** Never imply Valve made or endorses it. Say "Steam
Frame" for the headset and "Frame Control" for the app.
- **Credit people.** If a user reported the bug or suggested the feature and is
happy to be named, thank them by handle.
## The formats
Every announceable PR ends with an `## Announcement` section holding these.
The reviewer checks it like code.
### 1. The post (X, and any other social account)
```
<Kind>: <what you can do now, one sentence>
<one or two sentences: how it works, the catch, or what it was tested on>
<link>
```
- `<Kind>` is `New`, `Better` or `Fixed`.
- 280 characters maximum including the link (X counts any link as 23).
- One link: the release if it has shipped, otherwise the PR.
- One visual when the change is visible: a screenshot from the app, a GIF, or a
short clip from the headset. Alt text describes what it shows.
- No hashtags, except `#SteamFrame` on releases and on posts about something
new, because people search for it.
### 2. The release-note line
One bullet under **New in x.y.z**, same as the current release notes: the
first half of the post's first sentence, no kind label, no link.
### 3. Release post
```
Frame Control <version>: <the headline change>
<one sentence on the headline change>. Also: <two or three short items>.
Windows, macOS and Linux: <release link>
#SteamFrame
```
The release title on GitHub uses the same `Frame Control <version>: <headline>`
line, as 0.3.0 and 0.3.1 already do.
### Roundups
Small fixes that don't earn their own post wait for a roundup, posted with the
next release or when three or more have piled up:
```
Fixed in Frame Control this week:
- <fix>
- <fix>
- <fix>
<link>
```
## Examples from what has already merged
**#11, older APK versions**
```
New: when an Android app is too new for the Frame, Frame Control now offers
older versions that will install.
It checks F-Droid, its archive and IzzyOnDroid, and verifies each download
before it goes on the headset.
https://github.com/saphid/steam-frame/pull/11
```
**#8, Mac mirror fixes**
```
Fixed: mirroring your Mac into the Steam Frame now fits the whole desktop in
the panel, asks for the right password, and shows the cursor.
Tested end to end on a real Frame from macOS 27.
https://github.com/saphid/steam-frame/pull/8
```
**v0.3.1**
```
Frame Control 0.3.1: install APKs without the Android SDK
Frame Control now reads APK files itself, so there's nothing extra to install.
Also: Linux and Windows game sideloading, and one-click install links.
Windows, macOS and Linux: https://github.com/saphid/steam-frame/releases/tag/v0.3.1
#SteamFrame
```
## Posting
Nothing is posted without a person approving it. The flow is:
1. The PR carries its `## Announcement` section.
2. On merge, the post is drafted from that section (manually for now).
3. Alex approves or edits it, then it's posted from the project account.
4. Replies and questions that turn out to be bugs become GitHub issues labelled
`feedback`, same as the website form.
+9
View File
@@ -17,6 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## 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,
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**
+9 -1
View File
@@ -51,7 +51,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` |
| **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 |
| **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 |
## Debug recipes
@@ -79,4 +85,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
- Installing and buying Steam games: [steam-games.md](steam-games.md)
- Remote access from anywhere: [tailscale.md](tailscale.md)
- Floating windows in space: [panels.md](panels.md)
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
- What's still unverified: [open-questions.md](open-questions.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 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
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.
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina
connection itself hasn't been tried in the headset yet (part of 11).
The Frame can reach the Mac's Screen Sharing port (5900).
- **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
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).
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)
@@ -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'`.
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at
Retina resolutions? Is the pre-seeded profile path
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina
actually reads?
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
VNC is too slow.
11. ~~**Remmina → macOS Screen Sharing**~~: answered 2026-09-27; see above
and [streaming.md](streaming.md).
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** VNC works but is
noticeably laggy, so this is worth trying.
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
it pair with KDE Connect for macOS?
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).
Still open: reaching the Frame from outside the home network, and the service
starting after a reboot.
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
17. **Floating panels in the headset** (see [panels.md](panels.md)): panels
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
reopening the app, or a reboot?
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
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
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
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
reports, not tested with the Frame.
- `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/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/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/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**) |
+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:
- **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
candidates in the list.
- **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
and upload whatever it points at.
- 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
scripts pass it to a shell (`steamos-delete` runs `rm -r` on it). Valve's
- The title id is limited to letters, digits and `_`, doesn't start with a
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,
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.
- 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
+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
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
- **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
This covers two directions:
This covers three directions, plus input:
- **A. Frame → Mac**: see and control the headset from the Mac.
- **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).
@@ -32,7 +34,7 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
| 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. |
| 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. |
@@ -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
`~/.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
still be asked for the VNC password in the headset the first time, unless you
choose to save it. Remmina stores passwords encrypted with a per-install key,
so the script doesn't try to write the password. (The Remmina file format is
standard; the Flatpak data path is inferred.)
SSH. The profile then appears in Remmina's list, and you just click it. It
scales the Mac's desktop to fit the window (`scale=1`, `viewmode=1`). Without
that, Remmina shows a Retina Mac's native pixels 1:1, so you see a zoomed-in
corner. (Verified 2026-09-27.)
## 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
avoid the virtual keyboard. Road to VR says there are "only a few things
you'd actually want to do" on the Linux desktop unless you connect a
keyboard and mouse.
(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)).
```sh
printf '%s' "$PASSWORD" | flatpak run org.remmina.Remmina \
--update-profile ~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina \
--set-option password
```
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
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>
+150
View File
@@ -0,0 +1,150 @@
# 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.
+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" "$@"
+4
View File
@@ -59,9 +59,13 @@ colordepth=32
quality=9
viewonly=0
showcursor=1
scale=1
viewmode=1
window_maximize=1
EOF
echo \"wrote \$d/mac-screen-sharing.remmina\"
"
print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >"
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
+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()
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars
+35
View File
@@ -0,0 +1,35 @@
# Website
The Frame Control website, <https://frame-control.pages.dev>, on Cloudflare Pages.
- `public/`: static pages. `/` is the landing page, `/feedback/` the feedback form, `/privacy/` the privacy note.
- `functions/api/feedback.js`: `POST /api/feedback`, which turns the form into a GitHub issue labelled `feedback`.
- `lib/feedback.js`: validation and issue formatting, tested by `test/feedback.test.mjs`.
- `public/js/site.js`: settings, including the Ko-fi page name for the donate buttons.
## Feedback → GitHub issues
The function needs a `GITHUB_TOKEN` secret: a fine-grained token with **Issues: read and write** on
`saphid/frame-control` only. Issues are opened as the token's owner, so they pass the contributor gate
(`.github/workflows/issue-gate.yml`) and stay open. Without the token the form answers 503 and offers a
prefilled GitHub issue instead.
```sh
cd site
npx wrangler pages secret put GITHUB_TOKEN --project-name frame-control
```
Spam protection: a hidden honeypot field, a 3-second minimum fill time, 5 submissions per hour per IP
(a salted hash, kept in the `FEEDBACK_RL` KV namespace for about an hour), and 100 a day in total.
User text has `@mentions` and `#123` references broken so nobody gets pinged.
## Run and deploy
```sh
cd site
node --test test/*.test.mjs
npx wrangler pages dev --port 8788 # local; put GITHUB_TOKEN/GITHUB_REPO in .dev.vars to test issues
npx wrangler pages deploy --branch main # production
```
Point `GITHUB_REPO` in `.dev.vars` at a scratch repo when testing locally so test issues don't land on the real tracker.
+86
View File
@@ -0,0 +1,86 @@
// POST /api/feedback: turns the website's feedback form into a GitHub issue.
//
// Environment (Cloudflare Pages → Settings → Variables and Secrets):
// GITHUB_TOKEN secret. Fine-grained token with Issues: read and write on GITHUB_REPO only.
// GITHUB_REPO owner/name, e.g. saphid/frame-control (wrangler.toml sets it).
// FEEDBACK_RL KV namespace binding for rate limits (optional; without it there is no limit).
import { buildIssue, hashIp, validate } from "../../lib/feedback.js";
const PER_IP_PER_HOUR = 5;
const TOTAL_PER_DAY = 100;
const json = (status, data) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
});
async function overLimit(kv, key, limit, ttl) {
const count = Number(await kv.get(key)) || 0;
if (count >= limit) return true;
await kv.put(key, String(count + 1), { expirationTtl: ttl });
return false;
}
export async function onRequestPost({ request, env }) {
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
return json(503, { error: "Feedback isn't connected to GitHub yet. Use the GitHub link instead." });
}
const origin = request.headers.get("origin");
if (origin && new URL(origin).host !== new URL(request.url).host) {
return json(403, { error: "Send feedback from the website's form." });
}
let input;
try {
input = await request.json();
} catch {
return json(400, { error: "Send the form as JSON." });
}
const checked = validate(input);
// Bots get a success-shaped answer so they don't learn what tripped them.
if (checked.spam) return json(200, { ok: true });
if (checked.error) return json(400, { error: checked.error });
// Best effort: KV is eventually consistent, so bursts can slip past, and a
// storage error lets the feedback through rather than losing it.
if (env.FEEDBACK_RL) try {
const ip = request.headers.get("cf-connecting-ip") || "unknown";
const hour = Math.floor(Date.now() / 3600e3);
const day = Math.floor(Date.now() / 86400e3);
// Salted with the secret token, so the stored hashes can't be reversed by trying every IP.
const who = await hashIp(ip, env.GITHUB_TOKEN);
if (await overLimit(env.FEEDBACK_RL, `ip:${who}:${hour}`, PER_IP_PER_HOUR, 3900)) {
return json(429, { error: "That's a lot of feedback in one hour. Try again later, or use GitHub." });
}
if (await overLimit(env.FEEDBACK_RL, `day:${day}`, TOTAL_PER_DAY, 90000)) {
return json(429, { error: "The form has had a busy day. Try again tomorrow, or use GitHub." });
}
} catch (err) {
console.log(`Rate limit check failed: ${err}`);
}
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, {
method: "POST",
headers: {
authorization: `Bearer ${env.GITHUB_TOKEN}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"user-agent": "frame-control-website",
"content-type": "application/json",
},
body: JSON.stringify(buildIssue(checked.value)),
});
if (!res.ok) {
console.log(`GitHub answered ${res.status}: ${(await res.text()).slice(0, 500)}`);
return json(502, { error: "GitHub didn't accept it just now. Try again, or use the GitHub link." });
}
const issue = await res.json();
return json(201, { ok: true, number: issue.number, url: issue.html_url });
}
export const onRequest = () => json(405, { error: "POST only." });
+102
View File
@@ -0,0 +1,102 @@
// Feedback form → GitHub issue. Pure functions, so tests can run them without
// Cloudflare or GitHub (site/test/feedback.test.mjs).
export const KINDS = {
bug: { label: "bug", title: "Bug report" },
idea: { label: "enhancement", title: "Idea" },
question: { label: "question", title: "Question" },
other: { label: null, title: "Other feedback" },
};
export const LIMITS = { title: [5, 120], message: [10, 5000], field: 120 };
// Anyone who fills the form in under this many milliseconds is a script.
export const MIN_FILL_MS = 3000;
const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const oneLine = (value, max) => String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max);
// Mentions in someone else's text would ping strangers, and issue references
// (#1, owner/repo#1, GH-1, github.com links) would add backlinks to other
// people's issues, so break them all with a zero-width space. Escaping & first
// stops &commat; and &num; from turning back into @ and # when GitHub renders,
// escaping < keeps out raw HTML such as an unclosed <!-- comment, and doubling
// backslashes stops GH\-1 or github\.com from being unescaped back into references.
const ZWSP = "\u200b";
export function defang(text) {
return text
.replace(/\\/g, "\\\\")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/@(?=[A-Za-z0-9])/g, `@${ZWSP}`)
.replace(/#(?=\d)/g, `#${ZWSP}`)
.replace(/\b(GH)-(?=\d)/gi, `$1${ZWSP}-`)
.replace(/\b(github)\.com/gi, `$1${ZWSP}.com`);
}
// Returns { error } or { value } with every field trimmed and bounded.
export function validate(input) {
if (!input || typeof input !== "object") return { error: "Send the form as JSON." };
if (oneLine(input.website, 200)) return { spam: true };
// Measured in the browser with a monotonic clock, so clock skew doesn't matter.
const elapsed = Number(input.elapsed);
if (!Number.isFinite(elapsed)) return { spam: true };
// The page waits this long before sending, so only scripts get here; say so anyway.
if (elapsed < MIN_FILL_MS) return { error: "That was quick. Send it again in a moment." };
const kind = Object.hasOwn(KINDS, input.kind) ? input.kind : "other";
const title = oneLine(input.title, LIMITS.title[1]);
const message = String(input.message ?? "").replace(/\r\n?/g, "\n").trim();
if (title.length < LIMITS.title[0]) return { error: "Give it a short title (at least 5 characters)." };
if (message.length < LIMITS.message[0]) return { error: "Tell us a little more (at least 10 characters)." };
if (message.length > LIMITS.message[1]) return { error: `Keep it under ${LIMITS.message[1]} characters.` };
const github = oneLine(input.github, 40).replace(/^@/, "");
if (github && !GITHUB_LOGIN.test(github)) return { error: "That doesn't look like a GitHub username." };
return {
value: {
kind,
title,
message,
github,
version: oneLine(input.version, LIMITS.field),
os: oneLine(input.os, LIMITS.field),
steamos: oneLine(input.steamos, LIMITS.field),
},
};
}
export function buildIssue(value) {
const kind = KINDS[value.kind];
const details = [
["Frame Control version", value.version],
["Computer", value.os],
["SteamOS build", value.steamos],
].filter(([, v]) => v);
// Our own lines go first, so nothing in the sender's text can hide them.
const lines = [
value.github
? `> Sent from the website feedback form by @${value.github}.`
: "> Sent from the website feedback form. The sender left no GitHub username, so they won't see replies here.",
"",
];
if (details.length) {
lines.push("| | |", "|---|---|", ...details.map(([k, v]) => `| ${k} | ${defang(v).replace(/\|/g, "\\|")} |`), "");
}
lines.push(defang(value.message));
return {
title: `${kind.title}: ${value.title}`,
body: lines.join("\n"),
labels: ["feedback", ...(kind.label ? [kind.label] : [])],
};
}
export async function hashIp(ip, salt) {
const bytes = new TextEncoder().encode(`${salt}:${ip}`);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].slice(0, 12).map((b) => b.toString(16).padStart(2, "0")).join("");
}
+1
View File
@@ -0,0 +1 @@
{ "type": "module", "private": true }
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found · Frame Control</title>
<meta name="description" content="Page not found.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="/#features">Features</a>
<a href="/#setup">Setup</a>
<a href="/#download">Download</a>
<a href="/#faq">FAQ</a>
<a href="/feedback/">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main class="page">
<div class="wrap" style="text-align:center;padding:80px 24px">
<span class="kicker">404</span>
<h1>That page isn't here</h1>
<p style="color:var(--muted);margin-top:14px">It may have moved. Try the home page, or tell us what you were looking for.</p>
<div class="cta"><a class="btn primary" href="/">Home</a><a class="btn ghost" href="/feedback/">Send feedback</a></div>
</div>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; connect-src 'self' https://api.github.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
/css/*
Cache-Control: public, max-age=3600
/js/*
Cache-Control: public, max-age=3600
/img/*
Cache-Control: public, max-age=86400
/media/*
Cache-Control: public, max-age=604800
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+201
View File
@@ -0,0 +1,201 @@
/* Frame Control website. Colours follow the app (ui/index.html): Steam's navy and blue. */
:root {
--bg: #0e141b; --bg-2: #131c26; --panel: #17222e; --panel-2: #1d2a38; --line: rgba(143, 152, 160, .16);
--text: #c7d0d8; --bright: #fff; --muted: #8f98a0; --dim: #5e6873;
--blue: #1a9fff; --link: #66c0f4; --green: #75b022; --warn: #d9a23a; --bad: #e0573c;
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--action-hi: linear-gradient(to right, #5fcaff 5%, #2458d6 95%);
--radius: 14px; --wrap: 1160px;
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 80px; }
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.6 var(--font); -webkit-font-smoothing: antialiased; }
img, video { max-width: 100%; height: auto; display: block; }
a { color: var(--link); text-decoration: none; }
a:hover { color: var(--bright); }
::selection { background: var(--blue); color: #fff; }
:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; border-radius: 4px; }
h1, h2, h3 { color: var(--bright); line-height: 1.15; margin: 0; letter-spacing: -.02em; }
h1 { font-size: clamp(38px, 6vw, 64px); font-weight: 800; }
h2 { font-size: clamp(28px, 3.6vw, 40px); font-weight: 750; }
h3 { font-size: 18px; font-weight: 650; letter-spacing: -.01em; }
p { margin: 0; }
code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rgba(255,255,255,.06); padding: 2px 6px; border-radius: 5px; color: var(--bright); }
.wrap { max-width: var(--wrap); margin: 0 auto; padding: 0 24px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
[hidden] { display: none !important; }
/* ---- header ---- */
.top { position: sticky; top: 0; z-index: 20; background: rgba(14, 20, 27, .78); backdrop-filter: saturate(160%) blur(14px);
-webkit-backdrop-filter: saturate(160%) blur(14px); border-bottom: 1px solid var(--line); }
.top .wrap { display: flex; align-items: center; gap: 28px; height: 64px; }
.brand { display: flex; align-items: center; gap: 10px; color: var(--bright); font-weight: 700; letter-spacing: 2.2px; font-size: 14px; text-transform: uppercase; }
.brand { white-space: nowrap; }
.brand img { width: 30px; height: 30px; }
.top nav { display: flex; gap: 22px; margin-left: 8px; }
.top nav a { color: var(--muted); font-size: 14.5px; font-weight: 500; }
.top nav a:hover, .top nav a[aria-current] { color: var(--bright); }
.top .end { margin-left: auto; display: flex; gap: 10px; align-items: center; }
@media (max-width: 880px) { .top nav { display: none; } }
@media (max-width: 480px) { .top .end .ghost { display: none; } }
/* ---- buttons ---- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 9px; height: 46px; padding: 0 22px; border-radius: 10px;
font: 600 15.5px var(--font); color: var(--bright); background: rgba(103, 112, 123, .22); border: 1px solid transparent;
cursor: pointer; transition: background .15s, transform .15s, box-shadow .15s; white-space: nowrap; }
.btn:hover { background: rgba(103, 112, 123, .4); color: var(--bright); }
.btn.primary { background: var(--action); box-shadow: 0 8px 28px rgba(26, 159, 255, .28); }
.btn.primary:hover { background: var(--action-hi); transform: translateY(-1px); }
/* The background shorthand resets this; without it the gradient repeats under the transparent border. */
.btn.primary, .btn.primary:hover { background-origin: border-box; }
.btn.ghost { background: transparent; border-color: var(--line); }
.btn.ghost:hover { border-color: rgba(143,152,160,.4); background: rgba(255,255,255,.03); }
.btn.small { height: 36px; padding: 0 14px; font-size: 14px; border-radius: 8px; }
.btn.coffee { background: #ff5e5b; box-shadow: 0 8px 24px rgba(255, 94, 91, .22); }
.btn.coffee:hover { background: #ff7471; }
.btn svg { width: 18px; height: 18px; flex: none; }
.btn[disabled] { opacity: .6; cursor: progress; transform: none; }
/* ---- hero ---- */
.hero { position: relative; padding: 88px 0 40px; overflow: hidden; text-align: center; }
.hero::before { content: ""; position: absolute; inset: -30% -10% auto; height: 900px; pointer-events: none;
background: radial-gradient(600px 380px at 30% 30%, rgba(26,159,255,.22), transparent 70%),
radial-gradient(520px 360px at 72% 20%, rgba(111,66,193,.2), transparent 70%); }
.hero > * { position: relative; }
.eyebrow { display: inline-block; max-width: 100%; padding: 6px 14px; border-radius: 999px; font-size: 13.5px;
color: var(--link); background: rgba(26,159,255,.1); border: 1px solid rgba(102,192,244,.22); margin-bottom: 26px; }
.eyebrow b { color: var(--bright); font-weight: 600; }
.eyebrow .plats { white-space: nowrap; }
@media (max-width: 520px) { .eyebrow { border-radius: 16px; } .eyebrow .sep { display: none; } .eyebrow .plats { display: block; white-space: normal; } }
.hero h1 { max-width: 880px; margin: 0 auto; }
.hero h1 span { background: linear-gradient(90deg, #66c0f4, #1a9fff 45%, #8a6cff); -webkit-background-clip: text; background-clip: text; color: transparent; }
.lede { max-width: 680px; margin: 22px auto 0; font-size: 19px; color: var(--text); }
.cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin-top: 34px; }
.fine { margin-top: 16px; font-size: 13.5px; color: var(--muted); }
.fine a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
.shot { margin: 64px auto 0; max-width: 1080px; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line);
box-shadow: 0 50px 120px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.02), 0 0 120px rgba(26,159,255,.12); }
.shot .bar { display: flex; gap: 7px; padding: 12px 14px; background: #10171f; border-bottom: 1px solid var(--line); }
.shot .bar i { width: 11px; height: 11px; border-radius: 50%; background: #2a3440; }
/* ---- sections ---- */
section { padding: 96px 0; }
section.alt { background: var(--bg-2); border-block: 1px solid var(--line); }
.head { max-width: 700px; margin: 0 auto 52px; text-align: center; }
.kicker { display: block; color: var(--link); font-size: 13px; font-weight: 650; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 12px; }
.head p { margin-top: 14px; font-size: 17.5px; color: var(--muted); }
.video { max-width: 1000px; margin: 0 auto; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #000;
box-shadow: 0 40px 100px rgba(0,0,0,.5); }
.video video { width: 100%; aspect-ratio: 16 / 9; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px; transition: border-color .2s, transform .2s; }
.card:hover { border-color: rgba(102,192,244,.3); transform: translateY(-2px); }
.card .ico { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; margin-bottom: 16px;
background: rgba(26,159,255,.12); color: var(--link); }
.card .ico svg { width: 22px; height: 22px; }
.card p { margin-top: 8px; color: var(--muted); font-size: 15px; }
.feature { display: grid; grid-template-columns: 1fr 1.35fr; gap: 56px; align-items: center; }
.feature + .feature { margin-top: 96px; }
.feature.flip { grid-template-columns: 1.35fr 1fr; }
.feature.flip .copy { order: 2; }
.feature .copy h2 { margin-top: 0; }
.feature .copy p { margin-top: 16px; color: var(--muted); font-size: 17px; }
.feature ul { margin: 20px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.feature li { padding-left: 28px; position: relative; color: var(--text); }
.feature li::before { content: ""; position: absolute; left: 2px; top: 7px; width: 14px; height: 8px; border-left: 2px solid var(--link);
border-bottom: 2px solid var(--link); transform: rotate(-45deg); }
.feature img { border-radius: 12px; border: 1px solid var(--line); box-shadow: 0 30px 80px rgba(0,0,0,.45); }
@media (max-width: 880px) { .feature, .feature.flip { grid-template-columns: 1fr; gap: 28px; } .feature.flip .copy { order: 0; } }
.phones { display: flex; justify-content: center; gap: 28px; margin-top: 8px; }
.phones img { width: 260px; border-radius: 34px; border: 8px solid #0a0f15; box-shadow: 0 30px 80px rgba(0,0,0,.5), 0 0 0 1px var(--line); }
.phones img:last-child { transform: translateY(40px); }
@media (max-width: 600px) { .phones img { width: 44%; border-width: 5px; border-radius: 22px; } }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; counter-reset: step; }
.steps .card { position: relative; padding-top: 64px; }
.steps .card::before { counter-increment: step; content: counter(step); position: absolute; top: 22px; left: 24px; width: 30px; height: 30px;
border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 14px; color: var(--bright); background: var(--action); }
@media (max-width: 880px) { .steps { grid-template-columns: 1fr; } }
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.dl { display: flex; flex-direction: column; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 26px; }
.dl.mine { border-color: rgba(26,159,255,.55); box-shadow: 0 0 0 1px rgba(26,159,255,.25), 0 20px 50px rgba(26,159,255,.1); }
.dl .os { display: flex; align-items: center; gap: 10px; }
.dl .os svg { width: 24px; height: 24px; color: var(--bright); }
.dl .need { color: var(--muted); font-size: 14px; }
.dl .links { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.dl .links .btn { width: 100%; }
.dl .tag { margin-left: auto; font-size: 11.5px; font-weight: 650; letter-spacing: 1px; text-transform: uppercase; color: var(--link); }
.faq { max-width: 780px; margin: 0 auto; display: grid; gap: 10px; }
.faq details { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 0 22px; }
.faq summary { cursor: pointer; list-style: none; padding: 18px 0; color: var(--bright); font-weight: 600; display: flex; justify-content: space-between; gap: 16px; }
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after { content: "+"; color: var(--muted); font-size: 22px; line-height: 1; transition: transform .2s; }
.faq details[open] summary::after { transform: rotate(45deg); }
.faq details > div { padding: 0 0 20px; color: var(--muted); }
.faq details > div p + p { margin-top: 10px; }
.faq.notes section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 22px 20px; color: var(--muted); }
.faq.notes h2 { font-size: 16px; font-weight: 600; letter-spacing: 0; color: var(--bright); margin: 0 0 10px; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.split .card { padding: 36px; }
.split .card h3 { font-size: 24px; }
.split .card p { font-size: 16px; margin: 12px 0 24px; }
@media (max-width: 760px) { .split { grid-template-columns: 1fr; } }
/* ---- footer ---- */
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 .cols { display: flex; gap: 12px 28px; flex-wrap: wrap; }
footer a { color: var(--muted); }
footer .legal { flex-basis: 100%; font-size: 13px; }
/* ---- feedback page ---- */
.page { padding: 72px 0 96px; }
.page .head { text-align: left; margin: 0 0 36px; max-width: none; }
.form-wrap { display: grid; grid-template-columns: 1fr 320px; gap: 28px; align-items: start; }
@media (max-width: 900px) { .form-wrap { grid-template-columns: 1fr; } }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 30px; }
aside.panel h3 { margin-bottom: 10px; }
aside.panel p { color: var(--muted); font-size: 15px; }
aside.panel p + h3 { margin-top: 26px; }
.field { display: grid; gap: 8px; margin-bottom: 22px; }
.field > label, .field > legend { color: var(--bright); font-weight: 600; font-size: 14.5px; padding: 0; }
.field > legend { margin-bottom: 10px; } /* fieldset grids ignore gap for the legend */
.field small { color: var(--muted); font-size: 13px; font-weight: 400; }
.row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
@media (max-width: 640px) { .row3 { grid-template-columns: 1fr; } }
fieldset { border: 0; padding: 0; margin: 0 0 22px; min-width: 0; }
input[type=text], textarea { width: 100%; font: 15.5px/1.5 var(--font); color: var(--bright); background: #0f161e; border: 1px solid rgba(143,152,160,.22);
border-radius: 9px; padding: 11px 13px; transition: border-color .15s, box-shadow .15s; }
input[type=text]::placeholder, textarea::placeholder { color: var(--dim); }
input[type=text]:focus, textarea:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(26,159,255,.2); }
textarea { min-height: 180px; resize: vertical; }
.kinds { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
@media (max-width: 640px) { .kinds { grid-template-columns: repeat(2, 1fr); } }
.kinds label { position: relative; cursor: pointer; }
.kinds input { position: absolute; opacity: 0; inset: 0; pointer-events: none; }
.kinds span { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 14px 8px; border-radius: 10px; font-size: 14px; font-weight: 600;
color: var(--muted); background: #0f161e; border: 1px solid rgba(143,152,160,.18); transition: all .15s; }
.kinds span svg { width: 20px; height: 20px; }
.kinds input:checked + span { color: var(--bright); border-color: var(--blue); background: rgba(26,159,255,.1); }
.kinds input:focus-visible + span { outline: 2px solid var(--link); outline-offset: 2px; }
.trap { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 8px; }
.note { color: var(--muted); font-size: 13.5px; }
.alert { border-radius: 10px; padding: 14px 16px; margin-bottom: 20px; font-size: 15px; }
.alert.err { background: rgba(224,87,60,.12); border: 1px solid rgba(224,87,60,.4); color: #ffb4a6; }
.alert.err a { color: #ffd2c9; text-decoration: underline; }
.done { text-align: center; padding: 40px 10px; }
.done .tick { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 20px; display: grid; place-items: center; background: rgba(117,176,34,.15); color: #a4d007; }
.done .tick svg { width: 30px; height: 30px; }
.done p { color: var(--muted); margin: 10px auto 24px; max-width: 460px; }
.done .cta { margin-top: 0; }
.page h1 { font-size: clamp(34px, 5vw, 48px); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+143
View File
@@ -0,0 +1,143 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Send feedback · Frame Control</title>
<meta name="description" content="Report a bug, suggest an idea or ask a question about Frame Control. No GitHub account needed.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
<script src="/js/feedback.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="/#features">Features</a>
<a href="/#setup">Setup</a>
<a href="/#download">Download</a>
<a href="/#faq">FAQ</a>
<a href="/feedback/" aria-current="page">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main class="page">
<div class="wrap">
<div class="head">
<span class="kicker">Feedback</span>
<h1>Tell us what happened</h1>
<p>Bugs, ideas and questions all help. What you send becomes a public issue on GitHub, where you can follow it.</p>
</div>
<div class="form-wrap">
<div>
<form class="panel" id="feedback" novalidate>
<div class="alert err" id="error" role="alert" hidden></div>
<fieldset class="field">
<legend>What kind of feedback?</legend>
<div class="kinds">
<label><input type="radio" name="kind" value="bug" checked><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2l1.9 1.9M16 2l-1.9 1.9M9 7.1V6a3 3 0 1 1 6 0v1.1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6zM12 20v-9M6.5 13H3M21 13h-3.5M6 9 3.5 7M18 9l2.5-2M6 17l-2.5 2M18 17l2.5 2"/></svg>Bug</span></label>
<label><input type="radio" name="kind" value="idea"><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7V16h8v-1.3A7 7 0 0 0 12 2z"/></svg>Idea</span></label>
<label><input type="radio" name="kind" value="question"><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/></svg>Question</span></label>
<label><input type="radio" name="kind" value="other"><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/></svg>Other</span></label>
</div>
</fieldset>
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title" maxlength="120" required placeholder="e.g. Live view stops after a minute on Windows">
</div>
<div class="field">
<label for="message">Details <small id="message-hint">What you tried, what happened, and what you expected.</small></label>
<textarea id="message" name="message" maxlength="5000" required></textarea>
</div>
<div class="row3">
<div class="field">
<label for="os">Your computer</label>
<input type="text" id="os" name="os" maxlength="120" placeholder="e.g. Windows 11">
</div>
<div class="field">
<label for="version">App version</label>
<input type="text" id="version" name="version" maxlength="120" placeholder="e.g. v0.3.1">
</div>
<div class="field">
<label for="steamos">SteamOS build</label>
<input type="text" id="steamos" name="steamos" maxlength="120" placeholder="Steam Settings → System">
</div>
</div>
<div class="field">
<label for="github">GitHub username <small>Optional. Add it to get notified when someone replies.</small></label>
<input type="text" id="github" name="github" maxlength="40" autocomplete="username" placeholder="@yourname">
</div>
<div class="trap" aria-hidden="true">
<label for="website">Leave this empty</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="actions">
<button class="btn primary" type="submit" id="send">Send feedback</button>
<span class="note">Everything you send is public. Don't include passwords, IP addresses or personal details.</span>
</div>
</form>
<div class="panel done" id="done" hidden>
<div class="tick"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7"/></svg></div>
<h2>Thanks, that's sent</h2>
<p id="done-text">Your feedback is now an issue on GitHub.</p>
<div class="cta">
<a class="btn primary" id="issue-link" href="https://github.com/saphid/frame-control/issues" hidden>View your issue</a>
<a class="btn ghost" href="/feedback/">Send more</a>
</div>
</div>
</div>
<aside class="panel">
<h3>Reporting a bug?</h3>
<p>The server log helps most: in the app, choose <b>Frame → Show Server Log</b> and paste the last few lines into Details.</p>
<h3>Prefer GitHub?</h3>
<p>You can <a href="https://github.com/saphid/frame-control/issues/new/choose">open an issue there directly</a>. First-time issues are closed until a maintainer reviews them; see <a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>. This form skips that queue.</p>
<h3 data-needs-kofi>Enjoying it?</h3>
<p data-needs-kofi>Frame Control is free. <a data-kofi href="#" target="_blank" rel="noopener">Buy me a coffee</a> if it saved you some time.</p>
</aside>
</div>
</div>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+313
View File
@@ -0,0 +1,313 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frame Control: manage your Steam Frame from your computer</title>
<meta name="description" content="Free, open-source app for the Valve Steam Frame. See what the headset sees, install Steam games and Android apps, move files and text across, and check battery and status. macOS, Windows, Linux and iPhone.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta property="og:type" content="website">
<meta property="og:title" content="Frame Control">
<meta property="og:description" content="Manage your Valve Steam Frame from your computer. Free and open source.">
<meta property="og:image" content="/img/og.jpg">
<meta name="twitter:card" content="summary_large_image">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="#features">Features</a>
<a href="#setup">Setup</a>
<a href="#download">Download</a>
<a href="#faq">FAQ</a>
<a href="/feedback/">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main>
<section class="hero">
<div class="wrap">
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux · iPhone</span></span>
<h1>Your Steam Frame, <span>managed from your desk.</span></h1>
<p class="lede">See what the headset sees, install games and Android apps, move files and text across, and keep an eye on battery and status. All over SSH, with nothing to install on the Frame.</p>
<div class="cta">
<a class="btn primary" id="hero-download" href="#download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 20h16"/></svg>
<span data-label>Download Frame Control</span>
</a>
<a class="btn ghost" href="#trailer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg>
Watch the trailer
</a>
</div>
<p class="fine"><span hidden>Latest: <span data-version></span> · </span><a href="#download">All platforms</a> · MIT licence · Not affiliated with Valve</p>
<div class="shot">
<div class="bar"><i></i><i></i><i></i></div>
<img src="/img/home-live.jpg" width="1600" height="1000" alt="Frame Control's Home tab: a live view of the headset beside battery, storage, temperature and Wi-Fi.">
</div>
</div>
</section>
<section id="trailer" class="alt">
<div class="wrap">
<div class="head">
<span class="kicker">Trailer</span>
<h2>Sixty-six seconds of Frame Control</h2>
<p>Recorded against a real Steam Frame. Turn the sound on.</p>
</div>
<div class="video">
<video controls playsinline preload="none" poster="/media/poster.jpg">
<source src="/media/trailer.mp4" type="video/mp4">
</video>
</div>
</div>
</section>
<section id="features">
<div class="wrap">
<div class="head">
<span class="kicker">Features</span>
<h2>Everything the headset needs, one window away</h2>
<p>Frame Control uses what SteamOS already ships. It only changes what you click.</p>
</div>
<div class="grid">
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="10" rx="4"/><circle cx="8" cy="12" r="2"/><circle cx="16" cy="12" r="2"/></svg></div>
<h3>Headset view</h3>
<p>Live video of what the lenses show at about 30 fps, or a still of both eyes. Zoom, pan, full screen, save as PNG.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="17" height="10" rx="2"/><path d="M22 11v2M6 10v4M9.5 10v4"/></svg></div>
<h3>Battery and status</h3>
<p>Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 11h4M8 9v4M15 12h.01M18 10h.01"/><path d="M17.3 5H6.7a4 4 0 0 0-3.98 3.59l-.7 7A3 3 0 0 0 5 19c1 0 1.5-.5 2-1l1.4-1.4A2 2 0 0 1 9.8 16h4.4a2 2 0 0 1 1.4.6L17 18c.5.5 1 1 2 1a3 3 0 0 0 2.98-3.41l-.7-7A4 4 0 0 0 17.3 5z"/></svg></div>
<h3>Steam games</h3>
<p>Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="8" width="14" height="12" rx="3"/><path d="M8 8a4 4 0 0 1 8 0M8 4l1.5 2M16 4l-1.5 2M10 13h.01M14 13h.01"/></svg></div>
<h3>Android apps</h3>
<p>About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M12 18v-6m0 0-3 3m3-3 3 3"/></svg></div>
<h3>Files, games and clipboard</h3>
<p>Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library. Send your clipboard to the headset's desktop.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8a2 2 0 0 1 2-2h2l2-2h4l2 2h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><circle cx="12" cy="13" r="3.5"/></svg></div>
<h3>Screenshots</h3>
<p>Browse the shots you take in the headset and save them straight to your Pictures folder.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M17.5 14v7M14 17.5h7"/></svg></div>
<h3>Flatpaks and display</h3>
<p>Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg></div>
<h3>One-click tools</h3>
<p>SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.</p>
</div>
</div>
</div>
</section>
<section class="alt">
<div class="wrap">
<div class="feature">
<div class="copy">
<span class="kicker">Games</span>
<h2>Your Steam library, rated for the Frame</h2>
<p>See every game you own with how well it runs on the Frame, then install it onto the headset without putting it on.</p>
<ul>
<li>Live download progress, straight from the Steam client on the headset</li>
<li>Search the Steam store from your computer</li>
<li>Sideload your own Linux or Windows games, with Proton or the Linux runtime picked for you</li>
</ul>
</div>
<img src="/img/games.jpg" width="1600" height="1000" loading="lazy" alt="The Games tab: owned games with Steam Frame ratings and install buttons.">
</div>
<div class="feature flip">
<div class="copy">
<span class="kicker">Android</span>
<h2>Android apps, one click each</h2>
<p>A catalogue of about 4,500 F-Droid apps, each tested and rated on a real Frame. Every app gets its own Lepton instance and its own tile in your Steam library.</p>
<ul>
<li>Filter by what works on the Frame</li>
<li>Install any APK you have, no Android SDK needed</li>
<li>Set resolution and text size per app</li>
</ul>
</div>
<img src="/img/android.jpg" width="1600" height="1000" loading="lazy" alt="The Android tab: installed apps and the rated F-Droid catalogue.">
</div>
<div class="feature">
<div class="copy">
<span class="kicker">Tools</span>
<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>
</div>
<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>
</section>
<section>
<div class="wrap">
<div class="head">
<span class="kicker">iPhone and iPad</span>
<h2>Also in your pocket</h2>
<p>The same features on iPhone and iPad, served from the Frame itself, so there's nothing to run on a computer. Build it from the repo in Xcode.</p>
</div>
<div class="phones">
<img src="/img/phone-home.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: headset view and device status.">
<img src="/img/phone-games.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: the Games tab.">
</div>
</div>
</section>
<section id="setup" class="alt">
<div class="wrap">
<div class="head">
<span class="kicker">Setup</span>
<h2>One password, typed once</h2>
<p>Everything else happens on your computer.</p>
</div>
<div class="steps">
<div class="card">
<h3>Turn on Developer Mode</h3>
<p>On the Frame: Steam Settings → System → <b>Enable Developer Mode</b>, then <b>Set User Password</b> in the Developer section.</p>
</div>
<div class="card">
<h3>Set up the connection</h3>
<p>Open Frame Control and choose <b>Set Up Connection</b>. It finds the headset, makes an SSH key, and asks for that password once.</p>
</div>
<div class="card">
<h3>That's it</h3>
<p>The app reaches the headset whenever it's awake and on the same network. For anywhere else, use <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md">Tailscale</a>.</p>
</div>
</div>
</div>
</section>
<section id="download">
<div class="wrap">
<div class="head">
<span class="kicker">Download</span>
<h2>Get Frame Control</h2>
<p>Free, with Python and adb built in. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
</div>
<div class="downloads">
<div class="dl" data-os="mac">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.37 12.6c-.02-2.2 1.8-3.26 1.88-3.31-1.02-1.5-2.62-1.7-3.19-1.72-1.36-.14-2.65.8-3.34.8-.69 0-1.75-.78-2.88-.76a4.27 4.27 0 0 0-3.6 2.19c-1.53 2.66-.39 6.6 1.1 8.76.73 1.06 1.6 2.24 2.74 2.2 1.1-.04 1.51-.71 2.84-.71 1.32 0 1.7.71 2.86.69 1.18-.02 1.93-1.08 2.65-2.14.83-1.23 1.18-2.41 1.2-2.47-.03-.01-2.3-.88-2.32-3.5zM14.2 6.13c.6-.73 1.01-1.75.9-2.76-.87.04-1.92.58-2.54 1.3-.56.64-1.05 1.67-.92 2.66.97.08 1.96-.49 2.56-1.2z"/></svg><h3>macOS</h3><span class="tag" hidden>Your system</span></div>
<span class="need">Apple Silicon</span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg">Download .dmg</a>
</div>
</div>
<div class="dl" data-os="windows">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10 4.5v7H3zM11 4.35 21 3v8.5H11zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.4z"/></svg><h3>Windows</h3><span class="tag" hidden>Your system</span></div>
<span class="need">Windows 10 or 11, x64</span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe">Download installer</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip">Portable .zip</a>
</div>
</div>
<div class="dl" data-os="linux">
<div class="os"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/></svg><h3>Linux</h3><span class="tag" hidden>Your system</span></div>
<span class="need">x64 or arm64 · needs <code>ssh</code></span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage">AppImage (x64)</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb">.deb (x64)</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage">AppImage (arm64)</a>
</div>
</div>
</div>
</div>
</section>
<section id="faq" class="alt">
<div class="wrap">
<div class="head">
<span class="kicker">FAQ</span>
<h2>Good questions</h2>
</div>
<div class="faq">
<details>
<summary>macOS says the app is damaged or can't be checked</summary>
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Drag it to Applications, then clear the download quarantine once in Terminal:</p>
<p><code>xattr -dr com.apple.quarantine "/Applications/Frame Control.app"</code></p></div>
</details>
<details>
<summary>Windows SmartScreen says it protected my PC</summary>
<div><p>The installer isn't code-signed. Choose <b>More info → Run anyway</b>, or use the portable .zip: unzip it anywhere and run <code>Frame Control.exe</code>.</p></div>
</details>
<details>
<summary>What does it change on my headset?</summary>
<div><p>Only what you click. Installs go to your user account on the Frame, and nothing needs <code>sudo</code> except the power buttons. On your computer it adds a <code>Host frame</code> entry to <code>~/.ssh/config</code> and two SSH keys.</p></div>
</details>
<details>
<summary>Is it safe to leave Developer Mode on?</summary>
<div><p>With Developer Mode on, SSH, ADB and remote desktop are reachable on your local network. Use trusted networks, turn Developer Mode off when you don't need it, and never port-forward those ports from your router. For remote access, use Tailscale. <a href="https://github.com/saphid/frame-control#readme">Security notes</a></p></div>
</details>
<details>
<summary>Is this made by Valve?</summary>
<div><p>No. It's an unofficial hobby project, free and MIT-licensed. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p></div>
</details>
</div>
</div>
</section>
<section>
<div class="wrap split">
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/></svg></div>
<h3>Tell us what you think</h3>
<p>Found a bug, or want something added? Reports from Windows and Linux are especially useful. No GitHub account needed.</p>
<a class="btn primary" href="/feedback/">Send feedback</a>
</div>
<div class="card">
<div class="ico" style="background:rgba(255,94,91,.14);color:#ff8a87"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4zM6 2v3M10 2v3M14 2v3"/></svg></div>
<h3 data-needs-kofi>Buy me a coffee</h3>
<h3 data-no-kofi hidden>Like it? Pass it on</h3>
<p data-needs-kofi>Frame Control is free and always will be. If it saves you time, a coffee keeps it going.</p>
<p data-no-kofi hidden>Frame Control is free and always will be. The best way to help is to star the repo and tell a friend.</p>
<a class="btn coffee" data-kofi href="#" target="_blank" rel="noopener">Support on Ko-fi</a>
<a class="btn ghost" data-no-kofi hidden href="https://github.com/saphid/frame-control">Star on GitHub</a>
</div>
</div>
</section>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
// The feedback form: posts to /api/feedback (site/functions/api/feedback.js), which opens a GitHub issue.
const form = document.getElementById("feedback");
const errorBox = document.getElementById("error");
const send = document.getElementById("send");
const started = performance.now();
const HINTS = {
bug: "What you tried, what happened, and what you expected.",
idea: "What you'd like, and what it would help you do.",
question: "What you'd like to know.",
other: "Anything you'd like to tell us.",
};
// Prefill the computer field; people rarely know the exact wording otherwise.
(function guessOs() {
const ua = navigator.userAgent;
const guess = /Windows/.test(ua) ? "Windows" : /Macintosh/.test(ua) ? "macOS" : /iPhone|iPad/.test(ua) ? "iOS"
: /Android/.test(ua) ? "" : /Linux/.test(ua) ? "Linux" : "";
if (guess) form.elements.os.value = guess;
})();
form.addEventListener("change", (e) => {
if (e.target.name === "kind") document.getElementById("message-hint").textContent = HINTS[e.target.value];
});
// A prefilled GitHub issue form (.github/ISSUE_TEMPLATE), for when this form can't reach GitHub itself.
function githubUrl(data) {
const params = data.kind === "idea"
? new URLSearchParams({ template: "idea.yml", title: data.title, what: data.message })
: new URLSearchParams({ template: "bug.yml", title: data.title, description: data.message,
version: data.version, os: [data.os, data.steamos && `SteamOS ${data.steamos}`].filter(Boolean).join(", ") });
return `https://github.com/saphid/frame-control/issues/new?${params}`;
}
function showError(message, data) {
errorBox.textContent = message + " ";
if (data) {
const a = document.createElement("a");
a.href = githubUrl(data);
a.target = "_blank";
a.rel = "noopener";
a.textContent = "Open it on GitHub instead";
errorBox.append(a);
}
errorBox.hidden = false;
errorBox.scrollIntoView({ behavior: "smooth", block: "center" });
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorBox.hidden = true;
const data = Object.fromEntries(new FormData(form));
if (data.title.trim().length < 5) {
form.elements.title.focus();
return showError("Give it a short title (at least 5 characters).");
}
if (data.message.trim().length < 10) {
form.elements.message.focus();
return showError("Tell us a little more in Details.");
}
send.disabled = true;
send.textContent = "Sending…";
try {
// The server treats anything sent sooner as a script (site/lib/feedback.js MIN_FILL_MS).
const wait = 3100 - (performance.now() - started);
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...data, elapsed: Math.round(performance.now() - started) }),
});
const reply = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(reply.error || "Something went wrong sending that.");
form.hidden = true;
document.getElementById("done").hidden = false;
if (reply.url) {
const link = document.getElementById("issue-link");
link.href = reply.url;
link.hidden = false;
document.getElementById("done-text").textContent = `Your feedback is now issue #${reply.number} on GitHub.`
+ (data.github ? " You'll be notified when someone replies." : " Bookmark it to follow along.");
}
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (err) {
showError(err.message || "Couldn't reach the server.", data);
} finally {
send.disabled = false;
send.textContent = "Send feedback";
}
});
+63
View File
@@ -0,0 +1,63 @@
// Shared by every page. Change the settings here, not in the HTML.
const SITE = {
repo: "saphid/frame-control",
// Ko-fi page name, the part after ko-fi.com/. Donate buttons stay hidden while it's empty.
kofi: "alexsouthwell",
};
const RELEASE = `https://github.com/${SITE.repo}/releases/latest/download/`;
// Donate buttons.
for (const el of document.querySelectorAll("[data-kofi]")) {
if (SITE.kofi) el.href = `https://ko-fi.com/${SITE.kofi}`;
else el.hidden = true;
}
for (const el of document.querySelectorAll("[data-needs-kofi]")) el.hidden = !SITE.kofi;
for (const el of document.querySelectorAll("[data-no-kofi]")) el.hidden = !!SITE.kofi;
// Best guess at the visitor's platform, for the hero button and the download cards.
function detectPlatform() {
const ua = navigator.userAgent;
const hint = navigator.userAgentData?.platform || navigator.platform || "";
if (/iPhone|iPad|iPod/.test(ua) || (/Mac/.test(hint) && navigator.maxTouchPoints > 1)) return "ios";
if (/Android/.test(ua)) return null;
if (/Mac/.test(hint) || /Macintosh/.test(ua)) return "mac";
if (/Win/.test(hint) || /Windows/.test(ua)) return "windows";
if (/Linux/.test(hint) || /Linux/.test(ua)) return /aarch64|arm64/i.test(ua + hint) ? "linux-arm" : "linux";
return null;
}
const PLATFORMS = {
mac: { name: "macOS", file: "Frame-Control-mac-arm64.dmg" },
windows: { name: "Windows", file: "Frame-Control-Setup-x64.exe" },
linux: { name: "Linux", file: "Frame-Control-linux-x86_64.AppImage" },
"linux-arm": { name: "Linux (arm64)", file: "Frame-Control-linux-arm64.AppImage" },
};
const platform = detectPlatform();
const hero = document.getElementById("hero-download");
if (hero && PLATFORMS[platform]) {
hero.href = RELEASE + PLATFORMS[platform].file;
hero.querySelector("[data-label]").textContent = `Download for ${PLATFORMS[platform].name}`;
}
const card = platform && document.querySelector(`.dl[data-os="${platform.replace("-arm", "")}"]`);
if (card) {
card.classList.add("mine");
card.querySelector(".tag").hidden = false;
}
// Latest version number, so the page never goes stale. Fails quietly.
const versionEls = document.querySelectorAll("[data-version]");
if (versionEls.length) {
fetch(`https://api.github.com/repos/${SITE.repo}/releases/latest`, { headers: { accept: "application/vnd.github+json" } })
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((release) => {
for (const el of versionEls) {
el.textContent = release.tag_name;
el.closest("[hidden]")?.removeAttribute("hidden");
}
})
.catch(() => {});
}
document.querySelectorAll("[data-year]").forEach((el) => (el.textContent = new Date().getFullYear()));
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy · Frame Control</title>
<meta name="description" content="What the Frame Control website and app collect.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="/#features">Features</a>
<a href="/#setup">Setup</a>
<a href="/#download">Download</a>
<a href="/#faq">FAQ</a>
<a href="/feedback/">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main class="page">
<div class="wrap" style="max-width:780px">
<div class="head">
<span class="kicker">Privacy</span>
<h1>Privacy</h1>
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
</div>
<div class="faq notes">
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
</div>
</div>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+74
View File
@@ -0,0 +1,74 @@
// node --test site/test/*.test.mjs
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildIssue, defang, hashIp, MIN_FILL_MS, validate } from "../lib/feedback.js";
const form = (over = {}) => ({
kind: "bug",
title: "Live view freezes",
message: "After about a minute the live view stops updating.",
elapsed: MIN_FILL_MS + 1,
...over,
});
test("accepts a normal report and trims it", () => {
const { value, error } = validate(form({ title: " Live view freezes ", os: " macOS 26 " }));
assert.equal(error, undefined);
assert.equal(value.title, "Live view freezes");
assert.equal(value.os, "macOS 26");
assert.equal(value.kind, "bug");
});
test("flags the honeypot as spam and asks fast senders to retry", () => {
assert.deepEqual(validate(form({ website: "http://spam" })), { spam: true });
assert.match(validate(form({ elapsed: 500 })).error, /again/);
assert.deepEqual(validate(form({ elapsed: undefined })), { spam: true });
});
test("rejects short, long and malformed input", () => {
assert.match(validate(form({ title: "hi" })).error, /title/);
assert.match(validate(form({ message: "short" })).error, /more/);
assert.match(validate(form({ message: "x".repeat(5001) })).error, /under/);
assert.match(validate(form({ github: "not a user!" })).error, /GitHub/);
assert.match(validate(null).error, /JSON/);
});
test("unknown kinds become other feedback", () => {
assert.equal(validate(form({ kind: "__proto__" })).value.kind, "other");
});
test("builds a labelled issue that credits a GitHub user", () => {
const { value } = validate(form({ github: "@octocat", version: "0.3.1", steamos: "20260922" }));
const issue = buildIssue(value);
assert.equal(issue.title, "Bug report: Live view freezes");
assert.deepEqual(issue.labels, ["feedback", "bug"]);
assert.match(issue.body, /\| Frame Control version \| 0\.3\.1 \|/);
assert.match(issue.body, /^> Sent from the website feedback form by @octocat\./);
assert.ok(issue.body.endsWith(value.message));
});
test("anonymous feedback says replies won't reach the sender", () => {
const issue = buildIssue(validate(form({ kind: "other" })).value);
assert.deepEqual(issue.labels, ["feedback"]);
assert.match(issue.body, /won't see replies/);
});
test("breaks mentions, issue refs and table cells in user text", () => {
assert.equal(defang("ping @valve about #12"), "ping @\u200bvalve about #\u200b12");
assert.equal(defang("email me@example.com"), "email me@\u200bexample.com");
assert.equal(defang("see valve/steam#7 and GH-8"), "see valve/steam#\u200b7 and GH\u200b-8");
assert.equal(defang("&commat;valve &#64;valve &num;3"), "&amp;commat;valve &amp;#\u200b64;valve &amp;num;3");
assert.equal(defang("end <!--"), "end &lt;!--");
assert.equal(defang("GH\\-1 github\\.com"), "GH\\\\-1 github\\\\.com");
assert.equal(defang("https://github.com/a/b/issues/1"), "https://github\u200b.com/a/b/issues/1");
const issue = buildIssue(validate(form({ os: "a | b" })).value);
assert.match(issue.body, /\| a \\\| b \|/);
});
test("hashes IPs without keeping them", async () => {
const a = await hashIp("203.0.113.9", "salt");
assert.equal(a.length, 24);
assert.equal(a, await hashIp("203.0.113.9", "salt"));
assert.notEqual(a, await hashIp("203.0.113.10", "salt"));
assert.ok(!a.includes("203"));
});
+12
View File
@@ -0,0 +1,12 @@
# Cloudflare Pages project for the Frame Control website. Deploy: see site/README.md.
name = "frame-control"
pages_build_output_dir = "public"
compatibility_date = "2026-07-01"
[vars]
GITHUB_REPO = "saphid/frame-control"
# Rate limits for /api/feedback (site/functions/api/feedback.js).
[[kv_namespaces]]
binding = "FEEDBACK_RL"
id = "4852de5aae9e4d9f989f6dc2bc3b2b6b"
+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"]
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more