Compare commits

...
52 Commits
Author SHA1 Message Date
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
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
saphidandClaude Opus 5.5 13f629af35 WebXR doc: drop the stale not-verified list
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:55:09 +10:00
saphidandClaude Opus 5.5 7efce19f4c WebXR doc: measured frame rate and controller input
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:54:48 +10:00
saphidandClaude Opus 5.5 623ce683d4 Document testing VR without wearing the headset; Steam-launched WebXR verified
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:47:57 +10:00
saphidandClaude Opus 5.5 e5f3c96275 WebXR doc: keep only the verified DevTools launch recipe
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:47:33 +10:00
saphidandClaude Opus 5.5 0cb289d571 WebXR: point to the public chromium-webxr-steam-frame repo
The build script, launcher and Steam shortcut helper now live in
saphid/chromium-webxr-steam-frame, so drop the duplicate copies here.
The doc keeps the findings, verification and upstream status.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 07:06:42 +10:00
Alex Southwell d7fc0189b5 Merge pull request #3 from saphid/frame-sideload-features
Sideload Linux/Windows builds, install links, and devkit pairing
2026-09-26 23:21:01 +10:00
saphidandClaude Opus 5.5 8f379db15e Skip the stalled-download quit tests on Windows, and say why
Waking a recv blocked in another thread needs the handle closed on
Windows, which isn't safe under a TLS read (the previous commit, reverted
after review). A stalled download there holds the quit for the 4 s grace
period; its partial file is swept on the next start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 23:16:57 +10:00
saphid 2f5ba5c086 Revert "Interrupt stalled downloads on Windows too"
This reverts commit b86dd3b07d.
2026-09-26 23:16:31 +10:00
saphidandClaude Opus 5.5 5592ee1570 Chromium XR build: skip gclient sync on re-runs; link the public repo
Once the SO_PEERCRED patch is applied, gclient sync refuses the modified
checkout, so a re-run failed. Sync once per revision instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 23:14:03 +10:00
saphidandClaude Opus 5.5 b86dd3b07d Interrupt stalled downloads on Windows too
shutdown() from another thread doesn't wake a blocked recv on Windows, so
quitting mid-download waited out the 4 s deadline there (CI's Windows
server tests). Close the handle as well, detached first so the worker's own
close can't hit a reused handle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 23:06:13 +10:00
saphid 1cd839e0f1 Merge remote-tracking branch 'origin/main' into frame-sideload-features 2026-09-26 23:00:33 +10:00
Alex Southwell 91aafc1371 Merge pull request #2 from saphid/bundle-deps
Bundle Python and adb so the app needs nothing installed
2026-09-26 22:55:54 +10:00
saphidandClaude Opus 5.5 eb78eba0dc Give the install dialog its own snapshot of installed titles
A Refresh in flight could leave the shared list stale when the dialog
opened. The drop now fetches the list itself and hands it to the dialog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 22:55:42 +10:00
saphidandClaude Opus 5.5 fd0a284942 Refuse APK entries Android can't read; ignore stale title lists
- Only stored and deflated entries are read: Python 3.9's bzip2 and lzma
  readers inflate without bound before trimming.
- loadTitles drops a response that a newer request has overtaken, so the
  install dialog's replace warning uses the fresh list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 22:49:18 +10:00
saphidandClaude Opus 5.5 dfacf43e55 Fix the follow-up review's findings
- APK reader: read members with a bounded read, since ZipFile.read inflates
  a member fully before trimming it to a forged declared size; the reference
  walk counts every entry it examines, dead ends and cycles included.
- Titles: a path that exists under the root wins over stripping the archive
  prefix; the prefix is taken before a linked folder is staged elsewhere
  (another drive on Windows); the install dialog loads a fresh title list
  first and says so if it couldn't check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 22:44:07 +10:00
saphidandClaude Opus 5.5 0530a6d045 Field notes: boot-loop recovery, the SteamVR health check, T3 Code desktop
- Recovery menu (AUX + Power), USB and EDL re-imaging, from Valve's docs.
- Verified cause and fix of a boot loop: steamvr-health-check wipes
  ~/.local/share/Steam after repeated SteamVR start failures, which then
  repeat while Steam re-downloads, until the Frame reboots.
- T3 Code desktop running natively as a panel (from an earlier session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 22:36:34 +10:00
saphidandClaude Opus 5.5 39d28790a1 Fix the cross-provider review's findings
- APK reader: cap AndroidManifest.xml, resources.arsc and icon sizes before
  inflating them (APKs can come from install links), and follow resource
  references without cycles and with a result budget.
- Sideloading: reserve devkit-steam (SteamOS's sideloaded-client trampoline);
  the install dialog warns when a name replaces an installed title; a
  manifest's exe may name the program as it is in the archive, above the
  folder the installer steps into.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 22:35:16 +10:00
saphidandClaude Opus 5.5 1580dae42e Record headset results for sideloading and devkit pairing
Tested on a Frame (BUILD_ID 20260922.6101926):

- Devkit pairing: the service runs and answers properties.json, but /register
  refuses with "please put the Steam client in pairing mode" unless Steam is on
  Settings > Developer > Pair new host. Both setup paths now say so and keep
  asking for 2 minutes before falling back to the password.
- Sideloading: registering, launching, quoted start paths and Remove work; a
  Windows exe runs under Proton 11 through FEX. An aarch64 build starts but
  outside the runtime container, and an x86-64 Linux build doesn't start
  because the x86-64 Steam Linux Runtime 4.0 isn't installed. The inspect note
  for x86-64 Linux builds now says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 22:31:05 +10:00
saphid 84ac687399 Merge remote-tracking branch 'origin/main' into frame-sideload-features 2026-09-26 22:01:46 +10:00
saphid ccf5f3e123 Merge remote-tracking branch 'origin/bundle-deps' into frame-sideload-features
# Conflicts:
#	app/main.js
2026-09-26 22:01:46 +10:00
saphidandClaude Opus 5.5 0478626061 Reject bad redirects cleanly and ignore any icon read error
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:55:41 +10:00
saphidandClaude Opus 5.5 2ab2ffa65a Fix the final review's findings on the combined features
- Links to localhost need FRAME_CONTROL_LOCAL_LINKS=1: otherwise any website's
  link could make the app fetch from services on this computer.
- Title staging folders (unzipped titles) carry the server's PID and are swept
  on the next start like download folders, so quitting mid-install doesn't leave
  gigabytes behind.
- An install from a link refreshes Sideloaded titles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:49:21 +10:00
saphidandClaude Opus 5.5 d145537d9a Harden the APK reader and build downloads after review
- frame_apk: android: attributes win over same-named attributes in
  other namespaces; string attributes that keep only a typed value (no
  raw string) still resolve; a failed icon read leaves the icon out
  instead of failing the install.
- The clipboard IPC origin check can't throw on odd frame URLs.
- fetch-deps.js: 60 s download timeout, at most 5 redirects, a SystemRoot
  fallback for tar.exe, and prunes pydoc_data, venv and the static
  libpython.
- Docs keep the clipboard-tool note for running the UI in a browser.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:44:40 +10:00
saphidandClaude Opus 5.5 636a4a47b7 Merge sideloading, install links and devkit pairing
Combines the three feature branches on bundle-deps. Conflicts in server.py,
index.html, preload.js, README and test_server.py keep both sides. The
web-install downloader now uses urllib's default HTTPS context, so the
bundled CA list from 770f26c applies to it on Windows too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:39:52 +10:00
saphid 26fff2a06c Merge branch 'worktree-agent-abd0401d6e82a9dd4' into frame-sideload-features 2026-09-26 20:38:41 +10:00
saphid 031ab6aa12 Merge branch 'worktree-agent-a64afd16c052d0281' into frame-sideload-features 2026-09-26 20:38:41 +10:00
saphidandClaude Opus 5.5 6c4d387ef3 Sideload Linux and Windows builds as Steam Devkit Games
Dropping a game's .zip, folder or .exe on Send to Frame now adds it to the
headset's Steam library through Valve's SteamOS Devkit title path, with the
runtime picked from the program's header: Windows PE -> Proton Experimental
(steam_play=1), aarch64 ELF -> SteamLinuxRuntime_4-arm64, x86-64 ELF ->
SteamLinuxRuntime_4 (through FEX). Other architectures are refused.

- frame/devkit-utils: Valve's devkit-utils vendored unmodified (MIT,
  steamos-devkit v0.20260925.1), synced to ~/devkit-utils by stamp, bundled in
  the app and compiled in CI.
- ui/frame_titles.py: inspect (safe unzip, ELF/PE classification, launch
  target ranking), install(path, name=None, exe=None, runtime=None,
  progress=None), list, launch, remove, plus a CLI.
- ui/server.py: /api/titles (inspect/install/discard/launch/remove),
  /api/titles/job progress, and an upload mode 'title'.
- ui/index.html: confirm dialog (name, launch target, runtime), install
  progress, and a Sideloaded titles list with Launch and Remove. The app's
  preload passes a dropped folder's path.
- tests and docs/sideloading.md. Device-side behaviour is inferred from
  Valve's source; the headset was offline, so none of it has been checked on
  a Frame yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:38:14 +10:00
saphidandClaude Opus 5.5 dd9c009206 Install links for websites: frame-control://install
A site can link to frame-control://install?manifest=URL (or ?url=URL) to
install a title with Frame Control. Manifests use FrameDrop's format, so
framedrop.install/v1 is accepted as well as frame-control.install/v1.

- app/install-link.js parses links; main.js registers the scheme (plus
  electron-builder protocols for Info.plist and the .desktop file), takes
  links from open-url, second-instance argv and the first argv, and holds
  them until the page asks for them through preload's onInstallLink.
- ui/frame_webinstall.py checks the URLs (HTTPS only; localhost over http
  only when the link itself is local; no userinfo; every address public,
  rechecked on redirects and pinned for the connection), reads the
  manifest, downloads with a size cap and sha256 check, and dispatch()
  sends .apk to frame_android and .zip/.exe to frame_titles when present.
- server.py adds /api/webinstall/check, start, job and cancel behind the
  existing Host and X-Frame-UI guards; a start needs a one-time id from
  check. Downloads stop on cancel and on shutdown, and leftovers from a
  killed server are swept by PID.
- index.html asks before anything downloads (name, source host, file,
  type, size, whether a sha256 was given) and shows progress.
- docs/web-install.md, docs/install.html (landing page, unpublished).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:18:39 +10:00
saphidandClaude Opus 5.5 770f26c703 Bundle a CA list so HTTPS works from Python on fresh Windows
The bundled Python only trusts roots already in the Windows certificate
store, which Windows fills lazily, so on a new install Steam store
search, F-Droid downloads and the compat DB failed with
CERTIFICATE_VERIFY_FAILED. fetch-deps.js now also bundles curl's pinned
copy of Mozilla's CA list, and the server adds it to the default HTTPS
context on top of the system certificates (before any urlopen, since
urllib keeps the context it first builds).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:12:32 +10:00
saphidandClaude Opus 5.5 a90ffeda5f Pair through the SteamOS devkit service before asking for a password
connect.sh and frame_connect.py now try Valve's steamos-devkit-service
first: GET /properties.json for the login user, then POST /register with
a new RSA key (~/.ssh/id_rsa_frame_devkit, the only type it accepts), so
the user approves a prompt in the headset instead of typing a password.
Port 32000 closed, a timeout or a 403 falls back to the existing
password copy. The Host frame block lists both keys; a host counts as
found if port 22 or 32000 answers; with no host given, dns-sd or
avahi-browse look for _steamos-devkit._tcp. Inferred from Valve's
source, not yet verified on a Frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:03:48 +10:00
saphidandClaude Opus 5.5 2544255825 Chromium XR as a Steam library app
chromium-xr.sh steam adds a non-Steam shortcut through Steam's DevTools
port. Chrome's flags move into frame/chromium-xr/launch.sh, which both the
shortcut and launch run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:54:15 +10:00
saphidandClaude Opus 5.5 1a0e54d8bd Bundle Python and adb so the app needs nothing installed
- app/build/fetch-deps.js downloads a standalone Python 3.12
  (python-build-standalone) for every build and adb from Google's
  platform-tools, pinned by SHA-256, and prunes what the server never
  uses. It replaces the Windows-only embeddable Python.
- The app runs the bundled Python with -I -u -B -X utf8, so a PYTHONHOME
  or PYTHONPATH meant for another Python can't break it and nothing is
  written inside the signed macOS bundle.
- An adb you already have still goes first, so two adb versions don't
  keep restarting each other's server. arm64 Linux has no official
  platform-tools and keeps using the system adb.
- ui/frame_apk.py reads APK badging (package, label, version, min SDK,
  ABIs, icon) from the binary manifest and resources.arsc, replacing
  aapt2. It matches aapt2 on nine F-Droid APKs and finds launcher icons
  the old path missed with adaptive icons.
- The app reads the computer's clipboard through Electron, so Linux no
  longer needs wl-clipboard or xclip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:42:49 +10:00
saphidandClaude Opus 5.5 6fd35a0a78 WebXR Chromium: verified in the headset, including 360 video
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:23:11 +10:00
saphidandClaude Opus 5.5 46043f7e95 WebXR doc: DevTools exposure, seccomp patch scope, link fixes
From the SWE-2 Max review of the Chromium XR results: note that the
unauthenticated DevTools port is tailnet-reachable with userspace
Tailscale, say the SO_PEERCRED patch is inert while launch disables
seccomp, link panel-on-frame.sh to the script, and describe the disk
guard accurately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:58:51 +10:00
saphidandClaude Opus 5.5 5c7ee97cd3 Finish shutdown cleanup even if SIGTERM arrives mid-way
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:32:42 +10:00
Alex Southwell 34ce457332 Merge pull request #1 from saphid/cross-platform
Frame Control 0.3.0: Windows and Linux
2026-09-26 14:25:46 +10:00
saphidandClaude Opus 5.5 8a90e3e34f Don't let the screenshot copy inherit stdin either
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:22:58 +10:00
saphidandClaude Opus 5.5 03729ce950 Fix Windows hangs found against a real Frame
- Child processes never inherit the server's stdin. Under the app it's the pipe
  held open for --exit-on-eof, and Windows' ssh.exe waited on it forever, so
  captures, the screenshot list and Android apps timed out.
- frame_connect's key check accepts a first-seen host key (as the copy step
  does), so an already-authorized key doesn't trigger a password prompt.

Verified on Windows 11, Ubuntu and macOS against a Steam Frame: status,
headset and desktop captures, live video, library, Android apps, screenshots
and upload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:08:25 +10:00
saphidandClaude Opus 5.5 37153f69ae Frame Control 0.3.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 09:57:30 +10:00
saphidandClaude Opus 5.5 52d01bd815 Terminal launcher and setup hardening from the second review
- lxterminal and tilix take the command as one string after -e.
- Refuse quotes and % in Windows terminal commands instead of a bogus escape.
- frame_connect validates FRAME_ALIAS and FRAME_USER before writing ~/.ssh/config.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 09:56:59 +10:00
saphidandClaude Opus 5.5 e210407f31 Fix review findings and Windows setup issues found in testing
- Run the server with -X utf8: the bundled Windows Python ignores PYTHON* variables.
- Quote every argument in Windows terminal commands, so cmd metacharacters are literal.
- frame_connect: accept HOST:PORT, validate input, retry the config swap while
  Windows' ssh.exe holds ~/.ssh/config locked, and don't apply 0o700 on Windows.
- Never use rsync on Windows; unbounded stream queue; validate FRAME_ALIAS;
  more Linux terminals; bundle the window icon; docs and wording fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 09:41:04 +10:00
saphidandClaude Opus 5.5 fc2fa65f0d Run the server in UTF-8 mode on every platform
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 08:56:03 +10:00
saphidandClaude Opus 5.5 2a4a709ced README: keep feature cells on one line
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 08:51:03 +10:00
saphidandClaude Opus 5.5 cfb7465c22 Build the installers on pull requests too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 08:50:14 +10:00
saphidandClaude Opus 5.5 07de29d58a Skip the Frame-side status probe test on Windows; new README and docs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 08:49:06 +10:00
saphidandClaude Opus 5.5 f6e77cd98c WIP: run Frame Control on Linux and Windows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 08:47:00 +10:00
94 changed files with 9696 additions and 695 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Scripts run on the Frame (Linux) and on macOS/Linux: keep LF even in Windows checkouts.
*.sh text eol=lf
*.py text eol=lf
*.js text eol=lf
*.html text eol=lf
*.json text eol=lf
*.md text eol=lf
*.bat text eol=crlf
+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
+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 -2
View File
@@ -27,8 +27,35 @@ jobs:
esac
done
- name: Python compiles
run: python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
run: |
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.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
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.
server-tests:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
python: "3.12"
- os: macos-latest
python: "3.12"
- os: ubuntu-latest
python: "3.13"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Server tests
run: python -m unittest discover -s tests -v
+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);
+61
View File
@@ -0,0 +1,61 @@
name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that touch the app build the same installers as artifacts.
on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
script: dist
files: app/dist/*.dmg app/dist/*.zip
- os: windows-latest
script: dist:win
files: app/dist/*.exe app/dist/*.zip
- os: ubuntu-latest
script: dist:linux
files: app/dist/*.AppImage app/dist/*.deb
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Build
working-directory: app
shell: bash
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber
- uses: actions/upload-artifact@v4
with:
name: frame-control-${{ matrix.os }}
path: |
app/dist/*.dmg
app/dist/*.exe
app/dist/*.zip
app/dist/*.AppImage
app/dist/*.deb
if-no-files-found: ignore
@@ -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,
});
+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.
+164 -210
View File
@@ -1,253 +1,208 @@
# Steam Frame ↔ Mac
<div align="center">
**Frame Control** is a Mac app for managing a Valve Steam Frame (standalone VR
headset: SteamOS 3, Arch-based, arm64, Snapdragon 8 Gen 3) over SSH: live
headset view, battery and status, your Steam library, Android (Lepton) apps,
file and clipboard transfer. This repo also holds the scripts behind it and
field notes on how the Frame's software works, all aimed at as little typing
on the headset's virtual keyboard as possible.
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
It's an unofficial hobby project, not affiliated with Valve.
# Frame Control
Status: written 2026-09-25 and checked against a real Frame the same day
(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app
and most scripts are **verified** on the device. The scripts table below marks
each one, and [docs/open-questions.md](docs/open-questions.md#verified-on-device-2026-09-25)
lists what's still unchecked.
**Manage your Valve Steam Frame from your computer.**<br>
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
## Trying it out
[![Latest release](https://img.shields.io/github/v/release/saphid/steam-frame?label=release&color=1a9fff)](https://github.com/saphid/steam-frame/releases/latest)
[![Platforms](https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-2a475e?label=runs%20on)](#install)
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
You need:
[**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)
- A Steam Frame with **Developer Mode** on (next section; it's a toggle).
- A Mac with Apple Silicon (M1 or later). Tested on macOS 26. There's no Intel
build.
- `python3` on the Mac (`xcode-select --install` provides it).
- Optional: `adb` for Android apps (`brew install android-platform-tools`).
<br>
Steps:
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
1. Download the DMG from the
[latest release](https://github.com/saphid/steam-frame/releases/latest),
open it and drag **Frame Control** to Applications.
2. The app isn't notarized (no paid Apple developer account), so macOS will
say it's damaged or can't be checked. Clear the download quarantine once:
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
3. Open it. With no `frame` SSH alias yet, it offers to run the connection
setup in Terminal. That asks for the Developer Mode password once, then
uses a key from then on.
<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>
**Feedback:** please open a
[GitHub issue](https://github.com/saphid/steam-frame/issues) with what you
tried, your SteamOS build (Steam Settings → System) and the server log
(**Frame → Show Server Log**, at `~/Library/Logs/Frame Control/server.log`).
Features are marked **verified** or not below; the unverified ones are the
most useful to hear about.
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
**What it changes on your Frame:** only what you click. Installs go to your
user account (`--user` Flatpaks, Lepton instances, Steam downloads), and
nothing needs `sudo` except the power buttons. On the Mac it adds a `Host
frame` entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
## Minimum typing on the headset
</div>
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
thing you type on the headset is a password you choose.
---
On the Frame:
## Features
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
2. Scroll down to the **Developer** section and click **Set User Password**.
Type a password. **This is the only thing you type on the headset.** Pick
something short, because you'll type it once more on the Mac and then
never again.
3. (Optional, no typing) Note the IP address from **Quick Settings** or
**Steam Settings → Internet**, in case `frame.local` doesn't resolve.
4. (Optional) Check **Steam Settings → System → Hostname**. Leaving it as
`frame` means the scripts work without any extra setup.
<table>
<tr>
<td width="50%" valign="top">
On the Mac:
**👓 Headset view**<br>
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
To use the scripts from a checkout instead of the app:
</td>
<td width="50%" valign="top">
```sh
git clone https://github.com/saphid/steam-frame.git && cd steam-frame
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
ssh frame # passwordless from now on
```
**🔋 Battery and status**<br>
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
`connect.sh` does four things:
</td>
</tr>
<tr>
<td valign="top">
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
- creates a dedicated key (`~/.ssh/id_ed25519_frame`)
- adds a `Host frame` block to `~/.ssh/config`
- runs `ssh-copy-id`, which asks for the Developer Mode password once
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
logins.
</td>
<td valign="top">
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
(both **confirmed on Steam Frame**, Valve official).
**🤖 Android apps**<br>
About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
Frame's Linux desktop. The script it serves installs your Mac's public key and
enables `sshd`. See [docs/ssh.md](docs/ssh.md#fallback-bootstrap-one-liner).
</td>
</tr>
<tr>
<td valign="top">
## Recommended options
**📁 Files, games and clipboard**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
| Goal | Recommended | Confidence |
</td>
<td valign="top">
**📸 Screenshots**<br>
Browse the shots you take in the headset and save them to your Pictures folder.
</td>
</tr>
<tr>
<td valign="top">
**🧩 Flatpaks and display**<br>
Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
</td>
<td valign="top">
**⚡ One-click tools**<br>
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
</td>
</tr>
</table>
Nothing is installed on the Frame for any of this: the app uses what SteamOS
already ships (sideloading a game copies Valve's own devkit scripts to
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
## Install
| | Download | Needs |
|---|---|---|
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
Details: [docs/ssh.md](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
[docs/file-transfer.md](docs/file-transfer.md),
[docs/open-questions.md](docs/open-questions.md). For how the Frame's software
fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.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.
## Windows anywhere in the room
<details>
<summary><b>macOS: the app isn't notarized</b></summary>
The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
for any Linux app tagged with an app id of its own:
There's no paid Apple developer account behind it, so macOS says the app is
damaged or can't be checked. Drag it to Applications, then clear the download
quarantine once:
```sh
./scripts/panel-on-frame.sh konsole
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
controls to place each panel. See [docs/panels.md](docs/panels.md).
The first time, macOS also asks to allow local network access (for SSH) and
control of Terminal (for the password prompts).
</details>
## Frame Control (Mac app)
<details>
<summary><b>Windows: SmartScreen warning</b></summary>
As of 2026-09-25 no other Mac app manages the Frame end to end.
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
Windows-only. Steam Link views the headset. **Frame Control** is a Mac app over
the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
the same UI in a browser without packaging:
The installer isn't code-signed, so Windows SmartScreen may say it protected
your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
installer: unzip it anywhere and run `Frame Control.exe`.
</details>
<details>
<summary><b>Linux: running the AppImage</b></summary>
```sh
./scripts/frame-ui.sh # opens http://127.0.0.1:47810 in its own window
chmod +x Frame-Control-linux-*.AppImage && ./Frame-Control-linux-*.AppImage
```
![Frame Control](docs/img/frame-control.png)
If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
or run it with `--appimage-extract-and-run`.
</details>
- **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**
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer fits the whole frame; zoom with − / + (or scroll, or
double-click), drag to pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots` API through Python `ctypes`
(`ui/frame_vrshot.py`). Nothing extra is installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel**
captures gamescope's flat layer instead.
- Battery with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature
- Storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR, the desktop,
Lepton and xrdp are running
- Library shelf with Steam cover art and a Play button (`steam://rungameid`)
- **Get games**: every game you own with its Steam Frame rating (Verified,
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
with live progress. Search the Steam store with prices and Frame ratings; Buy
opens the store page in your browser, or Store on Frame opens it in the
headset. It drives the Frame's own Steam client through its DevTools port;
see `docs/steam-games.md`
- Volume and mute (`wpctl`)
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
one with a click as its own Lepton instance (it keeps its data and shows in the
Steam library), then launch, stop, test or remove it. **Report an APK** records whether any APK
worked (F-Droid or not: pick a file, type a package, or use an installed app). Your
reports are saved on your Mac and change the verdicts you see. They aren't
uploaded anywhere: the shared database is maintainer-only for now (see
`compat-db/README.md`)
- **Android display**: pick a running Lepton instance (by the app in it) and set
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all three
back. Whether the settings survive the app relaunching is untested
- Drag and drop files to `~/Downloads`; `.apk` files install as their own Android app
- Send typed text, or the Mac clipboard, to the Frame clipboard
- Install and remove Flatpaks (quick picks: Moonlight, Firefox, VLC, Remmina)
- One-click SSH or SFTP in Terminal, Steam Link, and Windows App (RDP).
Sleep, restart and shut down open Terminal because SteamOS asks for the
sudo password over SSH.
## Set up the headset
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
header, so other websites can't drive it or read captures. It keeps a single multiplexed SSH connection open, so
status and each capture take about 0.3s. Headset captures are deleted from the
Frame as soon as they're copied, because they show everything on screen,
including anything private. The look follows the Steam client: its palette,
Motiva Sans (loaded from Valve's CDN), portrait library capsules and green
Play buttons. **Verified on the Frame 2026-09-25:** status and charging details,
both capture modes (headset view while in use, and a blank frame in standby,
which the UI labels), clipboard, volume, file push, and input validation. **Not yet exercised from the UI:** Launch, Flatpak
install/remove, APK drop, and the power buttons. Each of these calls a
command or script that was verified separately.
You type one password on the headset, once. Everything else happens on your
computer.
### Mac app
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
in the Developer section, **Set User Password**. Pick something short:
you'll type it once more on your computer and then never again.
2. **On your computer:** open Frame Control. It offers to **Set Up
Connection**, which finds the headset, creates an SSH key, and asks for that
password once in a terminal window. If it can't find the Frame, type the
IP address from the Frame's Quick Settings.
`app/` wraps the same UI as a standalone Mac app (Electron). The app bundles
`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`.
It starts `ui/server.py` on a free loopback port and shows it in its own window.
The server stops when you quit the app. A prebuilt DMG for Apple Silicon is
attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
Before asking for the password it tries Valve's SteamOS devkit pairing: in
the headset, open Steam Settings → Developer → **Pair new host** and approve
the request, and no password is needed. (The service and the pairing-mode
step are verified on a Frame; the approval itself isn't yet. See
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
3. That's it. The app now reaches the headset whenever it's awake and on the
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
```sh
cd app
npm install
npm run dist # → app/dist/Frame Control-<version>-arm64.dmg (and a .zip)
npm start # run from the checkout without packaging
```
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
games in `~/devkit-game`), and nothing
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
`~/.ssh/id_rsa_frame_devkit` (the pairing service only takes RSA keys).
Open the DMG and drag **Frame Control** to Applications. You need `python3` on
the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your
login shell, so Homebrew's `rsync` and `adb` work when you launch it from
Finder. Each time it starts while there's no `frame` SSH alias, the app offers
to run `connect.sh` in Terminal. **Frame → Set Up Connection…** does the same
at any time. The Frame menu also shows the server log at
`~/Library/Logs/Frame Control/server.log`. Installing APKs needs `adb`
(`brew install android-platform-tools`). The F-Droid ratings are bundled with the app.
## Feedback
The build is ad-hoc signed and not notarized. A copy you build yourself opens
normally. A copy downloaded from GitHub Releases is quarantined; clear it with
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
time you use them, macOS asks to allow local network access (for SSH) and
control of Terminal (for SSH and power actions). **Verified 2026-09-25:**
installed from the DMG, launched from Finder, connected to the Frame, and
showed live status and the library.
This is a first public test, so reports are really useful, especially from
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:
## Scripts
- 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
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `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](docs/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/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**) |
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](docs/vr-video.md) |
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
Issues and PRs opened directly on GitHub by new contributors are auto-closed
until a maintainer approves them; see [CONTRIBUTING.md](CONTRIBUTING.md).
## Security notes
## Going further
This repo also holds the scripts behind the app and field notes on how the
Frame's software fits together, all checked against a real headset and labelled
**verified** or **inferred**.
| | |
|---|---|
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [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 |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<details>
<summary><b>Security notes</b></summary>
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
@@ -256,7 +211,7 @@ showed live status and the library.
networks only, and turn Developer Mode off when you don't need it.
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
database key lives in the macOS Keychain and is never written to the repo.
database key (maintainer-only) is never written to the repo.
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
you've switched to key auth, a short password still protects `sudo` and
RDP, so pick one that isn't trivially guessable.
@@ -264,19 +219,18 @@ showed live status and the library.
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
**every** Frame port is reachable from your tailnet, including Steam's DevTools
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
</details>
## Development
```sh
python3 -m unittest discover -s tests # server guards, validation, Steam helpers; no headset needed
cd app && npm install && npm run dist # build the DMG
python3 -m unittest discover -s tests # server tests; no headset needed
cd app && npm install && npm start # run the app from the checkout
```
GitHub Actions runs the tests on Python 3.9, which is the oldest `python3` the app
may find (Xcode Command Line Tools), plus syntax checks for every script and the
Electron main process (`.github/workflows/checks.yml`). Anything that touches the
headset is verified by hand against a real Frame, and the docs label it
**verified** or **inferred**.
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
into the release. See [building](docs/frame-control.md#building).
## License
+1
View File
@@ -1,2 +1,3 @@
node_modules/
dist/
build/deps/
+134
View File
@@ -0,0 +1,134 @@
// Downloads what the app bundles so users install nothing else: a standalone
// Python (python-build-standalone), adb (Android platform-tools) and a CA
// bundle. Each goes in build/deps/<os>-<arch>/{python,tools}, which package.json
// copies into the app's resources. Everything is pinned by version and SHA-256.
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const path = require("path");
const { execFileSync } = require("child_process");
const PY = "3.12.14+20260924";
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
const PYTHON = {
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
"linux-arm64": ["aarch64-unknown-linux-gnu", "c8499b61252c433280f134df954464d19811527b31cb920c35fc6967c1222e35"],
};
// Google publishes no arm64 Linux platform-tools; there the app uses the system adb.
const PT = "37.0.1";
const PT_URL = (os) => `https://dl.google.com/android/repository/platform-tools_r${PT}-${os}.zip`;
const TOOLS = {
mac: ["darwin", "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d", ["adb"]],
win: ["win", "45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99",
["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll", "libwinpthread-1.dll"]],
linux: ["linux", "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1", ["adb"]],
};
// Mozilla's CA list, as curl publishes it: Python on Windows only trusts roots
// already in the Windows store (see frame_host.trust_bundled_cas).
const CA = "2026-09-25";
const CA_SHA256 = "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505";
// Parts of Python the server never imports (GUI, tests, packaging, headers).
const PRUNE = [
"include", "share", "Scripts", "libs", "tcl", "lib/pkgconfig", "lib/itcl4", "lib/tcl8", "lib/tcl8.6",
"lib/tk8.6", "lib/thread2.8", "bin/idle3", "bin/idle3.12", "bin/pip", "bin/pip3", "bin/pip3.12",
"bin/pydoc3", "bin/pydoc3.12", "bin/2to3", "bin/2to3-3.12", "bin/python3-config", "bin/python3.12-config",
...["test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "site-packages/pip", "pydoc_data", "venv"]
.flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
];
function get(url, redirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1));
}
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
async function download(url, sha256, file) {
const data = await get(url);
const sum = crypto.createHash("sha256").update(data).digest("hex");
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
fs.writeFileSync(file, data);
}
// Windows' own bsdtar: Git's GNU tar, often first on PATH, reads C:\ as a remote host.
const TAR = process.platform === "win32" ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe") : "tar";
function extract(file, dir) {
fs.mkdirSync(dir, { recursive: true });
// bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
try { execFileSync(TAR, ["-xf", file, "-C", dir]); }
catch (e) {
if (!file.endsWith(".zip")) throw e;
execFileSync("unzip", ["-q", "-o", file, "-d", dir]);
}
fs.rmSync(file);
}
async function fetch(os, arch) {
const key = `${os}-${arch}`;
if (!PYTHON[key]) throw new Error(`no bundle for ${key}`);
const out = path.join(__dirname, "deps", key);
const stamp = path.join(out, ".version");
const version = `python ${PY}, platform-tools ${PT}, CA ${CA}`;
if (fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === version) {
console.log(`${key}: already fetched (${version})`);
return;
}
fs.rmSync(out, { recursive: true, force: true });
fs.mkdirSync(out, { recursive: true });
const [triple, pySha] = PYTHON[key];
const tgz = path.join(out, "python.tar.gz");
await download(PY_URL(triple), pySha, tgz);
extract(tgz, out); // unpacks to python/
for (const p of PRUNE) fs.rmSync(path.join(out, "python", p), { recursive: true, force: true });
const stdlib = path.join(out, "python", "lib", "python3.12"); // macOS and Linux: drop the static libpython
if (fs.existsSync(stdlib)) {
for (const d of fs.readdirSync(stdlib)) {
if (d.startsWith("config-3.12")) fs.rmSync(path.join(stdlib, d), { recursive: true, force: true });
}
}
const tools = path.join(out, "tools");
fs.mkdirSync(tools);
if (!(os === "linux" && arch === "arm64")) {
const [name, ptSha, keep] = TOOLS[os];
const zip = path.join(out, "pt.zip");
const tmp = path.join(out, "pt");
await download(PT_URL(name), ptSha, zip);
extract(zip, tmp);
for (const f of [...keep, "NOTICE.txt", "source.properties"]) {
fs.copyFileSync(path.join(tmp, "platform-tools", f), path.join(tools, f));
}
if (os !== "win") fs.chmodSync(path.join(tools, "adb"), 0o755);
fs.rmSync(tmp, { recursive: true, force: true });
}
await download(`https://curl.se/ca/cacert-${CA}.pem`, CA_SHA256, path.join(tools, "cacert.pem"));
fs.writeFileSync(stamp, version);
console.log(`${key}: ${version} -> ${out}`);
}
(async () => {
const [os, ...archs] = process.argv.slice(2);
if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js <mac|win|linux> <arch>...");
for (const arch of archs) await fetch(os, arch);
})().catch((e) => { console.error(e.message); process.exit(1); });
+33
View File
@@ -0,0 +1,33 @@
// Parses frame-control://install?manifest=URL and frame-control://install?url=URL
// (see docs/web-install.md). Pure, so it runs under plain node for the tests.
// This is only a first filter: ui/frame_webinstall.py applies the full URL rules
// (HTTPS, no private addresses, redirects) before anything is fetched.
const SCHEME = "frame-control";
const MAX_LINK = 4096;
const MAX_URL = 2048;
// {kind: "manifest" | "url", target} or null if raw isn't a usable install link.
function parseInstallLink(raw) {
if (typeof raw !== "string" || raw.length > MAX_LINK || !raw.toLowerCase().startsWith(`${SCHEME}:`)) return null;
let link;
try { link = new URL(raw); } catch { return null; }
// frame-control://install?… puts "install" in the host; accept a trailing slash too.
if (link.protocol !== `${SCHEME}:` || link.hostname !== "install" || !["", "/"].includes(link.pathname)) return null;
const keys = [...new Set(link.searchParams.keys())];
if (keys.length !== 1 || !["manifest", "url"].includes(keys[0])) return null;
const values = link.searchParams.getAll(keys[0]);
if (values.length !== 1) return null;
const target = values[0];
if (!target || target.length > MAX_URL) return null;
let parsed;
try { parsed = new URL(target); } catch { return null; }
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password) return null;
return { kind: keys[0], target };
}
// The link among command-line arguments (Windows and Linux pass it there).
function linkFromArgv(argv) {
return (argv || []).find((a) => typeof a === "string" && a.toLowerCase().startsWith(`${SCHEME}:`)) || null;
}
module.exports = { SCHEME, parseInstallLink, linkFromArgv };
+163 -48
View File
@@ -1,7 +1,7 @@
// Frame Control as a Mac app: starts ui/server.py on a free loopback port and
// shows it in a native window. The server does all the work over the `frame`
// SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -9,14 +9,20 @@ const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const run = promisify(execFile);
// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout.
const IS_MAC = process.platform === "darwin";
const IS_WIN = process.platform === "win32";
// Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const TOOLS = path.join(ROOT, "tools"); // bundled adb and CA certificates
const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts");
const LOG_DIR = path.join(os.homedir(), "Library", "Logs", "Frame Control");
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
: path.join(app.getPath("userData"), "logs");
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
@@ -25,15 +31,18 @@ let server = null;
let url = null;
let win = null;
let quitting = false;
let python = null;
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
// Homebrew's python3, rsync and adb. Take PATH from the login shell instead.
// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare).
// Take PATH from the login shell instead. Windows has no login shell to ask.
// Runs asynchronously so a slow shell profile can't freeze the window.
let cachedPath = null;
async function loginPath() {
if (IS_WIN) return process.env.PATH || "";
if (cachedPath) return cachedPath;
const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh";
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
let fromShell = "";
try {
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
@@ -46,19 +55,44 @@ async function loginPath() {
return joined;
}
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
// -I ignores PYTHON* variables and user site-packages, so a PYTHONHOME or
// PYTHONPATH set for another Python can't break the bundled one. That makes these
// flags stand in for PYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE (no __pycache__
// inside the signed app) and PYTHONUTF8.
const PY_FLAGS = ["-I", "-u", "-B", "-X", "utf8"];
async function findPython(env) {
for (const dir of env.PATH.split(":")) {
const p = path.join(dir, "python3");
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
// The packaged app bundles Python (app/build/fetch-deps.js); a checkout uses PATH.
const candidates = [path.join(ROOT, "python", ...(IS_WIN ? ["python.exe"] : ["bin", "python3"]))];
for (const dir of env.PATH.split(path.delimiter)) {
// The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
for (const name of names) candidates.push(path.join(dir, name));
}
for (const p of candidates) {
try {
fs.accessSync(p, fs.constants.X_OK);
// /usr/bin/python3 is a stub until the Command Line Tools are installed.
await run(p, ["-c", "import http.server"], { timeout: 10000, env });
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
await run(p, [...PY_FLAGS, "-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
{ timeout: 10000, env, windowsHide: true });
return p;
} catch {}
}
return null;
}
async function hasSsh(env) {
try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
}
const PYTHON_HELP = app.isPackaged ? "The bundled Python is missing; reinstall Frame Control."
: "Install Python 3.8 or later, then reopen the app.";
const SSH_HELP = IS_WIN
? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client."
: "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
function freePort() {
return new Promise((resolve, reject) => {
const s = net.createServer();
@@ -80,17 +114,19 @@ function ping(target) {
}
async function startServer() {
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
const python = await findPython(env);
if (!python) {
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
}
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
const port = await freePort();
fs.mkdirSync(LOG_DIR, { recursive: true });
const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
const child = spawn(python, [SERVER, "--port", String(port)], { env, stdio: ["ignore", log, log] });
// stdin stays open while the app runs; the server exits cleanly when it closes.
const child = spawn(python, [...PY_FLAGS, SERVER, "--port", String(port), "--exit-on-eof"],
{ env, stdio: ["pipe", log, log], windowsHide: true });
child.stdin.on("error", () => {});
fs.closeSync(log);
server = child;
let exited = null;
@@ -112,13 +148,20 @@ async function startServer() {
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
child.kill("SIGTERM");
endServer(child);
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
}
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
function endServer(child) {
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
}
function stopServer() {
// server.py handles SIGTERM by closing its shared SSH connection.
if (server) server.kill("SIGTERM");
if (server) endServer(server);
}
function errorPage(message) {
@@ -139,12 +182,12 @@ async function restartServer() {
const old = server;
server = null;
url = null;
if (old) old.kill("SIGTERM");
if (old) endServer(old);
await load();
}
// The page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = `
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
`;
@@ -183,23 +226,76 @@ async function firstRunCheck() {
type: "info",
message: "Connect to your Steam Frame",
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup script: it finds the "
+ "headset, creates a key, and asks for that password once in Terminal.",
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the "
+ "headset, creates a key, and asks for that password once in a terminal window.",
buttons: ["Set Up Connection…", "Later"],
defaultId: 0, cancelId: 1,
});
if (response === 0) setUpConnection();
}
// IPC only from our own page in our own window.
function fromUi(e) {
if (!win || e.sender !== win.webContents || !url || !e.senderFrame) return false;
try {
return new URL(e.senderFrame.url).origin === new URL(url).origin;
} catch { return false; }
}
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch),
// so they wait here until the page asks for them. The page checks the link with
// the server and installs nothing until the user confirms in its dialog.
const pendingLinks = [];
let linkPage = null; // the webContents whose current page is listening
function openInstallLink(raw) {
const req = parseInstallLink(raw);
if (!req) {
app.whenReady().then(() => dialog.showErrorBox("Frame Control can't use this link",
"Install links look like frame-control://install?manifest=https://… or frame-control://install?url=https://…"));
return;
}
pendingLinks.push(req);
if (pendingLinks.length > 5) pendingLinks.shift(); // a page opening links in a loop
deliverLinks();
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
}
function deliverLinks() {
if (!win || !linkPage || linkPage !== win.webContents) return;
while (pendingLinks.length) win.webContents.send("install-link", pendingLinks.shift());
}
ipcMain.on("install-link:ready", (e) => {
if (!fromUi(e)) return;
linkPage = e.sender;
deliverLinks();
});
function registerScheme() {
// A checkout runs as `electron .`, so the OS must be told the script too.
// (macOS takes the scheme from Info.plist, which only the built app has.)
if (process.defaultApp) {
if (process.argv.length >= 2) app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]);
} else {
app.setAsDefaultProtocolClient(SCHEME);
}
}
function createWindow() {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
: { icon: path.join(__dirname, "build", "icon.png") }),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show());
win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
// External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => {
if (/^https?:\/\//.test(target)) shell.openExternal(target);
@@ -208,40 +304,51 @@ function createWindow() {
win.webContents.on("will-navigate", (e, target) => {
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
});
win.on("closed", () => { win = null; });
// A reload or a new page must ask for links again before it gets any.
win.webContents.on("did-start-loading", () => { linkPage = null; });
win.on("closed", () => { win = null; linkPage = null; });
load();
}
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
function runInTerminal(command) {
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
"-e", "activate", "-e", "end tell"], (err) => {
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
});
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
// Developer Mode password there.
async function runInTerminal(argv) {
try {
const env = { ...process.env, PATH: await loginPath() };
const py = python || await findPython(env);
if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`);
await run(py, [...PY_FLAGS, path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv],
{ env, timeout: 15000, windowsHide: true });
} catch (err) {
dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim());
}
}
const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
function setUpConnection() {
runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(path.join(SCRIPTS, "connect.sh"))}`);
async function setUpConnection() {
const alias = `FRAME_ALIAS=${FRAME}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
// A new console inherits our environment on Windows; Linux terminals may not.
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
}
function buildMenu() {
const template = [
{ role: "appMenu" },
...(IS_MAC ? [{ role: "appMenu" }] : []),
{ role: "fileMenu" },
{ role: "editMenu" },
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: setUpConnection },
{ label: "Open SSH in Terminal", click: () => runInTerminal(`ssh ${sh(FRAME)}`) },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) },
...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
],
},
{
@@ -253,7 +360,7 @@ function buildMenu() {
{ type: "separator" }, { role: "togglefullscreen" },
],
},
{ role: "windowMenu" },
...(IS_MAC ? [{ role: "windowMenu" }] : []),
{
role: "help",
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
@@ -265,10 +372,18 @@ function buildMenu() {
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
// macOS delivers install links here, even before the app is ready.
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
// Windows and Linux start a second instance with the link as an argument.
app.on("second-instance", (_e, argv) => {
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
const link = linkFromArgv(argv);
if (link) openInstallLink(link);
});
const firstLink = IS_MAC ? null : linkFromArgv(process.argv);
if (firstLink) openInstallLink(firstLink);
app.whenReady().then(() => {
registerScheme();
buildMenu();
createWindow();
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.2.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.2.0",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+82 -6
View File
@@ -1,16 +1,18 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.2.0",
"description": "Mac app for managing a Valve Steam Frame over SSH",
"version": "0.3.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
"license": "MIT",
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "electron-builder --mac --arm64 --publish never",
"dist:dir": "electron-builder --mac --arm64 --dir"
"dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -19,13 +21,24 @@
"build": {
"appId": "com.saphid.frame-control",
"productName": "Frame Control",
"protocols": [
{
"name": "Frame Control install link",
"schemes": [
"frame-control"
]
}
],
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"main.js",
"package.json"
"preload.js",
"install-link.js",
"package.json",
"build/icon.png"
],
"extraResources": [
{
@@ -51,6 +64,14 @@
"*.py"
]
},
{
"from": "../frame/devkit-utils",
"to": "frame/devkit-utils",
"filter": [
"**/*",
"!**/__pycache__/**"
]
},
{
"from": "../apk-catalog",
"to": "apk-catalog",
@@ -59,6 +80,20 @@
"pins.json",
"site/apps.js"
]
},
{
"from": "build/deps/${os}-${arch}/python",
"to": "python",
"filter": [
"**/*"
]
},
{
"from": "build/deps/${os}-${arch}/tools",
"to": "tools",
"filter": [
"**/*"
]
}
],
"mac": {
@@ -73,7 +108,8 @@
"extendInfo": {
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
}
},
"artifactName": "Frame-Control-mac-${arch}.${ext}"
},
"dmg": {
"title": "Frame Control ${version}"
@@ -82,6 +118,46 @@
"runAsNode": false,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false
},
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Utility",
"icon": "build/icon.png",
"executableName": "frame-control",
"synopsis": "Manage a Valve Steam Frame over SSH",
"artifactName": "Frame-Control-linux-${arch}.${ext}",
"desktop": {
"entry": {
"StartupWMClass": "frame-control"
}
}
},
"deb": {
"depends": [
"openssh-client"
]
},
"win": {
"target": [
"nsis",
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "Frame-Control-Setup-${arch}.${ext}"
}
},
"homepage": "https://github.com/saphid/steam-frame",
"author": {
"name": "saphid",
"email": "4596216+saphid@users.noreply.github.com"
}
}
+17
View File
@@ -0,0 +1,17 @@
// Lets the page read this computer's clipboard through Electron, so sending it
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// 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 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"),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
ipcRenderer.send("install-link:ready");
},
});
+2 -1
View File
@@ -18,7 +18,8 @@ python3 ui/frame_android.py list|launch|stop|remove|probe <package>
Each APK becomes its own app, the way T3 Code is set up (see the instance
section below), instead of going into Lepton Development:
1. `aapt2` reads the package, label, version, ABIs and icon. APKs that need
1. `ui/frame_apk.py` reads the package, label, version, ABIs and icon
(a stdlib parser of the binary manifest and resource table, so no Android SDK). APKs that need
API > 30 or have no `arm64-v8a` build are refused.
2. The APK, `frame/android/lepton-app.sh` (as `launch.sh`), `instance.id`,
`meta.json`, the icon and the `lepton-show-flatscreen` marker go to
+143
View File
@@ -0,0 +1,143 @@
# Frame Control in detail
What each part of the app does, how it works, and what has been checked on a
real Frame. For installing it, see the [README](../README.md#install).
As of 2026-09-25 no other desktop app manages the Frame end to end.
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
Windows-only. Steam Link views the headset.
You can also run the same UI in a browser without the app, from a checkout:
```sh
./scripts/frame-ui.sh # macOS: opens http://127.0.0.1:47810 in its own window
python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
```
## Features
- **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**
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer
fits the whole frame; zoom with − / + (or scroll, or double-click), drag to
pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots`
API through Python `ctypes` (`ui/frame_vrshot.py`). Nothing extra is
installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel** captures
gamescope's flat layer instead.
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
save them to `~/Pictures/SteamFrame`.
- **Battery** with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
the desktop, Lepton and xrdp are running.
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
- **Get games**: every game you own with its Steam Frame rating (Verified,
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
with live progress. Search the Steam store with prices and Frame ratings; Buy
opens the store page in your browser, or Store on Frame opens it in the
headset. It drives the Frame's own Steam client through its DevTools port;
see [steam-games.md](steam-games.md).
- **Volume** and mute (`wpctl`).
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
one with a click as its own Lepton instance (it keeps its data and shows in the
Steam library), then launch, stop, test or remove it. **Report an APK** records
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts
you see. They aren't uploaded anywhere: the shared database is maintainer-only
for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
`adb`, or yours if you have one.
- **Android display**: pick a running Lepton instance (by the app in it) and set
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all
three back. Whether the settings survive the app relaunching is untested.
- **Transfer**: drag and drop files to `~/Downloads`; `.apk` files install as
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
Runtime picked from the program's header), listed under **Sideloaded titles**
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame clipboard.
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
## How it works
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
and shows it in its own window; the server stops when you quit the app. The
app bundles `ui/`, `scripts/`, `frame/android/`, Valve's `frame/devkit-utils/` and the rated catalogue from
`apk-catalog/`, plus a standalone Python
([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
and `adb` from Google's platform-tools, so there's nothing else to install. It
also bundles curl's copy of Mozilla's CA list, because Python on Windows only
trusts root certificates already in the Windows store.
`app/build/fetch-deps.js` downloads both, pinned by SHA-256.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
header, so other websites can't drive it or read captures. Everything reaches
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
multiplexed SSH connection open, so status and each capture take about 0.3 s.
Windows' OpenSSH can't share a connection, so there each request connects on
its own and the app is a little slower. What differs between the three
systems lives in `ui/frame_host.py`.
Headset captures are deleted from the Frame as soon as they're copied, because
they show everything on screen, including anything private. The look follows
the Steam client: its palette, Motiva Sans (loaded from Valve's CDN), portrait
library capsules and green Play buttons.
**Verified on the Frame 2026-09-25 (macOS app):** status and charging details,
both capture modes (headset view while in use, and a blank frame in standby,
which the UI labels), clipboard, volume, file push, and input validation.
**Not yet exercised from the UI:** Launch, Flatpak install/remove, APK drop,
title sideloading (not yet run on a headset at all), and the power buttons. Each of these calls a command that was verified
separately.
## Per-platform notes
**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`
and `adb` are used when you launch it from Finder. Set Up Connection runs
`scripts/connect.sh` in Terminal. The log is at
`~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not
notarized: a downloaded copy is quarantined until you run
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
time you use them, macOS asks to allow local network access (for SSH) and
control of Terminal (for SSH and power actions).
**Windows.** `ssh` is Windows' built-in OpenSSH client
(Settings → System → Optional features, if it's been removed). Set Up
Connection runs `ui/frame_connect.py` in a console window. Copies use `scp`
because Windows has no `rsync`. The installer isn't code-signed, so SmartScreen
warns on first run: choose **More info → Run anyway**. The log is at
`%APPDATA%\Frame Control\logs\server.log`.
**Linux.** Needs `ssh`, which most desktops have; the `.deb` pulls it in.
The arm64 build also needs your distribution's `adb` for Android apps, because
Google publishes no arm64 Linux platform-tools. Set Up Connection runs
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
and others). The log is at
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
`xclip` (X11).
## Building
```sh
cd app
npm install
npm start # run from the checkout without packaging
npm run dist # macOS: dist/*.dmg and .zip (Apple Silicon)
npm run dist:win # Windows: installer and .zip
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
```
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
release (`.github/workflows/release.yml`).
+5 -1
View File
@@ -33,6 +33,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
@@ -44,11 +45,14 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| 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 |
| **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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Install with Frame Control</title>
<!-- Landing page for install links (docs/web-install.md): install.html?manifest=URL
or ?url=URL opens frame-control://install?… and offers the download if the
app doesn't open. Static, no requests of its own. Not published yet. -->
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0d1117; color: #e6edf3;
font: 15px/1.5 -apple-system, "Segoe UI", sans-serif; }
main { max-width: 520px; padding: 32px; }
h1 { font-size: 20px; margin: 0 0 8px; }
p { color: #8b98a8; }
code { color: #e6edf3; overflow-wrap: anywhere; }
a.btn { display: inline-block; margin: 8px 12px 0 0; padding: 9px 16px; border-radius: 3px; text-decoration: none;
background: #2d333b; color: #e6edf3; }
a.btn.go { background: #1a9fff; color: #fff; font-weight: 600; }
.err { color: #ff7b72; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>Install with Frame Control</h1>
<p id="what"></p>
<p id="bad" class="err" hidden>This link doesn't name an https:// manifest or file, so there's nothing to install.</p>
<div id="actions" hidden>
<a class="btn go" id="open">Open in Frame Control</a>
<a class="btn" href="https://github.com/saphid/steam-frame/releases/latest">Get Frame Control</a>
</div>
<p id="missing" hidden>Nothing happened? Frame Control isn't installed on this computer, or is older than the
version that handles install links. Get it, open it once, then use the link again.</p>
</main>
<script>
(() => {
const q = new URLSearchParams(location.search);
const kind = q.has("manifest") ? "manifest" : q.has("url") ? "url" : null;
const target = kind && q.get(kind);
let ok = false;
try {
const u = new URL(target);
const local = ["localhost", "127.0.0.1"].includes(u.hostname);
ok = !u.username && !u.password && (u.protocol === "https:" || (u.protocol === "http:" && local));
} catch {}
if (!ok) { document.getElementById("bad").hidden = false; return; }
const link = `frame-control://install?${kind}=${encodeURIComponent(target)}`;
document.getElementById("what").textContent = `From ${new URL(target).hostname}. Frame Control shows what it will `
+ "install and asks you before downloading anything.";
document.getElementById("open").href = link;
document.getElementById("actions").hidden = false;
// If the app opens, this page loses focus or is hidden; if not, say how to get it.
let left = false;
window.addEventListener("blur", () => { left = true; });
document.addEventListener("visibilitychange", () => { if (document.hidden) left = true; });
setTimeout(() => { if (!left) document.getElementById("missing").hidden = false; }, 2000);
location.href = link;
})();
</script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
# Scripts and headset setup
The command-line side of this repo: how SSH gets set up with as little typing on
the headset as possible, what to use for each job, and the helper scripts that
Frame Control is built on. The scripts are zsh/bash and run on macOS; most also
run on Linux. On Windows, use the app.
## Minimum typing on the headset
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
thing you type on the headset is a password you choose.
On the Frame:
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
2. Scroll down to the **Developer** section and click **Set User Password**.
Type a password. **This is the only thing you type on the headset.** Pick
something short, because you'll type it once more on the Mac and then
never again.
3. (Optional, no typing) Note the IP address from **Quick Settings** or
**Steam Settings → Internet**, in case `frame.local` doesn't resolve.
4. (Optional) Check **Steam Settings → System → Hostname**. Leaving it as
`frame` means the scripts work without any extra setup.
On the Mac:
To use the scripts from a checkout instead of the app:
```sh
git clone https://github.com/saphid/steam-frame.git && cd steam-frame
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
ssh frame # passwordless from now on
```
`connect.sh` does four things:
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
- creates dedicated keys (`~/.ssh/id_ed25519_frame`, plus `~/.ssh/id_rsa_frame_devkit` for pairing)
- adds a `Host frame` block to `~/.ssh/config`
- tries SteamOS devkit pairing (approve on the headset, no password; **inferred**,
see [SSH](ssh.md#password-free-pairing-steamos-devkit-service)), else runs
`ssh-copy-id`, which asks for the Developer Mode password once
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
logins.
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
(both **confirmed on Steam Frame**, Valve official).
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
Frame's Linux desktop. The script it serves installs your Mac's public key and
enables `sshd`. See [docs/ssh.md](ssh.md#fallback-bootstrap-one-liner).
## Recommended options
| Goal | Recommended | Confidence |
|---|---|---|
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
Details: [docs/ssh.md](ssh.md), [docs/streaming.md](streaming.md),
[docs/file-transfer.md](file-transfer.md),
[docs/open-questions.md](open-questions.md). For how the Frame's software
fits together, see [docs/how-the-frame-works.md](how-the-frame-works.md).
## Windows anywhere in the room
The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
for any Linux app tagged with an app id of its own:
```sh
./scripts/panel-on-frame.sh konsole
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
```
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
controls to place each panel. See [docs/panels.md](panels.md).
## Scripts
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `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/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**) |
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](vr-video.md) |
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
+171
View File
@@ -0,0 +1,171 @@
# Sideloading Linux and Windows games
A game you have as files (an itch.io download, your own build, a DRM-free
release) can go into the Frame's Steam library without a Steam store page.
Frame Control uses the same path as Valve's
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit):
the title becomes a Steam **Devkit Game**, with a runtime (Proton or a Steam
Linux Runtime) chosen from the program itself.
For Android APKs, see [apks.md](apks.md) instead.
**Status: nothing here has run on a headset yet.** Every device-side step is
**inferred from Valve's steamos-devkit source** (release v0.20260925.1). The
local steps (reading the zip, picking the program and runtime, building the
request) are covered by `tests/test_frame_titles.py`.
## Using it
Drop a game's `.zip`, folder or `.exe` on **Send to Frame**. (Folders need the
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.
- **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
between Proton Experimental and Proton (stable).
Install copies it to the Frame and registers it with Steam; progress shows in
the bar and the activity log. **Sideloaded titles** lists what's installed,
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
behaviour for a zip that isn't a game.
From a terminal:
```sh
python3 ui/frame_titles.py inspect Game.zip # what would be installed, no headset needed
python3 ui/frame_titles.py install Game.zip [--name N] [--exe REL] [--runtime R]
python3 ui/frame_titles.py list | launch ID | remove ID
```
## Choosing the runtime
The program's header decides, not its file name:
| Program | Runtime (Steam compat tool) | `steam_play` | Confidence |
|---|---|---|---|
| Windows `.exe`, x86-64 (PE machine `0x8664`) | `proton-experimental` | 1 | Inferred: ARM64 Proton runs x86-64 code through FEX |
| Windows `.exe`, 32-bit x86 (`0x14c`) or ARM64 (`0xaa64`) | `proton-experimental` | 1 | Inferred |
| Linux ELF, aarch64 (`e_machine` `0xB7`) | `SteamLinuxRuntime_4-arm64` | 0 | Verified: starts, but natively (see below) |
| Linux ELF, x86-64 (`0x3E`) | `SteamLinuxRuntime_4` | 0 | Verified not to start: the runtime isn't installed (see below) |
| Shell script | the runtime of the Linux binary beside it, else `SteamLinuxRuntime_4-arm64` | 0 | Guess |
| Anything else (32-bit Linux, other CPUs, DLLs, data) | refused with a message | | |
Proton Experimental is the default rather than stable because the Frame's
ARM64 Proton and FEX stack is new and Proton fixes reach Experimental first.
If a game misbehaves, reinstall it with Proton (stable).
The aliases and settings are the ones Valve's client sends: `RUNTIME_ALIASES`
in `devkit_client/__init__.py`, and `gui2._update_game`, which sets
`steam_play=1, steam_play_debug=0, steam_play_debug_version=2019` for Proton
and `steam_play=0` otherwise, plus `compat_tool=<alias>`. Valve's client only
offers `SteamLinuxRuntime_4-arm64` and Lepton when the device reports itself
as Deckard (the Frame).
## Picking the program
`ui/frame_titles.py` reads every file's header: ELF executables (PIE ones are
told from shared libraries by their `PT_INTERP` segment), PE executables (not
DLLs) and scripts with `#!`. A zip with a single top-level folder is treated
as that folder. Candidates are ranked by:
1. Not a helper: names like `UnityCrashHandler64`, `CrashReportClient`,
`*setup*`, `unins*`, `vc_redist*`, `dxsetup`, `*prereq*`, and anything under
`_CommonRedist`, `Redist`, `DirectX` or `Engine` go last.
2. Platform: native ARM64 Linux, then Windows x86-64, then x86-64 Linux, then
other Windows builds.
3. Name: a program named like the zip or folder (build words such as
`-linux-arm64` or `_v1.2` are dropped from the name).
4. Depth, then size: Unreal's top-level `Game.exe` beats
`Game/Binaries/Win64/Game-Win64-Shipping.exe`.
A top-level shell script beats a Linux binary one folder down (`run.sh` +
`bin/game`); a binary next to a script wins. The list in the dialog lets you
pick another.
## What happens on the Frame (inferred)
1. **Tools.** `frame/devkit-utils/` (Valve's scripts, vendored unmodified, MIT)
is copied to `~/devkit-utils`, where Valve's client puts it, unless the
stamp file there already matches. Files are merged, not replaced, so a
newer copy from Valve's client keeps its extra files.
2. **Folder.** `python3 ~/devkit-utils/steamos-prepare-upload --gameid ID`
makes `~/devkit-game/ID` and prints `{user, directory}`.
3. **Copy.** The files go there with `rsync -a --delete` on macOS and Linux,
or `scp -r` into a fresh folder that then replaces it on Windows. Then
`chmod -R 755`, the modes Valve's client gives an upload.
4. **Register.** `python3 ~/devkit-utils/steam-client-create-shortcut --parms JSON`
with `{gameid, directory, argv: [target], env: {}, settings, clear_settings,
force_appid: "", lepton_args: ""}`. It writes `ID-argv.json`,
`ID-env.json` and `ID-settings.json` next to the folder, then sends
`create-shortcut` to the running Steam client over `~/.steam/steam.pipe`
(authenticated by `~/.steam/steam.token`) and waits up to 5 s for Steam's
answer file. Its `error`, for example "The Steam client is not running",
is shown as the install error. The files stay, so installing again with
Steam running finishes the job.
5. **Launch** is `steam-devkit-rpc run-game gameid=ID`. **Remove** is
`steamos-delete --delete-title ID`, which deletes the folder and has Steam
drop shortcuts with no folder. Frame Control then removes the `ID-*.json`
files that Valve's script leaves behind.
Frame Control also writes `~/devkit-game/ID-framecontrol.json` (name, source
file, target, runtime, size). **Sideloaded titles** lists every folder in
`~/devkit-game`, including titles uploaded with Valve's client.
`argv` is one string, as in Valve's client (the start command may carry
arguments), so a program path with spaces is sent in double quotes. How Steam
splits that string is **not checked**.
## Safety
- Zips are unpacked on your computer first. Entries with absolute paths, `..`,
drive letters or `:` anywhere in the path, or links that point outside the
zip (or at a folder they're in) are refused. So are zips over 64 GB
unpacked, over 200,000 entries, more than 200× compressed past 1 GB, or
bigger than the free space.
- No symlink is created while unpacking, so no write can be redirected
through one. A link to a file inside the zip (`libfoo.so.1 → libfoo.so.1.2`)
becomes a copy of that file, which also works on Windows. Links to folders,
loops and dangling links are left out.
- A dropped folder that contains symlinks (or Windows junctions) is copied on your computer first,
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
reserved sideload names (`steam`, `steamvr`, and their `deckard` forms,
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
[frame-control.md](frame-control.md#how-it-works)).
## Checked on a headset
Tested 2026-09-26 on a Frame (BUILD_ID 20260922.6101926) with small static test
programs and PuTTY's official 64-bit `putty.exe`, through both the command line
and the app (inspect, install job, ▶, Remove, and install links):
- [x] `create-shortcut` registers a title; it shows in the Steam library and in
**Sideloaded titles**, and Steam maps it to the chosen compat tool.
- [x] `steam-devkit-rpc run-game` starts it (Steam logs `devkit run-game: started
devkit game "<id>"`), and Remove (`steamos-delete`) deletes the files, the
shortcut and the Proton prefix.
- [x] A quoted path in the start command is fine: Steam runs
`proton waitforexitandrun "/home/steamos/devkit-game/<id>/<exe>"`.
- [x] An x86-64 Windows `.exe` runs under **Proton 11 (stable)** through FEX
(ARM64EC) inside the Steam Linux Runtime 4.0 ARM64 container; PuTTY stayed up.
Proton Experimental wasn't installed at the time (it was downloading), so it's
untested. A Go-built x86-64 test program crashed in `libarm64ecfex.dll`
(a FEX limitation with that program, not the sideloading).
- [ ] **An aarch64 build runs natively, not in `SteamLinuxRuntime_4-arm64`**:
Steam records the mapping (`CompatToolMapping`, `compat_log.txt`) but launches
the devkit title without the runtime's `_v2-entry-point` prefix. Fine for a
self-contained build; a build that needs the runtime's libraries may not start.
- [ ] **An x86-64 Linux build doesn't start**: Steam logs `Tool 4183110 "Steam
Linux Runtime 4.0" is found for appID …, but is not installed`, and the Frame
doesn't install that x86-64 runtime for a devkit title (a `steam://install/4183110`
request did nothing).
- [ ] Whether these titles open as flat panels or need anything VR-specific.
+41 -1
View File
@@ -33,7 +33,8 @@ unless your router's DNS registers DHCP client names.
- **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame
and `frame.local` resolves from the Mac over mDNS.
- `scripts/connect.sh` tries `frame.local`, then `frame`. If neither works, it tells you to re-run it with the IP.
- `scripts/connect.sh` tries `frame.local`, then `frame`, then an mDNS browse for
the devkit service (below). If none works, it tells you to re-run it with the IP.
Once you have a working address, the `Host frame` alias means you just type
`ssh frame`.
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
@@ -55,10 +56,49 @@ Host frame
HostName frame.local
User steamos
IdentityFile ~/.ssh/id_ed25519_frame
IdentityFile ~/.ssh/id_rsa_frame_devkit
IdentitiesOnly yes
ServerAliveInterval 30
```
The script only asks for the password if the pairing below doesn't work.
## Password-free pairing (SteamOS devkit service)
From Valve's source ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service),
[steamos-devkit](https://gitlab.steamos.cloud/devkit/steamos-devkit) client). **Verified on a
Frame 2026-09-26** (BUILD_ID 20260922.6101926): the service runs with Developer Mode
on, `properties.json` answers with `"login": "steamos"`, the headset advertises
`_steamos-devkit._tcp` as `frame`, and `/register` needs pairing mode (below). The
approve prompt and key install are not verified yet. SteamOS's devkit service is
what Valve's Devkit Client uses to pair. `scripts/connect.sh` and
`ui/frame_connect.py` try it first:
- The headset serves HTTP on port **32000** and advertises mDNS
`_steamos-devkit._tcp`. `GET /properties.json` gives the `login` user; the
script uses it as `User` (unless you set `FRAME_USER`, or it says `root`),
for the password fallback too, and keeps it on re-runs.
- **Open Steam Settings → Developer → Pair new host in the headset first.**
Otherwise `/register` answers at once with `403` `"please put the Steam client
in pairing mode: Settings -> Developer -> Pair new host"` (verified). The
scripts say so and keep asking for 2 minutes while you open it.
- `POST /register` with `ssh-rsa <key> <comment> 900b919520e4cf601998a71eec318fec`
(a fixed token from Valve's client) shows an approve prompt inside the
headset naming the comment (`frame-control@<your computer>`). It waits 30 s,
then installs the key for the device user and turns `sshd` on. The reply is
`200 Registered`, or `403` with `{"error": ...}` (declined, timed out, Steam
not running).
- It only accepts **RSA** keys, hence the second key,
`~/.ssh/id_rsa_frame_devkit` (3072-bit).
- A host counts as found if port 22 **or** 32000 answers. With no host given,
and `frame.local`/`frame` unreachable, it browses `_steamos-devkit._tcp` with
`dns-sd` (macOS) or `avahi-browse` (Linux) for a few seconds if installed.
- Port 32000 closed, a timeout, or an error: the script says why and falls back
to copying the ed25519 key with the Developer Mode password, as before.
Anyone on your network can send the request, so only approve a prompt you
started. `curl http://<frame-ip>:32000/properties.json` shows whether the service is up.
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
updates (inferred from Deck; the Frame uses the same A/B image scheme).
+158
View File
@@ -0,0 +1,158 @@
# Install links for websites
A website can put an "Install with Frame Control" button next to its download.
Clicking it opens Frame Control, which shows what the link wants to install and
asks the user. Only after they click **Install** does it download the file and
install it on the Frame.
What's verified: the link parsing, URL rules, manifest parsing, download,
size cap and sha256 check, by `tests/test_webinstall.py` and
`tests/test_server.py` (no network: a stub server on 127.0.0.1). Installing on
the headset is the same code as dropping a file on Frame Control: `.apk` files go
to the APK installer ([apks.md](apks.md)), `.zip` and `.exe` files to the
Linux/Windows title installer. A link hasn't been clicked through to a headset
install yet.
## The link
```
frame-control://install?manifest=<URL-encoded manifest URL>
frame-control://install?url=<URL-encoded file URL>
```
Use `manifest` when you can: it carries the title's name and a sha256, which
Frame Control checks before installing. `url` is for a file on its own; the
dialog then names the title after the file.
The manifest is FrameDrop's format, so one manifest serves both apps. The
schema may be `framedrop.install/v1` or `frame-control.install/v1`:
```json
{
"schema": "framedrop.install/v1",
"name": "My Game",
"files": [
{ "url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "optional-but-better" }
]
}
```
| Field | |
|---|---|
| `schema` | Required, one of the two above |
| `name` | Shown in the confirm dialog (at most 120 characters). Defaults to the file name. APKs are still named in the Steam library by their own label |
| `files` | Exactly one entry for now; more is refused with a message |
| `files[0].url` | Required. The file to install |
| `files[0].sha256` | Optional, 64 hex digits. The download must match or nothing is installed |
| `files[0].size` | Optional (Frame Control extension), bytes. Shown up front; the download must match |
| `files[0].exe` | Optional (Frame Control extension), for a `.zip` title: the program inside it to run |
What gets installed depends on the file name's extension:
| File | Installed as |
|---|---|
| `.apk` | An Android app in its own Lepton instance with a Steam shortcut ([apks.md](apks.md)) |
| `.zip`, `.exe` | A Linux or Windows title. Versions of Frame Control without the title installer say "Linux/Windows titles need a newer Frame Control" |
| anything else | Refused |
## Rules
Frame Control refuses a link, and downloads nothing, unless:
- Every URL (the manifest's, the file's and each redirect) is `https://`.
`http://` works only for `localhost` or `127.0.0.1`, for testing: only when
Frame Control runs with `FRAME_CONTROL_LOCAL_LINKS=1`, and only when the
link itself points there. It's off by default so a website's link can't make
the app fetch from services on your computer, and a public manifest can
never send it there.
- No URL has a user name or password in it (`https://user:pw@…`).
- No host is, or resolves to, a private, loopback, link-local, CGNAT
(100.64.0.0/10), multicast or otherwise non-public address. Every address
the name has must be public, it's checked again on every redirect (at most
5), and the download connects to the address that was checked.
- The file URL ends in a file name with one of the extensions above
(`https://example.com/games/` is refused).
- The manifest is JSON of at most 256 KB, and the file at most 4 GiB
(`MAX_MANIFEST` and `MAX_FILE` in `ui/frame_webinstall.py`).
- The user confirms. The dialog shows the title's name, the site the link came
from (and the file's host if different), the file name and type, the size if
known, and whether a sha256 was given.
A web page can't install anything itself: it can only open the link. Frame
Control's local server refuses requests from web pages, so the only way in is
the operating system handing the link to the app, then the user's click.
## Button for your site
Paste this where the download is, with your manifest's URL in `MANIFEST`:
```html
<a id="frame-control-install" href="#"
style="display:inline-block;padding:10px 18px;border-radius:4px;background:#1a9fff;color:#fff;
font:600 15px -apple-system,'Segoe UI',sans-serif;text-decoration:none">Install with Frame Control</a>
<script>
(() => {
const MANIFEST = "https://example.com/mygame/frame-control.json";
const GET_APP = "https://github.com/saphid/steam-frame/releases/latest";
const button = document.getElementById("frame-control-install");
button.href = "frame-control://install?manifest=" + encodeURIComponent(MANIFEST);
button.addEventListener("click", () => {
// If Frame Control opens, this page loses focus; if it doesn't, offer the download.
let left = false;
const away = () => { left = true; };
window.addEventListener("blur", away, { once: true });
setTimeout(() => {
window.removeEventListener("blur", away);
if (!left && confirm("Frame Control didn't open. Download it?")) location.href = GET_APP;
}, 2000);
});
})();
</script>
```
For a single file, use `"frame-control://install?url=" + encodeURIComponent(FILE_URL)`.
`docs/install.html` is a landing page that does the same from a plain link:
`install.html?manifest=<URL-encoded URL>` tries the app and shows a "Get Frame
Control" link. It isn't published anywhere yet; host a copy to use it.
## Testing locally
Start Frame Control with `FRAME_CONTROL_LOCAL_LINKS=1` in its environment (for
example `FRAME_CONTROL_LOCAL_LINKS=1 npm start` in `app/`), then serve the
manifest and file from your own computer:
```sh
cd mygame && python3 -m http.server 8000
open 'frame-control://install?manifest=http%3A%2F%2Flocalhost%3A8000%2Fmanifest.json' # xdg-open on Linux, start "" on Windows
```
The manifest's file URL must then be `http://localhost:8000/…` or
`http://127.0.0.1:8000/…` too.
## How it works
- `app/install-link.js` parses the link (only `frame-control://install` with
exactly one `manifest` or `url`); `app/main.js` registers the scheme
(`app.setAsDefaultProtocolClient`, and electron-builder's `protocols` for the
macOS Info.plist and the Linux `.desktop` file). macOS delivers links through
`open-url`, Windows and Linux as an argument to a second instance. Links
wait in the main process until the page has loaded and asked for them
(`frameApp.onInstallLink` in `app/preload.js`). `framedrop://` is left alone.
- The page posts the link to `/api/webinstall/check`, which reads the manifest,
applies the rules, asks the file's size with a HEAD request and returns a
one-time id. Nothing is downloaded.
- **Install** posts the id to `/api/webinstall/start`. The server downloads to
a temporary folder (progress at `/api/webinstall/job`, cancellable with
`/api/webinstall/cancel`), checks size and sha256, hands the file to
`frame_webinstall.dispatch()` and deletes the folder.
- The app registers the scheme each time it starts, so the last Frame Control
started (e.g. a development checkout) handles the links.
**Quitting during a stalled download.** On macOS and Linux, quitting stops a
download at once (`shutdown()` on its socket wakes the blocked read). On
Windows that doesn't wake a read in another thread, and closing the handle
under a TLS read isn't safe, so a download that has stalled holds the quit for
the 4-second grace period until the app stops the server; the partial file is
removed on the next start. Downloads that are still moving stop at their next
read either way.
+88 -29
View File
@@ -3,6 +3,12 @@
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
press its VR button, and watch in 3D in the headset.
The build and installer now live in their own public repo,
[saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame):
a build script for an x86-64 Linux host, the SO_PEERCRED patch, and a
Frame-side installer that adds "Chromium XR" to the Steam library. This page
keeps the findings and what was verified on this Frame.
## Why Flathub Chromium can't
**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub
@@ -41,42 +47,95 @@ gets both.
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
## Building it
## Building and installing it
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
90 GB of disk. It shallow-fetches the CL ref, runs `gclient sync --no-history`,
installs the sysroot, builds `chrome` with `symbol_level=0` and proprietary
codecs, and packs `chromium-xr-arm64.tar.xz`. Progress is logged to
`~/chromium-xr/stage`. The build aborts if `/` drops below 12 GB free.
Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build).
In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of
disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and
`frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the
`chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut
through the Steam client's DevTools port, the same way as T3 Code
([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel,
`valve.steam.desktopgame.<appid>`, like any other app.
First run: a 12-core, 31 GB x64 Linux box, started 2026-09-25.
First build, 2026-09-25, on a 12-thread, 31 GB x64 Linux box: 9 h 33 min for
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
takes under a minute, plus about 4 minutes to repack.
## Running it on the Frame
To debug from the Mac, launch it as a panel with DevTools on the Frame
(verified 2026-09-27):
`scripts/panel-on-frame.sh -- '~/.local/bin/chromium-xr' --remote-debugging-port=9223 URL`
([panels.md](panels.md)). DevTools has no authentication. It listens on
loopback, but with the userspace Tailscale from [tailscale.md](tailscale.md)
running, loopback ports are reachable from your tailnet. Close the browser
when you're done. Chromium runs one browser per profile, so close the
Steam-launched one first or the flag is ignored.
[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh):
**The SO_PEERCRED fix.** The XR seccomp policy refuses `getsockopt`. SteamVR's
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
so the XR process died with a seccomp crash (arm64 syscall 209). The patch
allows that one option. It's needed but not enough: the launcher still turns
seccomp off (below), so the patch only matters once that's fixed too.
```sh
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
scripts/chromium-xr.sh launch [URL] # headset desktop, --enable-features=OpenXR
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
```
**Seccomp is off.** The launcher passes `--disable-seccomp-filter-sandbox`.
With the XR seccomp policy on, SteamVR's client reads `/proc/self/status`
through Chrome's file broker and gets the broker's pid. SteamVR then binds
the app to the wrong process ("Unable to init path manager:
VRInitError_Init_Internal") and `xrCreateInstance` fails. The broker can't
answer `/proc/self` for another process, so fixing this needs a change in
Chromium's broker client or in the CL. The namespace sandbox stays on, but
seccomp is off for every process, so use this profile for VR sites rather
than everyday browsing.
It runs natively, not as a Flatpak, so the XR sandbox and SteamVR's IPC work
as the CL expects. It uses its own profile (`~/.config/chromium-xr`) and
DevTools on loopback port 9223, so it doesn't collide with the Flatpak's 9222.
**Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
and the CLs' author are working on Steam Frame support
([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)).
Both sandbox problems above, with the patch, are reported in
[utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7).
**Verified 2026-09-25:**
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
this build):
- Vulkan is there: Turnip (Mesa) on Adreno 750, API 1.4.359.
- Unprivileged user namespaces work (`unshare -Ur true`), so Chromium's
namespace sandbox shouldn't need the setuid `chrome_sandbox`.
- `isSessionSupported('immersive-vr')` is `true`. The WebXR samples page
shows "VR support detected".
- `requestSession('immersive-vr')` succeeds after the prompt. With a WebGL
layer, the first XR frame has a viewer pose with 2 views and a
2880 × 1440 framebuffer (1440 × 1440 per eye).
- SteamVR moves the app from `VRApplication_OpenXRInstance` to
`VRApplication_OpenXRScene` and gives it scene focus. `xrEndFrame` submits
both projection views, and the compositor receives the 2880 × 1440 scene.
- The OpenXR runtime uses Vulkan (`XR_KHR_vulkan_enable2`). Chromium's own GPU
process uses ANGLE on GL, running on zink over Turnip Vulkan (Adreno 750);
Chromium's Vulkan backend is off. That doesn't stop the session.
- Unprivileged user namespaces work (`unshare -Ur true`), so the namespace
sandbox runs without the setuid `chrome_sandbox`.
- **With the headset on** (same day, seccomp sandbox off): the WebXR
samples' Immersive VR Session showed its scene in the headset, and SteamVR
loaded the Frame controller bindings for the app. The three.js
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
a stereo 360 video, played in 3D after pressing Enter VR.
**Unverified (inferred):**
- **Launched from the Steam library, verified remotely 2026-09-27** with
nobody wearing the headset (standby workaround in
[how-the-frame-works.md](how-the-frame-works.md)). The installer's Steam
shortcut starts Chromium, and SteamVR takes it as scene app
`steam.app.<shortcut id>`. A minimal WebXR session that clears every frame
to red ran at about 75 frames per second, and the stereo headset capture
showed both eyes solid red. Steam preloads its overlay
(`gameoverlayrenderer.so`), which crashed Chromium's zygote about 30 s after
a Steam launch. The public repo's launcher now removes it from
`LD_PRELOAD`. With the headset outside its playspace, SteamVR shows
passthrough wherever the page leaves transparent pixels.
- Chromium's GPU process may still fall back from Vulkan to GL on Turnip.
- An immersive session started from a window on the nested desktop may not
hand over cleanly to the SteamVR compositor.
- If the sandbox fails to start, `--no-sandbox` is the fallback for a first
test.
- **Frame rate and input, measured 2026-09-27** (standby workaround, red
test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR
dropped frames only at startup. The right controller showed up as an
`oculus-touch` `tracked-pointer` with an `xr-standard` gamepad and a
25-joint hand, with poses on every frame. A real squeeze reached the page
as `squeezestart`/`squeeze`. Haptics aren't exposed (no actuators).
Details are in the public repo's technical notes.
**Not verified yet:** trigger, thumbstick and face buttons, the left
controller, bare-hand tracking, and third-party VR180 players (DeoVR and
DL8 web embeds).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2022 Valve Software inc., Collabora Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
# Valve's devkit-utils (vendored)
Unmodified copy of `client/devkit-utils/` from Valve's
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit),
MIT licensed (see `LICENSE`; Valve's own notes are in `VALVE-README.md`).
- Source: steamos-devkit, commit `6f0711a` ("Code drop."),
release **v0.20260925.1** (ChangeLog entry dated 2026-09-25).
`ui/frame_titles.py` copies this folder to `~/devkit-utils` on the Frame (where
Valve's own client puts it) and uses `steamos-prepare-upload`,
`steam-client-create-shortcut`, `steam-devkit-rpc` and `steamos-delete` to
register uploaded builds as Steam "Devkit Games". See `docs/sideloading.md`.
To update: copy the folder from a newer checkout over this one, keep this
README, and update the version line above. The stamp Frame Control compares
on the headset is a hash of these files, so a changed copy is re-synced on the
next use.
+4
View File
@@ -0,0 +1,4 @@
These scripts and supporting utility module are uploaded to the devkit by the devkit client:
- steamos-* : scripts that operate (mostly) at SteamOS level for devkit functionality purposes
- steam-client-* : scripts that relay commands to the local running Steam client
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import os
import time
import subprocess
import logging
import argparse
import json
import datetime
import io
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
def main():
parser = argparse.ArgumentParser(description='Capture screenshot and videos on Steam Frame device')
parser.add_argument('--filename', '-f',
default='/tmp/screenshot.png',
help='Output')
parser.add_argument('--timestamp', action='store_true',
help='Add timestamp')
parser.add_argument('--json', action='store_true',
help='Output result as JSON')
args = parser.parse_args()
output_buffer = io.StringIO()
try:
steamvr_path = subprocess.check_output(['steamvr', 'path'], stderr=subprocess.STDOUT, universal_newlines=True).strip()
cdd = os.path.join(steamvr_path, 'bin/linuxarm64')
run_vrcmd = os.path.join(cdd, 'vrcmd')
assert os.path.exists(run_vrcmd), "vrcmd not found"
# Enable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=true']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Wait for the video device to produce frames.
# Note that even when disabled it outputs roughly 2 blank frames per second.
cmd = ['timeout', '1', 'ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '4', '-f', 'null', '-', '-v', 'error']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
retries = 2
while True:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode == 0:
break
retries -= 1
if retries <= 0:
raise Exception("Failed to get video frames from /dev/video99. Is VR active? Is the v4l2 configuration correct?")
output_filename = args.filename
if args.timestamp:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
base, ext = os.path.splitext(output_filename)
output_filename = f"{base}-{timestamp}{ext}"
# Capture screenshot
cmd = ['ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '1', '-q:v', '1', '-y', output_filename]
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Disable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=false']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
except Exception as e:
error_msg = str(e)
# Print collected output to stderr on error
print(output_buffer.getvalue(), file=sys.stderr)
logger.error(error_msg)
if args.json:
result = {
'success': False,
'error': error_msg
}
print(json.dumps(result))
else:
print(f"Error: {error_msg}", file=sys.stderr)
return 1
if args.json:
result = {
'success': True,
'output': output_filename
}
print(json.dumps(result))
else:
print(f"Screenshot saved to {output_filename}")
if __name__ == '__main__':
sys.exit(main())
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python
# encoding: utf-8
"""Utility functions for the Steam client hook scripts"""
import sys
import os
import traceback
import tempfile
import json
import logging
import fcntl
import errno
import contextlib
import time
import fcntl
import logging as logging_module
logger = logging_module.getLogger(__name__)
@contextlib.contextmanager
def wrap_outputs(stderr_prefix):
# capture stderr to file to support debugging
stderr_fd = sys.stderr.fileno()
tf = tempfile.NamedTemporaryFile(
mode='w+',
prefix=stderr_prefix,
delete=True)
if sys.version_info >= (3, 4):
# this API in the os module is only available for python3
# but it does not seem to work with subprocess anyway
os.set_inheritable(tf.file.fileno(), True)
assert os.get_inheritable(tf.file.fileno())
sys.stderr = tf.file
# we can only write out a json response to stdout,
# so redirect stdout to stderr,
# and keep a handle on the original stdout for the response
stdout_fd = os.dup(sys.stdout.fileno())
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
ctx = {}
try:
yield ctx
except:
logger.error(traceback.format_exc())
finally:
tf.flush()
tf.seek(0)
os.write(stderr_fd, tf.read().encode('utf-8'))
if 'ret' in ctx:
os.write(stdout_fd, json.dumps(ctx['ret']).encode('utf-8'))
class SteamClientNotRunningException(Exception):
def __init__(self, error_message):
self.error_message = error_message
def __str__(self):
return self.error_message
def validate_steam_client():
"""Verify that the steam client is running, and permissions are adequate"""
pid_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pid')))
if not os.path.exists(pid_path):
raise SteamClientNotRunningException('{0} does not exist'.format(pid_path))
try:
pid = int(open(pid_path, 'rt').read())
except Exception:
raise SteamClientNotRunningException('{0} is invalid'.format(pid_path))
try:
os.kill(pid, 0)
except OSError:
raise SteamClientNotRunningException('{0} does not refer to a valid process'.format(pid_path))
logger.info('Found steam client pid %s', pid)
def execute_steam_client_command(cmd):
"""Send a command to the steam client over the IPC pipe"""
pipe_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pipe')))
try:
pipe = open(pipe_path, 'wb+', 0)
except IOError:
raise Exception('cannot open steam client pipe')
session_token = open(os.path.expanduser('~/.steam/steam.token')).read()
#pipe_cmd = 'steam://{0}'.format(cmd)
# ^ hack to execute a normal command over the IPC directly - sometimes useful
pipe_cmd = 'devkit-1 steam://devkit-1/{0}/{1}'.format(
session_token,
cmd
)
logger.debug('Sending command line:')
logger.debug(pipe_cmd)
pipe.write('{0}\n'.format(pipe_cmd).encode('utf-8'))
pipe.close()
def save_argv(gameid, argv):
"""Save command line and arguments if provided"""
if argv is None:
return
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "w") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
json.dump(argv, argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for writing: {0}".format(argvfile))
def obtain_argv(gameid, argv):
"""Obtain command line with arguments"""
# If present and not None or [], just return the local arguments
if argv:
return argv
# From here, expect arguments to have been saved previously
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "r") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
argv = json.load(argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for reading: {0}".format(argvfile))
return argv
def save_env(gameid, env):
"""Save environment variables if provided"""
if not env:
return
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "w") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
json.dump(env, envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open env file for writing: {0}".format(envfile))
def obtain_env(gameid):
"""Obtain environment variables for a game, if any were saved"""
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "r") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
env = json.load(envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
return {}
return env
def save_settings(gameid, data):
"""Save settings"""
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-settings.json")
settings = dict()
if data.get('clear_settings', False):
settings = {}
else:
try:
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
except IOError as e:
if (e.errno != errno.ENOENT):
raise
# Merge settings from new json
if 'settings' in data:
settings.update(data['settings'])
try:
with open(settingsfile, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
json.dump(settings, f)
fcntl.flock(f, fcntl.LOCK_UN)
except (IOError):
raise Exception(
"Unable to open settings file for writing: {0}".format(
settingsfile
))
return settings
def load_settings(gameid):
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game", gameid + '-settings.json')
if not os.path.isfile(settingsfile):
return None
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
return settings
class SteamResponse_Timeout(Exception):
pass
class SteamResponse_Error(Exception):
def __init__(self, error_response):
self.error_response = error_response
def __str__(self):
return self.error_response
@contextlib.contextmanager
def wait_on_file_response(path, timeout=5):
"""
The pipe to the Steam Client is one way.
Responses from the Steam Client are written to filesystem.
Protocol is as follows:
- Steam Client creates a 'path.lock' file
- Steam Client writes either 'path' or 'path.error' to indicate a problem
- Steam Client deletes 'path.lock'
- Caller (us) can then read the response
NOTE 1: this function is used as a context manager and will block until a response comes in or timeout.
NOTE 2: the files are created by Steam when responding to a command. If the files already exist the response protocol will break.
"""
lock_path = '{0}.lock'.format(path)
error_path = '{0}.error'.format(path)
max_count = timeout
while True:
time.sleep(1)
if os.path.exists(error_path) or os.path.exists(path) and not os.path.exists(lock_path):
if os.path.exists(error_path):
with open(error_path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
error_response = f.read()
fcntl.flock(f, fcntl.LOCK_UN)
raise SteamResponse_Error(error_response)
with open(path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
success_response = f.read()
yield success_response
fcntl.flock(f, fcntl.LOCK_UN)
return
max_count -= 1
if max_count > 0:
continue
raise SteamResponse_Timeout()
# Setting up as a context manager so we never miss the deletion
# Creating a temporary .lock file to guard the create operation
@contextlib.contextmanager
def create_pid(pid_path):
os.makedirs(os.path.dirname(pid_path), exist_ok=True)
lock_path = '{0}.lock'.format(pid_path)
try:
lock_file = os.open(lock_path, os.O_CREAT | os.O_EXCL)
except IOError as e:
logger.error('cannot create lock file %s for pid file %s', lock_path, pid_path)
logger.error('remove the lock file manually and run again if you are confident no other instance is active')
raise
pid_file = open(pid_path,'w')
pid_file.write(str(os.getpid()))
pid_file.flush()
os.close(lock_file)
os.unlink(lock_path)
try:
yield pid_file
finally:
pid_file.close()
# Assume that's atomic and all is well, no need for another .lock
os.unlink(pid_path)
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import sys
import os
import logging
from urllib.parse import quote_plus as urllib_quote_plus
import json
import tempfile
from . import validate_steam_client
from . import execute_steam_client_command
from . import wait_on_file_response
import logging as logging_module
logger = logging_module.getLogger(__name__)
def resolve_shortcuts():
# make sure there is a steam client online that we can talk to before doing anything
validate_steam_client()
# scan the devkit games
installed_gameids = set([])
devkit_game_path = os.path.expanduser('~/devkit-game')
if not os.path.exists(devkit_game_path):
logger.info('%r does not exist, creating', devkit_game_path)
os.mkdir(devkit_game_path)
entries = sorted(os.scandir(devkit_game_path), key=lambda entry: entry.name)
directories = [e for e in entries if e.is_dir()]
for d in directories:
gameid = d.name
file_names = [f.name for f in entries if f.is_file() and f.name.startswith(gameid)]
has_argv = '{0}-argv.json'.format(gameid) in file_names
has_settings = '{0}-settings.json'.format(gameid) in file_names
if (not has_argv and not has_settings):
logger.info('Subfolder %r in %r is not accompanied by devkit configuration files, ignoring', d.name, devkit_game_path)
continue
logger.info('Found installed Devkit Game: %r', gameid)
installed_gameids.add(gameid)
# ask the Steam Client which Devkit Games are registered
with tempfile.TemporaryDirectory(prefix='list-shortcuts') as tempdir:
response = os.path.join(tempdir, 'shortcuts.json')
cmd = 'list-shortcuts?response={}'.format(
urllib_quote_plus(os.path.join(response))
)
# send the request
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
client_shortcuts = json.loads(response)
logger.debug(client_shortcuts)
assert client_shortcuts['version'] == 2
registered_gameids = set([])
logger.info('Steam Client has %d registered devkit game(s)', len(client_shortcuts['gameids']))
for gameid in client_shortcuts['gameids']:
logger.info('Found Devkit Game registered with Steam Client: %r', gameid)
registered_gameids.add(gameid)
# any registered game that is not found installed on disk needs to be removed
for remove_gameid in registered_gameids - installed_gameids:
with tempfile.TemporaryDirectory(prefix='delete-shortcut') as tempdir:
logger.info('Removing stale registered Devkit Game: %r', remove_gameid)
response = os.path.join(tempdir, 'shortcut-deleted')
cmd = 'delete-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
remove_gameid
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
# any installed game that is not found registered needs to be added
for add_gameid in installed_gameids - registered_gameids:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info('Registering installed Dekit Game: %r', add_gameid)
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}&directory={}'.format(
urllib_quote_plus(response),
add_gameid,
urllib_quote_plus(devkit_game_path)
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
if __name__ == '__main__':
resolve_shortcuts()
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import json
import platform
import tempfile
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--parms', required=True, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
parms = json.loads(conf.parms)
gameid = parms['gameid']
directory = parms['directory']
assert os.path.isdir(directory)
force_appid = parms['force_appid']
steam_appid_path = os.path.join(directory, 'steam_appid.txt')
if force_appid:
with open(steam_appid_path, 'w') as f:
f.write(force_appid + '\n')
logger.info(f'Wrote {steam_appid_path} with AppID {force_appid}')
elif os.path.exists(steam_appid_path):
# don't overwrite an existing steam_appid.txt file from the content tree
# NOTE: if the user sets an AppID through the tool, then delete it, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{steam_appid_path} already exists, leaving it in place')
# Lepton (Android runtime) titles: write UECommandLine.txt next to the .apk
is_lepton = parms['settings']['compat_tool'] == 'lepton'
uecommandline = parms['lepton_args'] if is_lepton else ''
uecommandline_path = os.path.join(directory, 'UECommandLine.txt')
if uecommandline:
with open(uecommandline_path, 'w') as f:
f.write(uecommandline + '\n')
logger.info(f'Wrote {uecommandline_path}')
elif os.path.exists(uecommandline_path):
# don't overwrite an existing UECommandLine.txt file from the content tree
# NOTE: if the user sets cmdline args through the tool, then clears them, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{uecommandline_path} already exists, leaving it in place')
logger.info(f'Updating command line and runtime settings for {gameid} on {platform.node()}')
devkit_utils.save_argv(gameid, parms['argv'])
devkit_utils.save_env(gameid, parms['env'])
devkit_utils.save_settings(gameid, parms)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running. Registration did not complete.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info(f'Registering Devkit Game {gameid} with Steam Client')
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
gameid,
)
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam client did not respond to registration request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import tempfile
import urllib.parse
import re
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('command')
parser.add_argument('args', nargs='*')
conf = parser.parse_args()
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
logger.error(repr(e))
sys.exit(-1)
else:
with tempfile.TemporaryDirectory(prefix='steam-devkit-rpc') as tempdir:
response = os.path.join(tempdir, 'steam-devkit-rpc')
parms = {
'response' : response,
}
for arg in conf.args:
(k, v) = re.split('=', arg)
parms[k] = v
cmd = f'{conf.command}/?{urllib.parse.urlencode(parms)}'
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.info('success')
sys.stdout.write(success_response)
sys.exit(0)
except devkit_utils.SteamResponse_Timeout:
logger.error('timeout')
except devkit_utils.SteamResponse_Error as e:
logger.error('failed')
sys.stdout.write(e.error_response)
sys.exit(-1)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
import sys
import os
import shutil
import logging
import argparse
import subprocess
import devkit_utils.resolve
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--delete-title', required=False, action='store', help='Delete a devkit title by name')
parser.add_argument('--delete-all-titles', required=False, action='store_true', default=False, help='Delete all devkit titles uploaded')
parser.add_argument('--reset-steam-client', required=False, action='store_true', default=False, help='Reset Steam client and delete all local Steam content')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
if conf.delete_all_titles:
subprocess.check_call('rm -rf ~/devkit-game/*', shell=True)
elif conf.delete_title:
gamepath = os.path.expanduser( os.path.join( '~/devkit-game', conf.delete_title ) )
if not os.path.isdir(gamepath):
print(f'Not found: {gamepath}')
else:
subprocess.check_call(f'rm -r {gamepath}', shell=True)
# synchronize the Steam client's view of the devkit games with the on disk state
try:
devkit_utils.resolve.resolve_shortcuts()
except Exception as e:
logger.warning(f'Steam client sync of devkit games failed: {e}')
if conf.reset_steam_client:
# first make sure any sideloaded trampoline has been deleted
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if os.path.exists(devkit_steam_trampoline_path):
os.unlink(devkit_steam_trampoline_path)
# wipe the local Steam install
subprocess.check_call(f'rm -rf ~/.local/share/Steam', shell=True)
# restart the session, which will initiate a reinstall of Steam from the OS client
subprocess.check_call([session_select_command(), 'gamescope'])
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import tempfile
import json
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--appid', required=False, action='store')
parser.add_argument('--gameid', required=False, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='controller-config') as tempdir:
response = os.path.join(tempdir, 'dumpcontrollerconfig')
cmd = f'dumpcontrollerconfig?response={urllib_quote_plus(response)}'
if conf.appid:
cmd += f'&appid={conf.appid}'
if conf.gameid:
cmd += f'&gameid={conf.gameid}'
logger.debug(f'command: {cmd}')
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam did not respond to the command request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
+443
View File
@@ -0,0 +1,443 @@
#!/usr/bin/env python3
import sys
import os
import re
import shutil
import subprocess
import logging
import enum
import argparse
import json
import shlex
import datetime
import pathlib
import socket
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
WIRELESS_DISABLE_POWER_MANAGEMENT = '/usr/bin/steamos-polkit-helpers/steamos-disable-wireless-power-management'
# Must match in gui2.py
class SteamStatus(enum.Enum):
NOT_RUNNING = 0
ERROR = 1
OS = 2
OS_DEV = 3
SIDELOADED = 4
@classmethod
def from_string(cls, status_str):
if not status_str:
return cls.ERROR
try:
if '.' in status_str:
name = status_str.split('.')[-1]
else:
name = status_str
return cls[name]
except KeyError:
return cls.ERROR
@property
def description(self):
DESCRIPTIONS = {
SteamStatus.NOT_RUNNING: 'not running',
SteamStatus.OS: 'OS client',
SteamStatus.OS_DEV: 'OS client dev mode',
SteamStatus.SIDELOADED: 'sideloaded client',
SteamStatus.ERROR: 'error',
}
return DESCRIPTIONS[self]
class SteamConfig(enum.Enum):
ERROR = 1
# Matching the SteamStatus numeric values
OS = 2
OS_DEV = 3
SIDELOADED = 4
@property
def description(self):
DESCRIPTIONS = {
SteamConfig.OS: 'OS client',
SteamConfig.OS_DEV: 'OS client dev mode',
SteamConfig.SIDELOADED: 'sideloaded client',
SteamConfig.ERROR: 'error',
}
return DESCRIPTIONS[self]
SESSION_NAMES = ['gamescope', 'plasma-x11', 'plasma-x11-persistent', 'plasma-wayland', 'plasma-wayland-persistent']
class SessionConfig(enum.IntEnum):
# note: matches SESSION_NAMES indexes
GAMESCOPE = 0
PLASMA_X11 = 1
PLASMA_X11_PERSISTENT = 2
PLASMA_WAYLAND = 3
PLASMA_WAYLAND_PERSISTENT = 4
ERROR = 5
def cef_debugging():
'''Only sane way to check is to look for the listening port.'''
ret = subprocess.run('/usr/bin/ss -l -t -n -p | grep steamwebhelper | grep 8080 > /dev/null', shell=True)
return ( ret.returncode == 0 )
def steam_process_get_path_and_args():
ret = subprocess.run(['pgrep', '-a', '-x', 'steam'], capture_output=True, text=True)
if ret.returncode != 0:
return None
# Proton may run a dummy 'steam' process that confused previous implementations of this logic
# look for a process who's real filename is 'steam'
for l in ret.stdout.splitlines():
try:
pid = int(l.split(' ')[0])
except:
continue
rp = os.path.realpath(f'/proc/{pid}/exe')
if os.path.basename(rp) == 'steam':
try:
with open(f'/proc/{pid}/cmdline', 'rb') as f:
cmdline = f.read()
argv = [a.decode('utf-8', errors='replace') for a in cmdline.split(b'\x00') if a]
if len(argv) >= 2:
path = argv[0]
args = argv[1:]
# strip -srt-logger-opened: injected by steam.sh at runtime
args = [a for a in args if a != '-srt-logger-opened']
return (path, args)
return (argv[0], [])
except Exception as e:
logger.warning(f'Failed to read /proc/{pid}/cmdline: {e}')
return None
def steam_process_get_path():
try:
(path, _) = steam_process_get_path_and_args()
except:
return None
return path
def steam_process_get_args():
try:
(_, args) = steam_process_get_path_and_args()
except:
return ''
return args
def steam_status():
'''What is the status of the Steam client on the system?'''
s = steam_process_get_path()
if s is None:
return SteamStatus.NOT_RUNNING
if s.find('.local/share/Steam/') != -1:
if os.path.exists(os.path.expanduser('~/devkit-game/devkit-steam')):
return SteamStatus.OS_DEV
return SteamStatus.OS
if s.find('devkit-game/steam/') != -1 or s.find('devkit-game/steamdeckard/') != -1:
return SteamStatus.SIDELOADED
logger.warning(f'could not interpret pgrep result to determine steam client status: {s!r}')
return SteamStatus.ERROR
def steam_configuration():
'''How is the Steam client configured to run?'''
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if not os.path.exists(devkit_steam_trampoline_path):
return SteamConfig.OS
t = open(devkit_steam_trampoline_path, 'rt').read()
if t.find('SteamStatus.OS_DEV') != -1:
return SteamConfig.OS_DEV
if t.find('SteamStatus.SIDELOADED') != -1:
return SteamConfig.SIDELOADED
logger.warning(f'could not determine what {devkit_steam_trampoline_path} means to do')
return SteamConfig.ERROR
def osclient_branch(is_deckard):
'''Which branch is the default Steam 'OS client' configured to use?'''
# makes more sense to return strings here
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
return 'default' # not sure that's valid actually - would be the desktop client, which will only run in desktop mode ..
t = open(beta_path, 'rt').readline().strip('\n')
try:
p = 'steamdeck_(.*)'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
# internal builds
p = 'steampal_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
if is_deckard:
p = 'linux_arm64_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
raise Exception('no match')
except: # noqa: E722
logger.warning(f'could not determine the OS client branch config: {t!r}')
return 'error'
def osclient_version(conf):
'''Which version is the Steam 'OS client'?'''
if conf.is_deckard:
# old Steam client was using linuxarm64/, which is now reserved for the SDK binaries
for folder in ('linuxarm64', 'steamrtarm64'):
fn = os.path.expanduser(f'~/.steam/steam/{folder}/builddate.txt')
if os.path.exists(fn):
return open(fn, 'rt').read()
return 'Unknown - no builddate.txt'
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
logger.warning(f'not found: {beta_path}')
return None
t = open(beta_path, 'rt').readline().strip('\n')
manifest = os.path.expanduser(f'~/.steam/steam/package/steam_client_{t}_ubuntu12.manifest')
if not os.path.exists(manifest):
logger.warning(f'not found: {manifest}')
return None
try:
version = int(re.search('"version".*"(.*)"', open(manifest,'rt').read()).group(1))
return version
except:
logger.warning(f'could not parse version out of {manifest}')
return None
def session_config():
'''What is the graphics session configuration?'''
# RESTART_SESSION writes this file
conf_file = '/etc/sddm.conf.d/zz-steamos-autologin.conf'
if not os.path.exists(conf_file):
# fallback to the OS default
conf_file = '/etc/sddm.conf.d/steamos.conf'
if os.path.exists(conf_file):
s = open(conf_file, 'rt').read()
if s.find('plasmawayland.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND_PERSISTENT
if s.find('plasma.desktop') != -1:
return SessionConfig.PLASMA_X11_PERSISTENT
if s.find('gamescope-wayland.desktop') != -1:
return SessionConfig.GAMESCOPE
if s.find('plasma-steamos-oneshot.desktop') != -1:
return SessionConfig.PLASMA_X11
if s.find('plasma-steamos-wayland-oneshot.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND
else:
# if the conf file doesn't exist we are likely in the default config
# check for a running gamescope for sanity
if subprocess.call('pgrep -a -x gamescope', shell=True, stdout=subprocess.DEVNULL) == 0:
return SessionConfig.GAMESCOPE
# couldn't figure it out, halp
return SessionConfig.ERROR
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
def get_os_info():
os_info = {}
try:
for k, v in [ s.split('=') for s in open('/etc/os-release').read().split('\n') if len(s) > 0 ]:
os_info[k] = v.strip('"')
except Exception as e:
logger.error(e)
logger.error('Failed to parse OS release file')
return os_info
def steam_default_args(conf):
if conf.is_deckard:
# Frame currently uses a different setup
return []
try:
if os.path.exists('/usr/lib/steamos/steam-launcher'):
output = subprocess.check_output('cat /usr/lib/steamos/steam-launcher | grep ^steamargs=',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
return ret
except:
logger.warning('Failed to obtain steam default arguments from /usr/lib/steamos/steam-launcher')
# Legacy SteamOS
try:
output = subprocess.check_output('cat /usr/bin/gamescope-session | grep ^steamargs',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
except:
logger.warning('Failed to obtain steam default arguments from /usr/bin/gamescope-session')
# Hardcoded fallback
return ['-steamos3', '-steampal', '-steamdeck', '-gamepadui']
def frame_osclient_extra_args(conf, steam_status):
if not conf.is_deckard or steam_status != SteamStatus.OS:
return None
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
try:
content = open(STEAM_EXTRA_ARGS_FILE, 'rt').read()
match = re.search(r'Environment="STEAM_EXTRA_ARGS=(.*)"', content)
if match:
return match.group(1).replace('\\"', '"')
except Exception as e:
logger.warning(f'Failed to parse steam extra args: {e}')
return None
def user_password_is_set():
ret = subprocess.run('passwd', stdin=subprocess.DEVNULL, shell=True, capture_output=True, universal_newlines=True)
logger.debug(repr(ret))
return (ret.stderr.find('Current password:') != -1)
def steam_launch_flags():
'''Pull various steam flags that affect title execution.'''
ret = {}
if not 'XDG_RUNTIME_DIR' in os.environ:
logger.warning('XDK_RUNTIME_DIR is not set')
return ret
env_folder = os.path.join(os.environ['XDG_RUNTIME_DIR'], 'steam/env')
if not os.path.isdir(env_folder):
return ret
for fn in os.listdir(env_folder):
filepath = os.path.join(env_folder, fn)
content = open(filepath, 'rt').read()
# Check if this is a declaration file with key=value pairs
if content.count('\n') > 1 or '=' in content:
# Parse key=value format with comments
for line in content.splitlines():
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith('#'):
continue
# Parse key=value pairs
if '=' in line:
key, value = line.split('=', 1)
ret[key.strip()] = value.strip()
else:
# Legacy format: filename is the key, file content is the value
ret[fn] = content.strip('\n')
return ret
def renderdoc_replay_server_running():
ret = subprocess.run(['pgrep', '-x', 'renderdoccmd'], capture_output=True)
return ret.returncode == 0
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--json', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
os_info = get_os_info()
assert os_info is not None
conf.is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
os_name = os_info.get('PRETTY_NAME', None)
os_version = os_info.get('BUILD_ID', None)
_steam_launch_flags = steam_launch_flags()
if not conf.is_deckard:
# this bit of cargo cult is Steam Deck only
try:
# disable wireless power management for devkit usage: less latency on commands
subprocess.check_call(WIRELESS_DISABLE_POWER_MANAGEMENT)
except subprocess.CalledProcessError as e:
logger.warning(e)
session_config = session_config()
# enum -> human readable
session_status = SESSION_NAMES[session_config] if session_config != SessionConfig.ERROR else 'error'
steam_status = steam_status()
cef_debugging_enabled = False
if steam_status != SteamStatus.NOT_RUNNING:
cef_debugging_enabled = cef_debugging()
steam_configuration = steam_configuration()
osclient_branch = osclient_branch(conf.is_deckard)
osclient_version = osclient_version(conf)
steam_status_description = steam_status.description
if steam_status in (SteamStatus.OS, SteamStatus.OS_DEV) :
steam_status_description += f', on branch {osclient_branch!r}'
if osclient_version is not None:
if conf.is_deckard:
# we get builddate.txt
steam_status_description += f', {osclient_version}'
else:
utc_date_string = datetime.datetime.fromtimestamp(osclient_version, datetime.UTC).isoformat()
steam_status_description += f', version {osclient_version} {utc_date_string}'
has_side_loaded_client = os.path.exists(
os.path.join(
DEVKIT_TOOL_FOLDER,
'steam'
)
)
_user_password_is_set = user_password_is_set()
_renderdoc_replay_server_running = renderdoc_replay_server_running()
_renderdoc_layer_enabled = _steam_launch_flags.get('ENABLE_VULKAN_RENDERDOC_CAPTURE', '0') == '1'
_hostname = socket.gethostname()
if conf.json:
ret = {
'is_deckard': conf.is_deckard,
'hostname': _hostname,
'os_name': os_name,
'os_version': os_version,
'os_info': os_info,
'session_status': session_status,
'session_options': SESSION_NAMES,
'session_select': session_select_command(),
'steam_status': str(steam_status),
'cef_debugging_enabled': cef_debugging_enabled,
'steam_status_description': steam_status_description,
'steam_configuration': str(steam_configuration),
'steam_osclient_branch': osclient_branch,
'steam_osclient_version': osclient_version,
'has_side_loaded_client': has_side_loaded_client,
'steam_default_args': steam_default_args(conf),
'steam_current_args': steam_process_get_args(),
'frame_osclient_extra_args': frame_osclient_extra_args(conf, steam_status),
'user_password_is_set': _user_password_is_set,
'steam_launch_flags': _steam_launch_flags,
'renderdoc_layer_enabled': _renderdoc_layer_enabled,
'renderdoc_replay_server_running': _renderdoc_replay_server_running,
}
json.dump(ret, sys.stdout, sort_keys=True, indent=4)
else:
logger.info(f'Hostname : {_hostname}')
logger.info(f'OS : {os_name}')
logger.info(f'OS version : {os_version}')
logger.info(f'Session mode is : {session_status}')
logger.info(f'Session select command : {session_select_command()}')
logger.info(f'Steam client status : {steam_status_description}')
logger.info(f'Steam client args : {steam_process_get_args()!r}')
logger.info(f"Steam extra args (Frame) : {frame_osclient_extra_args(conf, steam_status)!r}")
logger.info(f"Steam CEF debug : {'enabled' if cef_debugging_enabled else 'disabled'}")
logger.info(f'Steam client config : {steam_configuration.description}')
logger.info(f'Steam OS client branch : {osclient_branch}')
logger.info(f'Steam OS client version : {osclient_version}')
logger.info(f"Sideloaded client : {'available' if has_side_loaded_client else 'not installed'}")
logger.info(f'OS client arguments : {steam_default_args(conf)!r}')
logger.info(f"User password is set : {'yes' if _user_password_is_set else 'no'}")
logger.info(f"Steam launch flags : {_steam_launch_flags}")
logger.info(f"RenderDoc layer enabled : {'yes' if _renderdoc_layer_enabled else 'no'}")
logger.info(f"RenderDoc replay running : {'yes' if _renderdoc_replay_server_running else 'no'}")
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import getpass
import json
from subprocess import DEVNULL
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = []
if os.path.isdir(DEVKIT_TOOL_FOLDER):
for filename in os.listdir(DEVKIT_TOOL_FOLDER):
gamefolder = os.path.join(DEVKIT_TOOL_FOLDER, filename)
if os.path.isdir(gamefolder):
ret.append( {
'gameid': filename,
} )
print(json.dumps(ret))
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import getpass
import json
import shutil
import subprocess
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--restart-steam', required=False, default='0', action='store')
parser.add_argument('--use-mask-unmask', required=False, default='0', action='store')
parser.add_argument('--prevent-auto-repair', required=False, default='0', action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
directory = os.path.join(
os.path.expanduser(DEVKIT_TOOL_FOLDER),
conf.gameid
)
os.makedirs(directory, exist_ok=True)
INHIBIT_SENTINEL = os.path.expanduser('~/.config/inhibit-short-session-tracker')
if int(conf.prevent_auto_repair) == 1:
open(INHIBIT_SENTINEL, 'w').close()
logger.info(f'Created sentinel file: {INHIBIT_SENTINEL}')
elif os.path.exists(INHIBIT_SENTINEL):
os.remove(INHIBIT_SENTINEL)
logger.info(f'Removed sentinel file: {INHIBIT_SENTINEL}')
ret = {
'user': getpass.getuser(),
'directory': directory,
}
print(json.dumps(ret))
@@ -0,0 +1,7 @@
#!/bin/bash
# meant to be executed remotely/interactively for password prompts
# there's some annoying trash at the top of the remote ssh screen
clear
passwd
sleep 2
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import enum
import subprocess
import shutil
import pathlib
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
# NOTE: only relevant to Frame + OS client with extra arguments
# sideloaded client on Frame supports full command line edit instead
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
class SteamStatus(enum.Enum):
# Supported values from steamos-get-status
OS = 2
OS_DEV = 3
SIDELOADED = 4
STATUS_STRINGS = [
( SteamStatus.OS, 'SteamStatus.OS' ),
( SteamStatus.OS_DEV, 'SteamStatus.OS_DEV' ),
( SteamStatus.SIDELOADED, 'SteamStatus.SIDELOADED' ),
]
# gamescope-session passes the execution to this script if it exists rather than start steam itself
DEVKIT_STEAM_TRAMPOLINE = os.path.expanduser('~/devkit-game/devkit-steam')
# this script executes the sideloaded Steam client (part of the steamos-devkit-service package)
SIDE_LOADED_STEAM_CLIENT = '/usr/share/steamos-devkit/bin/devkit-standalone.py'
def write_trampoline(text):
with open(DEVKIT_STEAM_TRAMPOLINE, 'w') as devkit_steam:
devkit_steam.write(text)
devkit_steam.flush()
os.chmod(DEVKIT_STEAM_TRAMPOLINE, 0o770)
# trying really hard to avoid leaving a zero sized trampoline if the deck is about to hang on the session restart coming next
subprocess.run(['/usr/bin/sync', DEVKIT_TOOL_FOLDER])
def get_os_info():
os_info = {}
try:
for k, v in [ s.split('=') for s in open('/etc/os-release').read().split('\n') if len(s) > 0 ]:
os_info[k] = v.strip('"')
except Exception as e:
logger.error(e)
logger.error('Failed to parse OS release file')
return os_info
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--client', action='store', required=True, choices=[ v[1] for v in STATUS_STRINGS ])
parser.add_argument('--args', action='store', required=False, help='steam client command line arguments')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--gdbserver', action='store_true', required=False)
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
target = [ v for v in STATUS_STRINGS if v[1] == conf.client ][0][0]
logging.info(f'Set steam client on device to {target}')
if os.path.exists(DEVKIT_STEAM_TRAMPOLINE):
os.unlink(DEVKIT_STEAM_TRAMPOLINE)
os_info = get_os_info()
assert os_info is not None
is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
if target == SteamStatus.OS:
if is_deckard:
# conf.args is the extra arguments for the normal Steam 'OS client', update it now
if conf.args is None or conf.args == '':
logger.info('Clearning extra arguments for normal Steam client')
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
os.unlink(STEAM_EXTRA_ARGS_FILE)
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
else:
logger.info(f'Setting extra arguments for normal Steam client: {conf.args}')
os.makedirs(os.path.dirname(STEAM_EXTRA_ARGS_FILE), exist_ok=True)
with open(STEAM_EXTRA_ARGS_FILE, 'wt') as extra_args_file:
escaped_args = conf.args.replace('"', '\\"')
extra_args_file.write(f'[Service]\nEnvironment="STEAM_EXTRA_ARGS={escaped_args}"')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
# When disabling a sideloaded client, also delete the ~/.steam symlinks:
# They will be re-created by the OS client when starting,
# this prevents SteamVR trying to use the sideloaded binaries that are still there for the steam API.
# (this may happen because SteamVR starts before Steam starts and has a chance to set those symlinks correctly)
for path in pathlib.Path(os.path.expanduser('~/.steam')).glob('*'):
if path.is_symlink():
try:
lnk = path.resolve()
if 'devkit-game' in str(lnk):
path.unlink()
print(f'Deleted: {path} -> {lnk}')
except Exception as e:
print(f'Error processing {path}: {e}')
logger.info('Devkit Steam client override is disabled - default OS client execution will resume.')
sys.exit(0)
os.makedirs(os.path.dirname(DEVKIT_STEAM_TRAMPOLINE), exist_ok=True)
# OS client
steam_client = '$HOME/.local/share/Steam/steam.sh'
if target == SteamStatus.SIDELOADED:
steam_client = '$HOME/devkit-game/steam/steam.sh'
if is_deckard:
# RUNSTEAM.sh checks for SIDELOADED_STEAMROOT="${HOME}/devkit-game/steam"
# this is consistent with sideload on Steam Deck, but we use a different name 'steamdeckard'
# will be addressed when reworking the sideload and debug strategy, for now just drop in a symlink
steam_symlink = os.path.expanduser('~/devkit-game/steam')
if os.path.lexists(steam_symlink):
if os.path.islink(steam_symlink):
os.unlink(steam_symlink)
else:
# this happens if an upload in Steam Deck mode was attempted against a Steam Frame for instance
# was an easy mistake to make before recent changes
logger.warning('warning: ~/devkit-game/steam exists but is not a symlink. Removing anyway.')
shutil.rmtree(steam_symlink)
os.symlink(
os.path.expanduser('~/devkit-game/steamdeckard'),
steam_symlink,
)
args = '"$@"'
if conf.args is not None:
args = conf.args
gdbserver = ''
if conf.gdbserver:
logger.info('Configuring for remote debugging via gdbserver')
gdbserver = 'export DEBUGGER="gdbserver 0.0.0.0:2345"'
write_trampoline('''#!/bin/bash
# Generated by steamos-set-steam-client, do not edit!
# configuration tag (do not delete): {}
{}
mkdir -p $HOME/.steam/steam/logs
exec {} {}
'''.format(
conf.client,
gdbserver,
steam_client,
args
))
-79
View File
@@ -1,79 +0,0 @@
#!/bin/bash
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
# (8441736 + 8132979, bug 506004811) so WebXR immersive-vr works on the Frame.
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
# Progress: ~/chromium-xr/stage. Output: ~/chromium-xr/chromium-xr-arm64.tar.xz,
# which scripts/chromium-xr.sh install copies to the Frame.
# Re-running resumes: existing checkout and out/XR are reused.
set -euo pipefail
W=~/chromium-xr
cd "$W"
stage(){ echo "$(date -Is) $*" | tee -a "$W/stage"; }
# Returns non-zero below 12 GB free; set -e turns that into an exit at top level.
guard(){ avail=$(df --output=avail -BG "$W" | tail -n 1 | tr -dc 0-9); if [ "$avail" -lt 12 ]; then stage "ABORT: only ${avail}G free for $W"; return 3; fi; }
[ -d depot_tools ] || git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0
CL_REF=refs/changes/79/8132979/44
if [ ! -f .gclient ]; then
cat > .gclient <<'G'
solutions = [{ "name": "src", "url": "https://chromium.googlesource.com/chromium/src.git",
"managed": False, "custom_deps": {}, "custom_vars": { "checkout_nacl": False } }]
target_os = ["linux"]
target_cpu = ["arm64"]
G
fi
# Keyed on a real commit, so an interrupted first fetch is retried on re-run.
if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
stage "clone src at $CL_REF"
mkdir -p src
[ -d src/.git ] || git -C src init -q
git -C src remote get-url origin >/dev/null 2>&1 || git -C src remote add origin https://chromium.googlesource.com/chromium/src.git
git -C src fetch -q --depth=1 origin "$CL_REF"
git -C src checkout -q FETCH_HEAD
fi
guard
stage "src at $(git -C src log -1 --format='%h %s')"
stage "gclient sync"
gclient sync --nohooks --no-history -D --shallow --revision "src@$(git -C src rev-parse HEAD)" -j 8
guard
stage "runhooks"
gclient runhooks
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
guard
cd src
mkdir -p out/XR
cat > out/XR/args.gn <<'A'
target_os = "linux"
target_cpu = "arm64"
is_debug = false
is_official_build = false
is_component_build = false
dcheck_always_on = false
symbol_level = 0
blink_symbol_level = 0
v8_symbol_level = 0
proprietary_codecs = true
ffmpeg_branding = "Chrome"
use_remoteexec = false
use_siso = true
treat_warnings_as_errors = false
A
stage "gn gen"
gn gen out/XR
gn args out/XR --list=enable_openxr --short | tee -a "$W/stage"
stage "build"
( while sleep 600; do guard || { pkill -u "$(id -u)" -f "siso|ninja"; exit 3; }; done ) &
GUARD=$!
trap 'kill $GUARD 2>/dev/null || true' EXIT
autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler
stage "package"
cd out/XR
files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales)
# GPU libraries aren't produced by every config; pack the ones that exist.
for f in libEGL.so libGLESv2.so libvk_swiftshader.so libvulkan.so.1 vk_swiftshader_icd.json; do
[ -e "$f" ] && files+=("$f")
done
tar -cJf "$W/chromium-xr-arm64.tar.xz" "${files[@]}"
stage "DONE $(ls -la $W/chromium-xr-arm64.tar.xz)"
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: install and launch the WebXR-enabled Chromium build on the Frame.
#
# Flathub Chromium can't enter immersive WebXR on Linux: upstream only wires
# the OpenXR device on Windows (see docs/webxr-chromium.md). This deploys an
# arm64 build with the Linux OpenXR CLs, made on a Linux host by
# scripts/build-chromium-xr.sh, into ~/chromium-xr on the Frame (not a
# Flatpak, so SteamVR's sockets and the XR sandbox work unmodified).
#
# Usage:
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
# scripts/chromium-xr.sh launch [URL] # opens in the headset desktop
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
BUILD_HOST=${BUILD_HOST:-}
BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz}
DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223}
here=${0:A:h}
case "${1:-}" in
install)
tarball=${2:-}
if [[ -z "$tarball" ]]; then
[[ -n "$BUILD_HOST" ]] || { print -u2 "Pass a tarball, or set BUILD_HOST to the build machine"; exit 2; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
tarball=$tmp/chromium-xr-arm64.tar.xz
scp -q "$BUILD_HOST:$BUILD_TARBALL" "$tarball"
fi
ssh "$FRAME_ALIAS" 'rm -rf ~/chromium-xr.new && mkdir -p ~/chromium-xr.new'
ssh "$FRAME_ALIAS" 'tar -xJf - -C ~/chromium-xr.new' < "$tarball"
# Check the new build runs before replacing the old one.
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
;;
launch)
# run-on-frame starts in $HOME on the Frame, so the profile path is relative.
exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \
--user-data-dir=.config/chromium-xr \
--enable-features=OpenXR \
--remote-debugging-port="$DEVTOOLS_PORT" \
"${2:-https://immersive-web.github.io/webxr-samples/}"
;;
check)
# DevTools listens on the Frame's loopback only; evaluate there.
ssh "$FRAME_ALIAS" python3 - "$DEVTOOLS_PORT" <<'EOF'
import json, sys, urllib.request, base64, os, socket, struct
port = int(sys.argv[1])
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=10))
page = next((t for t in tabs if t["type"] == "page"), None)
if page is None:
sys.exit("no open page: run 'chromium-xr.sh launch' first")
path = page["webSocketDebuggerUrl"].split(f":{port}", 1)[1]
s = socket.create_connection(("127.0.0.1", port), timeout=30)
key = base64.b64encode(os.urandom(16)).decode()
s.sendall(f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n".encode())
s.recv(4096)
msg = json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
"expression": "navigator.xr ? navigator.xr.isSessionSupported('immersive-vr') : 'no navigator.xr'",
"awaitPromise": True}}).encode()
mask = os.urandom(4)
hdr = bytes([0x81]) + (bytes([0x80 | len(msg)]) if len(msg) < 126
else bytes([0x80 | 126]) + struct.pack(">H", len(msg)))
s.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(msg)))
buf = b""
reply = None
while reply is None:
chunk = s.recv(65536)
if not chunk:
sys.exit("DevTools closed the connection")
buf += chunk
# Consume every complete frame already buffered before reading again.
while len(buf) >= 2:
n = buf[1] & 0x7F
off = 2
if n == 126:
if len(buf) < 4:
break
n, off = struct.unpack(">H", buf[2:4])[0], 4
elif n == 127:
if len(buf) < 10:
break
n, off = struct.unpack(">Q", buf[2:10])[0], 10
if len(buf) < off + n:
break
frame, buf = buf[off:off + n], buf[off + n:]
msg = json.loads(frame)
if msg.get("id") == 1:
reply = msg
break
print("immersive-vr supported:", reply["result"]["result"].get("value"))
EOF
;;
*) sed -n '2,13p' "$0"; exit 2 ;;
esac
+183 -42
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env zsh
# Mac-side: find the Steam Frame, create a key, add a `Host frame` alias to
# ~/.ssh/config, copy the key, and optionally disable SSH password logins.
# Mac-side: find the Steam Frame, create keys, add a `Host frame` alias to
# ~/.ssh/config, get a key onto the headset, and optionally disable SSH password
# logins. It first pairs through Valve's SteamOS devkit service (port 32000:
# approve on the headset, no password), else copies the key with the password.
#
# Verified on a Frame 2026-09-25 (except --harden). Idempotent: safe to re-run.
# Verified on a Frame 2026-09-25 (except --harden and devkit pairing). Idempotent.
#
# Usage:
# scripts/connect.sh [HOST_OR_IP] # set up key + alias
@@ -11,93 +13,232 @@
# Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame).
set -euo pipefail
user_from_env=${+FRAME_USER}
FRAME_USER=${FRAME_USER:-steamos}
FRAME_ALIAS=${FRAME_ALIAS:-frame}
KEY="$HOME/.ssh/id_ed25519_frame"
# The devkit service only accepts ssh-rsa keys, so pairing uses a second key.
DEVKIT_KEY="$HOME/.ssh/id_rsa_frame_devkit"
CONFIG="$HOME/.ssh/config"
BEGIN_MARK="# >>> steam-frame ($FRAME_ALIAS) >>>"
END_MARK="# <<< steam-frame ($FRAME_ALIAS) <<<"
DEVKIT_PORT=32000
DEVKIT_SERVICE=_steamos-devkit._tcp
MAGIC_PHRASE=900b919520e4cf601998a71eec318fec # fixed token Valve's client appends
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
HOST_RE='^[A-Za-z0-9][A-Za-z0-9.:%-]*$'
harden=0
host_arg=""
for arg in "$@"; do
case "$arg" in
--harden) harden=1 ;;
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
-h|--help) sed -n '2,13p' "$0"; exit 0 ;;
*) host_arg="$arg" ;;
esac
done
port_open() {
# nc resolves through the system resolver (including mDNS for .local).
nc -z -G 3 "$1" 22 >/dev/null 2>&1
nc -z -G 3 "$1" "$2" >/dev/null 2>&1
}
# sshd, or the devkit service, which turns sshd on once a pairing is approved.
reachable() {
port_open "$1" 22 || port_open "$1" $DEVKIT_PORT
}
# What a command printed within $1 seconds; dns-sd never exits by itself.
run_for() {
local secs=$1; shift
"$@" 2>/dev/null &
local pid=$!
sleep "$secs"
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
}
# Hosts advertising the devkit service over mDNS (dns-sd -B, then -L each).
discover_devkit() {
local name target
run_for 3 dns-sd -B $DEVKIT_SERVICE local. \
| sed -n "s/.* Add .*${DEVKIT_SERVICE//./\\.}\\.[[:space:]]*//p" | awk '!seen[$0]++' | head -n 4 \
| while IFS= read -r name; do
target=$(run_for 2 dns-sd -L "$name" $DEVKIT_SERVICE local. \
| sed -n 's/.* can be reached at \([^ :]*\):[0-9].*/\1/p' | head -n 1)
[[ -n "$target" ]] && print -r -- "${target%.}"
done | awk '!seen[$0]++'
}
pick_host() {
local candidates=()
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
local h
for h in "${candidates[@]}"; do
if port_open "$h"; then
if reachable "$h"; then
print -r -- "$h"; return 0
fi
print -u2 " - $h: not resolvable or port 22 closed"
print -u2 " - $h: not resolvable, or ports 22 and $DEVKIT_PORT closed"
done
[[ -n "$host_arg" ]] && return 1
print -u2 " - asking mDNS for $DEVKIT_SERVICE"
for h in ${(f)"$(discover_devkit)"}; do
if [[ "$h" =~ $HOST_RE ]] && reachable "$h"; then
print -r -- "$h"; return 0
fi
print -u2 " - $h: advertised, but not reachable"
done
return 1
}
make_key() { # path type comment [extra ssh-keygen args]
if [[ ! -f "$1" ]]; then
ssh-keygen -q -t "$2" "${@:4}" -N '' -C "$3" -f "$1"
print " created $1"
else
print " exists: $1"
fi
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
write_config() {
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
local tmp
tmp=$(mktemp) || return 1
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
# value it sees per option, so this block must precede any other "Host frame"
# or "Host *". The trailing "Host *" returns the rest of the file to global scope.
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
$0==b {skip=1; next}
$0==e {skip=0; next}
!skip {print}
' "$CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
{
print -r -- "$BEGIN_MARK"
print -r -- "Host $FRAME_ALIAS"
print -r -- " HostName $HOST"
print -r -- " User $FRAME_USER"
print -r -- " IdentityFile $KEY"
print -r -- " IdentityFile $DEVKIT_KEY"
print -r -- " IdentitiesOnly yes"
print -r -- " ServerAliveInterval 30"
print -r -- "Host *"
print -r -- "$END_MARK"
cat "$tmp"
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
rm -f "$tmp"
}
# accept-new: after pairing, this is the first contact, so trust a first-seen host
# key (as ssh-copy-id's prompt would); a changed one still fails.
key_login_works() {
ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new "$FRAME_ALIAS" true 2>/dev/null
}
# The User in our managed block, so a re-run keeps one the headset named earlier.
configured_user() {
[[ -f "$CONFIG" ]] || return 0
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
$0==b {inside=1; next}
$0==e {exit}
inside && $1=="User" {print $2; exit}
' "$CONFIG"
}
devkit_url() {
if [[ "$HOST" == *:* ]]; then print -r -- "http://[$HOST]:$DEVKIT_PORT$1"
else print -r -- "http://$HOST:$DEVKIT_PORT$1"; fi
}
# Valve's steamos-devkit-service: GET /properties.json names the login user; POST
# /register with "ssh-rsa <key> <comment> <magic>" shows an approve prompt in the
# headset (the comment is what it displays, 30 s to answer), then installs the key
# and turns sshd on. Returns non-zero with the reason in $devkit_why to fall back.
devkit_why=""
pair_with_devkit() {
local props login comment body resp code text err
print "==> Pairing through the headset's SteamOS devkit service (no password)"
if [[ ! -r "$DEVKIT_KEY.pub" ]]; then
devkit_why="can't read the pairing key $DEVKIT_KEY.pub"; return 1
fi
if ! props=$(curl -fsS --noproxy '*' -m 5 "$(devkit_url /properties.json)" 2>&1); then
devkit_why="devkit service not reachable on port $DEVKIT_PORT: ${${props##*curl: }%%$'\n'*}"; return 1
fi
# properties.json is Valve's json.dumps(indent=2): "login" sits on its own line.
login=$(print -r -- "$props" | sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
[[ "$login" =~ $NAME_RE && "$login" != root ]] || login=""
# Before the prompt, so the password fallback uses this user too.
if [[ -n "$login" && "$login" != "$FRAME_USER" ]]; then
if (( user_from_env )); then
print " the headset logs in as '$login'; keeping FRAME_USER=$FRAME_USER"
else
FRAME_USER=$login
print " the headset logs in as '$FRAME_USER'"
write_config || { print -u2 "Could not rewrite $CONFIG."; exit 1; }
fi
fi
# One word: the headset splits the body on spaces and shows the third field.
comment="frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')"
[[ "$comment" == "frame-control@" ]] && comment="frame-control@computer"
body="ssh-rsa $(awk '{print $2}' "$DEVKIT_KEY.pub") $comment $MAGIC_PHRASE"
print " In the headset: Steam Settings > Developer > Pair new host, then approve the request"
# The headset refuses at once unless Steam is on its "Pair new host" screen
# (verified on a Frame, 2026-09-26), so keep asking for 2 minutes while it's opened.
local deadline=$(( SECONDS + 120 ))
while true; do
if ! resp=$(print -r -- "$body" | curl -sS --noproxy '*' -m 60 -H 'Content-Type: text/plain' \
--data-binary @- -w '\n%{http_code}' "$(devkit_url /register)" 2>&1); then
devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1
fi
code=${resp##*$'\n'}
text=${resp%$'\n'*}
[[ "$code" == 2* ]] && break
err=$(print -r -- "$text" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
devkit_why="devkit pairing failed: ${err:-${text:-HTTP $code}}"
[[ "$devkit_why" == *"pairing mode"* ]] && (( SECONDS < deadline )) || return 1
sleep 3
done
# The approval is what turns sshd on, so it may take a moment to answer.
local i
for i in {1..10}; do
key_login_works && return 0
sleep 1
done
devkit_why="paired, but key login still fails"; return 1
}
print "==> Looking for the Steam Frame"
if ! HOST=$(pick_host); then
print -u2 "Could not reach the Frame on port 22."
print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT."
print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation."
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
exit 1
fi
print " found: $HOST"
print "==> SSH key"
print "==> SSH keys"
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
if [[ ! -f "$KEY" ]]; then
ssh-keygen -q -t ed25519 -N '' -C "mac->steam-frame" -f "$KEY"
print " created $KEY"
else
print " exists: $KEY"
fi
make_key "$KEY" ed25519 "mac->steam-frame"
make_key "$DEVKIT_KEY" rsa "frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" -b 3072
if (( ! user_from_env )); then
prev_user=$(configured_user)
if [[ "$prev_user" =~ $NAME_RE ]]; then FRAME_USER=$prev_user; fi
fi
print "==> ~/.ssh/config alias '$FRAME_ALIAS' -> $HOST"
touch "$CONFIG" && chmod 600 "$CONFIG"
tmp=$(mktemp)
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
# value it sees per option, so this block must precede any other "Host frame"
# or "Host *". The trailing "Host *" returns the rest of the file to global scope.
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
$0==b {skip=1; next}
$0==e {skip=0; next}
!skip {print}
' "$CONFIG" > "$tmp"
{
print -r -- "$BEGIN_MARK"
print -r -- "Host $FRAME_ALIAS"
print -r -- " HostName $HOST"
print -r -- " User $FRAME_USER"
print -r -- " IdentityFile $KEY"
print -r -- " IdentitiesOnly yes"
print -r -- " ServerAliveInterval 30"
print -r -- "Host *"
print -r -- "$END_MARK"
cat "$tmp"
} > "$CONFIG"
rm -f "$tmp"
write_config
print "==> Checking key login"
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true 2>/dev/null; then
if key_login_works; then
print " key login already works"
elif pair_with_devkit; then
print " paired; key login OK"
else
print " $devkit_why; falling back to the password"
print " copying key (enter the Developer Mode password once)"
ssh-copy-id -i "$KEY.pub" -o IdentitiesOnly=yes "$FRAME_USER@$HOST"
ssh -o BatchMode=yes -o ConnectTimeout=5 "$FRAME_ALIAS" true \
|| { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
key_login_works || { print -u2 "Key login still failing after ssh-copy-id."; exit 1; }
print " key login OK"
fi
+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 }
+54
View File
@@ -0,0 +1,54 @@
<!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 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="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</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

+192
View File
@@ -0,0 +1,192 @@
/* 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 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; } }
/* ---- 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); }
.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; }
.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; }
.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: 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 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

+137
View File
@@ -0,0 +1,137 @@
<!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 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="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</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: 99 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> · macOS · Windows · Linux · iPhone</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="1000" 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: "",
};
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.
+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>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 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">
<details open><summary>The app</summary><div><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></div></details>
<details open><summary>The feedback form</summary><div><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></div></details>
<details open><summary>This website</summary><div><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></div></details>
</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="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</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"
+218
View File
@@ -0,0 +1,218 @@
"""Setup-script checks that need no headset: devkit pairing against a stub of Valve's
steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers.
Run: python3 -m unittest discover -s tests
"""
import json
import socket
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_connect as fc # noqa: E402
PUB = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@old\n"
class StubDevkit(BaseHTTPRequestHandler):
"""Answers like steamos-devkit-service; `reply` picks the /register outcome."""
reply = (200, b"Registered\n")
replies = [] # if set, each /register takes the next one instead of `reply`
properties = {"txtvers": 1, "login": "steamos", "settings": "{}", "devkit1": ["devkit-1"]}
bodies = []
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/properties.json":
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(self.properties).encode())
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
StubDevkit.bodies.append((self.path, self.headers["Content-Type"], body))
code, text = StubDevkit.replies.pop(0) if StubDevkit.replies else self.reply
self.send_response(code)
self.send_header("Content-type", "text/plain")
self.end_headers()
self.wfile.write(text)
class DevkitPairing(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), StubDevkit)
cls.port = cls.server.server_address[1]
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
def setUp(self):
StubDevkit.reply = (200, b"Registered\n")
StubDevkit.bodies = []
StubDevkit.replies = []
self.said = []
self._say, fc.say = fc.say, self.said.append
def tearDown(self):
fc.say = self._say
def test_register_body(self):
body = fc.register_body(PUB, "frame-control@mac")
self.assertEqual(body, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@mac "
"900b919520e4cf601998a71eec318fec\n")
# approve-ssh-key shows split(' ')[2] as the key name.
self.assertEqual(body.split(" ")[2], "frame-control@mac")
with self.assertRaises(ValueError):
fc.register_body("ssh-ed25519 AAAAC3Nz x", "c")
def test_key_comment_is_one_word(self):
self.assertEqual(fc.key_comment("Alex's MacBook Pro.local"), "frame-control@Alex-s-MacBook-Pro")
self.assertEqual(fc.key_comment(""), "frame-control@computer")
self.assertNotIn(" ", fc.key_comment(" a b\nc "))
def test_parse_login(self):
self.assertEqual(fc.parse_login(b'{"login": "steamos", "txtvers": 1}'), "steamos")
for raw in (b'{"txtvers": 1}', b'{"login": "root"}', b'{"login": "x\\nHost *"}', b'{"login": 5}'):
self.assertIsNone(fc.parse_login(raw), raw)
for raw in (b"not json", b"[1]"):
with self.assertRaises(ValueError):
fc.parse_login(raw)
def test_devkit_error(self):
self.assertEqual(fc.devkit_error(403, b'{"error": "Steam is not running"}\n'), "Steam is not running")
self.assertEqual(fc.devkit_error(500, b"install-ssh-key:\nboom"), "install-ssh-key:\nboom")
self.assertEqual(fc.devkit_error(403, b""), "HTTP 403")
def test_pair_ok(self):
logins = []
reason = fc.devkit_pair("127.0.0.1", PUB, "frame-control@test", self.port, logins.append)
self.assertIsNone(reason)
self.assertEqual(logins, ["steamos"])
path, ctype, body = StubDevkit.bodies[0]
self.assertEqual((path, ctype), ("/register", "text/plain"))
self.assertEqual(body.decode(), fc.register_body(PUB, "frame-control@test"))
self.assertTrue(any("Pair new host" in s for s in self.said))
NOT_PAIRING = (403, b'{"error": "devkit approve-ssh-key: please put the Steam client in pairing mode: '
b'Settings -> Developer -> Pair new host"}')
def test_pair_waits_for_pairing_mode(self):
# The headset refuses until Steam is on "Pair new host", then prompts.
StubDevkit.replies = [self.NOT_PAIRING, self.NOT_PAIRING, (200, b"Registered\n")]
sleep, fc.time.sleep = fc.time.sleep, lambda s: None
try:
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port)
finally:
fc.time.sleep = sleep
self.assertIsNone(reason)
self.assertEqual(len(StubDevkit.bodies), 3)
def test_pair_gives_up_without_pairing_mode(self):
StubDevkit.reply = self.NOT_PAIRING
wait, fc.PAIRING_MODE_WAIT = fc.PAIRING_MODE_WAIT, 0
try:
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port)
finally:
fc.PAIRING_MODE_WAIT = wait
self.assertIn("pairing mode", reason)
self.assertEqual(len(StubDevkit.bodies), 1)
def test_pair_refused_falls_back(self):
StubDevkit.reply = (403, b'{"error": "timeout - Steam did not respond to the pairing request"}')
logins = []
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port, logins.append)
self.assertIn("timeout - Steam did not respond", reason)
# The login is still reported, so the password fallback uses the right user.
self.assertEqual(logins, ["steamos"])
def test_pair_without_service_falls_back(self):
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
closed = s.getsockname()[1]
logins = []
reason = fc.devkit_pair("127.0.0.1", PUB, "c", closed, logins.append)
self.assertIn("not reachable", reason)
self.assertEqual((logins, StubDevkit.bodies), ([], []))
def test_pair_times_out(self):
# Accepts the connection but never answers, like a prompt nobody taps.
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
s.listen()
ok, msg = fc.register("127.0.0.1", "x", s.getsockname()[1], timeout=0.5)
self.assertFalse(ok)
self.assertIn("no answer", msg)
class ConfigBlock(unittest.TestCase):
def test_both_keys(self):
block = fc.config_block("frame.local", 22, "steamos")
self.assertEqual(block[0], fc.BEGIN)
self.assertEqual(block[-1], fc.END)
self.assertIn(" User steamos", block)
self.assertNotIn(" Port 22", block)
files = [line for line in block if line.startswith(" IdentityFile")]
self.assertEqual(files, [" IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit"])
self.assertIn(" IdentitiesOnly yes", block)
self.assertEqual(block[-2], "Host *")
self.assertIn(" Port 2222", fc.config_block("10.0.0.5", 2222))
def test_write_config_replaces_block(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
saved = fc.SSH_DIR, fc.CONFIG
fc.SSH_DIR, fc.CONFIG = Path(d), Path(d) / "config"
try:
fc.CONFIG.write_text("Host other\n User me\n", encoding="utf-8")
fc.write_config("frame.local")
self.assertEqual(fc.configured_user(), "steamos")
fc.write_config("10.0.0.5", 22, "deck")
text = fc.CONFIG.read_text(encoding="utf-8")
self.assertEqual(fc.configured_user(), "deck") # not "me" from Host other
finally:
fc.SSH_DIR, fc.CONFIG = saved
self.assertEqual(text.count(fc.BEGIN), 1)
self.assertIn("HostName 10.0.0.5", text)
self.assertIn("User deck", text)
self.assertNotIn("frame.local", text)
self.assertTrue(text.endswith("Host other\n User me\n"))
class MdnsParsers(unittest.TestCase):
def test_dns_sd(self):
browse = ("Browsing for _steamos-devkit._tcp\n"
"Timestamp A/R Flags if Domain Service Type Instance Name\n"
"19:34:35.419 Add 3 15 local. _steamos-devkit._tcp. frame\n"
"19:34:35.611 Add 2 1 local. _steamos-devkit._tcp. frame\n"
"19:34:35.700 Add 2 15 local. _steamos-devkit._tcp. My Frame\n"
"19:34:36.000 Rmv 0 15 local. _steamos-devkit._tcp. gone\n")
self.assertEqual(fc.parse_dns_sd_browse(browse), ["frame", "My Frame"])
resolve = ("Lookup frame._steamos-devkit._tcp.local.\n"
"19:34:44.601 frame._steamos-devkit._tcp.local. can be reached at frame.local.:32000 (interface 15)\n")
self.assertEqual(fc.parse_dns_sd_resolve(resolve), "frame.local")
self.assertIsNone(fc.parse_dns_sd_resolve("Lookup frame\n"))
def test_avahi(self):
out = ('+;wlan0;IPv4;frame;_steamos-devkit._tcp;local\n'
'=;wlan0;IPv6;frame;_steamos-devkit._tcp;local;frame.local;fe80::1;32000;"login=steamos"\n'
'=;wlan0;IPv4;frame;_steamos-devkit._tcp;local;frame.local;192.168.1.50;32000;"login=steamos"\n')
self.assertEqual(fc.parse_avahi(out), ["frame.local", "192.168.1.50"])
if __name__ == "__main__":
unittest.main()
+198
View File
@@ -0,0 +1,198 @@
"""frame_apk against a small APK built here: binary manifest plus resource table."""
import io
import os
import struct
import sys
import tempfile
import tracemalloc
import unittest
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk # noqa: E402
def pool(strings, utf8=False):
"""A ResStringPool chunk."""
data, offsets = b'', []
for s in strings:
offsets.append(len(data))
if utf8:
b = s.encode()
data += bytes([len(s), len(b)]) + b + b'\0'
else:
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
data += b'\0' * (-len(data) % 4)
start = 28 + 4 * len(strings)
body = struct.pack(f'<{len(strings)}I', *offsets) + data
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
package_raw=False drops the package's raw string (as some repackers do);
foreign_label adds a non-android `label` attribute after android:label.
"""
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package, 'junk', 'label'] # the second 'label' has no android id
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
def element(name, attrs):
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
for aname, raw, dtype, value in attrs:
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def resources(values):
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
pkg_body = b''
for (tid, lang, density), entries in values.items():
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
count = max(entries) + 1
offsets, data = [], b''
for i in range(count):
if i in entries:
offsets.append(len(data))
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
else:
offsets.append(0xffffffff)
header = 20 + 64
estart = header + 4 * count
body = struct.pack(f'<{count}I', *offsets) + data
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
pkg = pkg_header + pkg_body
table = pool(strings, utf8=True) + pkg
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
def apk(files):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()
class ApkInfo(unittest.TestCase):
def read(self, data):
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, 'app.apk')
with open(p, 'wb') as f:
f.write(data)
return frame_apk.apk_info(p)
def test_resolves_references(self):
# string type 1: label (entry 0), version (entry 1); mipmap type 2: icon at three densities.
arsc = resources({(1, 'fr', 0): {0: 0}, (1, '', 0): {0: 1, 1: 2},
(2, '', 160): {0: 3}, (2, '', 640): {0: 4}, (2, '', 0xfffe): {0: 5}})
info = self.read(apk({
'AndroidManifest.xml': manifest('com.example.demo', 0x7f010000, 0x7f010001, 26),
'resources.arsc': arsc,
'res/icon_lo.png': b'lo', 'res/icon_hi.png': b'hi', 'res/icon.xml': b'<xml/>',
'lib/arm64-v8a/libx.so': b'', 'lib/x86_64/libx.so': b'',
}))
self.assertEqual(info['package'], 'com.example.demo')
self.assertEqual(info['label'], 'App label') # the default, not French
self.assertEqual(info['version'], '2.1')
self.assertEqual(info['min_sdk'], 26)
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
def test_missing_label_falls_back_to_package(self):
info = self.read(apk({'AndroidManifest.xml': manifest('com.example.bare', 0x7f010000, 0x7f010001, 21)}))
self.assertEqual(info['label'], 'com.example.bare')
self.assertEqual(info['version'], '')
self.assertEqual(info['abis'], [])
def test_repacked_manifest(self):
# Package kept only as a typed value; a foreign `label` mustn't beat android:label.
arsc = resources({(1, '', 0): {0: 1, 1: 2}})
info = self.read(apk({
'AndroidManifest.xml': manifest('com.example.repacked', 0x7f010000, 0x7f010001, 24,
package_raw=False, foreign_label=True),
'resources.arsc': arsc,
}))
self.assertEqual(info['package'], 'com.example.repacked')
self.assertEqual(info['label'], 'App label')
self.assertIsNone(info['icon_png'])
def test_rejects_non_apks(self):
for data in (b'not a zip', apk({'classes.dex': b''}), apk({'AndroidManifest.xml': b'<manifest/>'})):
with self.assertRaises(frame_apk.ApkError):
self.read(data)
def test_refuses_oversized_members(self):
# An APK from a website mustn't make the server inflate gigabytes.
data = apk({'AndroidManifest.xml': manifest('com.example.big', 0x7f010000, 0x7f010001, 21)})
limit, frame_apk.MAX_MANIFEST = frame_apk.MAX_MANIFEST, 16
try:
with self.assertRaises(frame_apk.ApkError):
self.read(data)
finally:
frame_apk.MAX_MANIFEST = limit
def test_forged_sizes_dont_inflate_everything(self):
# The central directory claims 1 byte; the deflated data holds 16 MB of zeros.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('AndroidManifest.xml', bytes(16 * 1024**2))
data = bytearray(buf.getvalue())
for sig, field in ((b'PK\x01\x02', 24), (b'PK\x03\x04', 22)):
at = data.index(sig)
data[at + field:at + field + 4] = struct.pack('<I', 1)
limit, frame_apk.MAX_MANIFEST = frame_apk.MAX_MANIFEST, 1024**2
tracemalloc.start()
try:
with self.assertRaises(frame_apk.ApkError):
self.read(bytes(data))
peak = tracemalloc.get_traced_memory()[1]
finally:
tracemalloc.stop()
frame_apk.MAX_MANIFEST = limit
self.assertLess(peak, 8 * 1024**2)
def test_refuses_compression_android_cant_read(self):
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', method) as z:
z.writestr('AndroidManifest.xml', manifest('com.example.odd', 0x7f010000, 0x7f010001, 21))
with self.assertRaisesRegex(frame_apk.ApkError, 'compression'):
self.read(buf.getvalue())
def test_reference_cycles_and_fan_out_are_bounded(self):
res = frame_apk.Resources(b'')
ref = frame_apk.T_REF
res.entries = {1: [('', 0, ref, 1)] * 5} # five references to itself
self.assertEqual(res.values(1), [])
# Five references at each of five hops: 3125 leaves without a budget.
res.entries = {i: [('', 0, ref, i + 1)] * 5 for i in range(1, 6)}
res.entries[6] = [('', 0, frame_apk.T_STRING, 0)]
self.assertEqual(len(res.values(1)), frame_apk.MAX_VALUES)
# Forty references at each hop round a four-id cycle: millions of dead ends.
looked = []
class Counting(dict):
def get(self, key, default=None):
looked.append(key)
return dict.get(self, key, default)
res.entries = Counting({i: [('', 0, ref, i % 4 + 1)] * 40 for i in range(1, 5)})
self.assertEqual(res.values(1), [])
self.assertLess(len(looked), frame_apk.MAX_STEPS + 10)
if __name__ == '__main__':
unittest.main()
+415
View File
@@ -0,0 +1,415 @@
"""frame_titles without a headset: executable headers, launch targets, zips, runtimes."""
import json
import os
import shutil
import struct
import sys
import tempfile
import unittest
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_titles # noqa: E402
from frame_titles import FrameError # noqa: E402
def elf(machine, e_type=3, interp=True, pad=0):
"""A 64-bit little-endian ELF header plus one program header (PT_INTERP or PT_LOAD)."""
ident = b'\x7fELF' + bytes([2, 1, 1]) + b'\0' * 9
header = ident + struct.pack('<HHIQQQIHHHHHH', e_type, machine, 1, 0, 64, 0, 0, 64, 56, 1, 0, 0, 0)
phdr = struct.pack('<IIQQQQQQ', 3 if interp else 1, 4, 0, 0, 0, 0, 0, 0)
return header + phdr + b'\0' * pad
def pe(machine, dll=False, pad=0):
"""An MZ stub pointing at a PE signature and COFF header."""
mz = b'MZ' + b'\0' * 0x3A + struct.pack('<I', 0x40)
coff = b'PE\0\0' + struct.pack('<HHIIIHH', machine, 1, 0, 0, 0, 0xF0, 0x2022 if dll else 0x0022)
return mz + coff + b'\0' * pad
class Classify(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
def check(self, data, name='f'):
p = os.path.join(self.dir, name)
with open(p, 'wb') as f:
f.write(data)
return frame_titles.classify(p)
def test_elf_machines(self):
self.assertEqual(self.check(elf(0xB7)), {'format': 'elf', 'arch': 'arm64', 'exe': True})
self.assertEqual(self.check(elf(0x3E))['arch'], 'x86_64')
self.assertEqual(self.check(elf(0x3E, e_type=2, interp=False))['exe'], True) # ET_EXEC
def test_shared_library_is_not_a_program(self):
self.assertFalse(self.check(elf(0xB7, interp=False))['exe'])
def test_pe_machines(self):
self.assertEqual(self.check(pe(0x8664)), {'format': 'pe', 'arch': 'x86_64', 'exe': True})
self.assertEqual(self.check(pe(0xAA64))['arch'], 'arm64')
self.assertEqual(self.check(pe(0x14C))['arch'], 'x86')
self.assertFalse(self.check(pe(0x8664, dll=True))['exe'])
def test_lying_headers_are_not_programs(self):
bad = bytearray(elf(0xB7))
struct.pack_into('<HH', bad, 54, 1, 1) # one-byte program header entries
self.assertFalse(self.check(bytes(bad))['exe'])
self.assertIsNone(self.check(b'MZ' + b'\0' * 0x3A + struct.pack('<I', 0xFFFFFFF0)))
def test_scripts_and_data(self):
self.assertEqual(self.check(b'#!/bin/sh\necho hi\n')['format'], 'script')
self.assertIsNone(self.check(b'MZ-not-really'))
self.assertIsNone(self.check(b'just text'))
class Targets(unittest.TestCase):
def tree(self, files):
root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, root)
for rel, data in files.items():
p = os.path.join(root, *rel.split('/'))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, 'wb') as f:
f.write(data)
return root
def plan(self, files, name):
return frame_titles.inspect(self.tree(files), name)
def test_unity_windows_build(self):
# UnityCrashHandler64.exe is bigger than the game's own exe; the name decides.
p = self.plan({'MyGame/MyGame.exe': pe(0x8664, pad=600),
'MyGame/UnityCrashHandler64.exe': pe(0x8664, pad=5000),
'MyGame/UnityPlayer.dll': pe(0x8664, dll=True, pad=9000),
'MyGame/MyGame_Data/Plugins/x86_64/steam_api64.dll': pe(0x8664, dll=True)}, 'MyGame')
self.assertEqual(p['target'], 'MyGame.exe')
self.assertEqual(p['runtime'], 'proton-experimental')
self.assertEqual(p['runtimes'], ['proton-experimental', 'proton-stable'])
crash = next(c for c in p['candidates'] if c['path'] == 'UnityCrashHandler64.exe')
self.assertTrue(crash['skip'])
self.assertNotIn('UnityPlayer.dll', [c['path'] for c in p['candidates']])
def test_unreal_prefers_top_level_bootstrap(self):
p = self.plan({'Game.exe': pe(0x8664, pad=200),
'Game/Binaries/Win64/Game-Win64-Shipping.exe': pe(0x8664, pad=9000),
'Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe': pe(0x8664, pad=9000)}, 'Game')
self.assertEqual(p['target'], 'Game.exe')
def test_installers_lose_to_the_game(self):
p = self.plan({'setup.exe': pe(0x8664, pad=9000), 'unins000.exe': pe(0x14C, pad=9000),
'_CommonRedist/vc_redist.x64.exe': pe(0x8664, pad=9000),
'Tool.exe': pe(0x8664)}, 'Something')
self.assertEqual(p['target'], 'Tool.exe')
def test_arm64_linux_build(self):
p = self.plan({'game.arm64': elf(0xB7, pad=100), 'lib/libfoo.so': elf(0xB7, interp=False, pad=900)}, 'game')
self.assertEqual((p['target'], p['runtime']), ('game.arm64', 'SteamLinuxRuntime_4-arm64'))
self.assertEqual(p['runtimes'], ['SteamLinuxRuntime_4-arm64'])
def test_x86_64_linux_build_warns_it_may_not_start(self):
p = self.plan({'game.x86_64': elf(0x3E)}, 'game')
self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4')
self.assertIn("won't start", p['note'])
def test_native_arm64_beats_x86_64(self):
p = self.plan({'game.x86_64': elf(0x3E, pad=900), 'game.arm64': elf(0xB7)}, 'game')
self.assertEqual(p['target'], 'game.arm64')
def test_top_level_script_beats_nested_binary(self):
p = self.plan({'run.sh': b'#!/bin/sh\nexec bin/game\n', 'bin/game': elf(0xB7)}, 'game')
self.assertEqual(p['target'], 'run.sh')
self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4-arm64')
def test_binary_beside_script_wins(self):
p = self.plan({'start.sh': b'#!/bin/sh\n', 'game': elf(0x3E)}, 'game')
self.assertEqual(p['target'], 'game')
def test_other_architectures_are_refused(self):
with self.assertRaisesRegex(FrameError, 'x86 Linux'):
self.plan({'game': elf(0x03)}, 'game')
with self.assertRaisesRegex(FrameError, 'no Linux or Windows program'):
self.plan({'readme.txt': b'hello'}, 'game')
def test_runtime_override_and_exe_choice(self):
p = self.plan({'A.exe': pe(0x8664), 'B.exe': pe(0x8664)}, 'A')
frame_titles._choose(p, 'B.exe', 'proton-stable')
self.assertEqual((p['target'], p['runtime']), ('B.exe', 'proton-stable'))
with self.assertRaises(FrameError):
frame_titles._choose(p, 'A.exe', 'SteamLinuxRuntime_4-arm64')
with self.assertRaises(FrameError):
frame_titles._choose(p, '../outside.exe')
def test_exe_path_from_above_the_unwrapped_folder(self):
# A manifest names the program as it is in the archive: Game/B.exe, not B.exe.
p = self.plan({'Game/A.exe': pe(0x8664), 'Game/B.exe': pe(0x8664)}, 'Game')
self.assertEqual(p['unwrapped'], 'Game')
frame_titles._choose(p, 'Game/B.exe')
self.assertEqual(p['target'], 'B.exe')
frame_titles._choose(p, 'Game\\A.exe')
self.assertEqual(p['target'], 'A.exe')
with self.assertRaises(FrameError):
frame_titles._choose(p, 'Game/../../outside.exe')
def test_root_relative_path_wins_over_archive_prefix(self):
# Game/Game/A.exe and Game/A.exe: 'Game/A.exe' is a real path under the root Game/.
p = self.plan({'Game/Game/A.exe': pe(0x8664), 'Game/A.exe': pe(0x8664)}, 'Game')
self.assertEqual(p['unwrapped'], 'Game')
frame_titles._choose(p, 'Game/A.exe')
self.assertEqual(p['target'], 'Game/A.exe')
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_prefix_is_taken_before_staging(self):
# A folder with a link is staged into a temporary copy; the prefix still names
# the folders stepped into in the original.
d = self.tree({'Game/A.exe': pe(0x8664)})
os.symlink('A.exe', os.path.join(d, 'Game', 'link.exe'))
p = frame_titles.inspect(d, 'Game')
try:
self.assertEqual(p['unwrapped'], 'Game')
self.assertTrue(p['work'])
finally:
frame_titles.discard(p)
class Zips(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
def zip(self, members, name='Cool Game-v1.2-win64.zip'):
p = os.path.join(self.dir, name)
with zipfile.ZipFile(p, 'w') as z:
for n, data in members.items():
z.writestr(n, data)
return p
def test_wrapper_folder_and_name(self):
plan = frame_titles.inspect(self.zip({'Cool Game/Cool Game.exe': pe(0x8664),
'__MACOSX/Cool Game/._Cool Game.exe': b'x'}))
try:
self.assertEqual(plan['name'], 'Cool Game')
self.assertEqual(plan['id'], 'Cool_Game')
self.assertEqual(plan['target'], 'Cool Game.exe')
self.assertTrue(os.path.isfile(os.path.join(plan['root'], 'Cool Game.exe')))
finally:
frame_titles.discard(plan)
self.assertFalse(os.path.exists(plan['work']))
def test_zip_slip_is_refused(self):
for bad in ('../evil.exe', 'ok/../../evil.exe', '/abs/evil.exe', 'C:/evil.exe', '..\\evil.exe'):
with self.subTest(bad=bad):
out = tempfile.mkdtemp(dir=self.dir)
with self.assertRaisesRegex(FrameError, 'absolute|climbs'):
frame_titles.extract_zip(self.zip({bad: pe(0x8664)}, 'bad.zip'), out)
self.assertFalse(os.path.exists(os.path.join(self.dir, 'evil.exe')))
def test_link_out_of_the_zip_is_refused(self):
p = os.path.join(self.dir, 'link.zip')
with zipfile.ZipFile(p, 'w') as z:
info = zipfile.ZipInfo('game/escape')
info.external_attr = (0o120777 << 16)
z.writestr(info, '../../etc/passwd')
with self.assertRaisesRegex(FrameError, 'outside'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
def link_zip(self, members):
"""members: (name, data, is_link) in order."""
p = os.path.join(self.dir, 'links.zip')
with zipfile.ZipFile(p, 'w') as z:
for name, data, is_link in members:
info = zipfile.ZipInfo(name)
info.external_attr = ((0o120777 if is_link else 0o100644) << 16)
z.writestr(info, data)
return p
def test_chained_links_cannot_escape(self):
# alias -> . ; alias/alias/escape -> ../.. ; escape/victim would land outside if links were real.
p = self.link_zip([('alias', '.', True), ('alias/alias/escape', '../..', True), ('escape/victim', b'x', False)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
self.assertTrue(os.path.isfile(os.path.join(out, 'escape', 'victim'))) # stayed inside
self.assertFalse(os.path.exists(os.path.join(self.dir, 'victim')))
self.assertFalse(os.path.exists(os.path.join(os.path.dirname(self.dir), 'victim')))
for root, dirs, files in os.walk(out):
self.assertFalse([n for n in dirs + files if os.path.islink(os.path.join(root, n))])
def test_folder_links_are_dropped_and_order_does_not_matter(self):
# b -> a/file listed before a -> dir; and a folder link that would contain itself.
p = self.link_zip([('dir/file', b'data', False), ('b', 'a/file', True), ('a', 'dir', True),
('dir/sub/loop', '../../a', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
with open(os.path.join(out, 'b'), 'rb') as f:
self.assertEqual(f.read(), b'data')
self.assertFalse(os.path.lexists(os.path.join(out, 'a')))
self.assertFalse(os.path.lexists(os.path.join(out, 'dir', 'sub', 'loop')))
def test_link_components_resolve_before_parent_steps(self):
# alias -> dirlink/../game.exe, dirlink -> deep/subdir: that's deep/game.exe, not game.exe.
p = self.link_zip([('deep/subdir/x', b'', False), ('deep/game.exe', b'deep one', False),
('game.exe', b'top one', False), ('dirlink', 'deep/subdir', True),
('alias', 'dirlink/../game.exe', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
with open(os.path.join(out, 'alias'), 'rb') as f:
self.assertEqual(f.read(), b'deep one')
def test_many_links_to_one_file_count_against_the_limit(self):
# The zip (1 KB) and the copies (15 KB) each fit under the limit; together they don't.
members = [('big', b'x' * 1000, False)] + [(f'alias{i}', 'big', True) for i in range(15)]
old = frame_titles.MAX_UNPACKED
frame_titles.MAX_UNPACKED = 15500
out = tempfile.mkdtemp(dir=self.dir)
try:
with self.assertRaisesRegex(FrameError, 'links would copy'):
frame_titles.extract_zip(self.link_zip(members), out)
finally:
frame_titles.MAX_UNPACKED = old
self.assertEqual(os.listdir(out), ['big']) # refused before copying any link
def test_oversized_link_is_refused(self):
p = self.link_zip([('big', 'x' * 5000, True)])
with self.assertRaisesRegex(FrameError, 'oversized link'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_unwrap_never_steps_through_a_link(self):
# A folder whose only entry links elsewhere (a junction on Windows) stays the boundary.
outside, game = os.path.join(self.dir, 'outside'), os.path.join(self.dir, 'Game')
os.makedirs(outside)
os.makedirs(game)
with open(os.path.join(outside, 'Other.exe'), 'wb') as f:
f.write(pe(0x8664))
os.symlink(outside, os.path.join(game, 'inner'))
with self.assertRaisesRegex(FrameError, 'no Linux or Windows program'):
frame_titles.inspect(game)
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_folder_with_outside_link_is_staged_without_it(self):
game, secret = os.path.join(self.dir, 'Game'), os.path.join(self.dir, 'secret')
os.makedirs(game)
os.makedirs(secret)
with open(os.path.join(secret, 'key'), 'wb') as f:
f.write(b'private')
with open(os.path.join(game, 'Game.exe'), 'wb') as f:
f.write(pe(0x8664))
os.symlink(secret, os.path.join(game, 'leak'))
os.symlink(os.path.join(secret, 'key'), os.path.join(game, 'leak-file'))
os.symlink('Game.exe', os.path.join(game, 'Alias.exe'))
plan = frame_titles.inspect(game)
try:
self.assertNotEqual(os.path.realpath(plan['root']), os.path.realpath(game))
self.assertEqual(sorted(os.listdir(plan['root'])), ['Alias.exe', 'Game.exe'])
self.assertFalse(os.path.islink(os.path.join(plan['root'], 'Alias.exe')))
finally:
frame_titles.discard(plan)
def test_links_become_copies(self):
# No symlinks on disk (Windows may not allow them); the library a link names is still there.
p = self.link_zip([('game/lib/libfoo.so.1.2', b'ELF-ish', False), ('game/lib/libfoo.so.1', 'libfoo.so.1.2', True),
('game/lib/libfoo.so', 'libfoo.so.1', True), ('game/dangling', 'nowhere', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
for name in ('libfoo.so.1', 'libfoo.so'):
path = os.path.join(out, 'game', 'lib', name)
self.assertFalse(os.path.islink(path))
with open(path, 'rb') as f:
self.assertEqual(f.read(), b'ELF-ish')
self.assertFalse(os.path.lexists(os.path.join(out, 'game', 'dangling')))
def test_drive_qualified_parts_are_refused(self):
for bad in ('sub/C:../C:../victim.txt', 'game/file.exe:stream'):
with self.subTest(bad=bad):
with self.assertRaisesRegex(FrameError, 'drive or stream'):
frame_titles.extract_zip(self.zip({bad: b'x'}, 'drive.zip'), tempfile.mkdtemp(dir=self.dir))
def test_absurd_size_is_refused(self):
p = self.zip({'game.exe': pe(0x8664)}, 'bomb.zip')
old = frame_titles.MAX_UNPACKED
frame_titles.MAX_UNPACKED = 10
try:
with self.assertRaisesRegex(FrameError, 'looks wrong'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
finally:
frame_titles.MAX_UNPACKED = old
def test_not_a_zip(self):
p = os.path.join(self.dir, 'x.zip')
with open(p, 'wb') as f:
f.write(b'nope')
with self.assertRaisesRegex(FrameError, 'not a readable zip'):
frame_titles.inspect(p)
class Names(unittest.TestCase):
def test_title_id(self):
self.assertEqual(frame_titles.title_id('Hollow Knight: Silksong!'), 'Hollow_Knight_Silksong')
self.assertEqual(frame_titles.title_id('steam'), 'steam-game') # Valve's reserved sideload names
self.assertEqual(frame_titles.title_id('Devkit Steam'), 'Devkit_Steam')
self.assertEqual(frame_titles.title_id('devkit-steam'), 'devkit-steam-game') # the trampoline file
self.assertEqual(frame_titles.title_id('--rm -rf /'), 'rm_-rf')
self.assertEqual(len(frame_titles.title_id('x' * 200)), 64)
with self.assertRaises(FrameError):
frame_titles.title_id('!!!')
def test_display_name(self):
self.assertEqual(frame_titles.display_name('MyGame-linux-arm64.zip'), 'MyGame')
self.assertEqual(frame_titles.display_name('Portal 2.zip'), 'Portal 2')
self.assertEqual(frame_titles.display_name('Game_v1.0.3_Win64.zip'), 'Game')
class Parms(unittest.TestCase):
def test_proton_parms(self):
p = frame_titles.shortcut_parms('Cool_Game', '/home/steamos/devkit-game/Cool_Game',
'Cool Game.exe', 'proton-experimental')
self.assertEqual(p, {'gameid': 'Cool_Game', 'directory': '/home/steamos/devkit-game/Cool_Game',
'argv': ['"Cool Game.exe"'], 'env': {},
'settings': {'steam_play': '1', 'steam_play_debug': '0',
'steam_play_debug_version': '2019',
'compat_tool': 'proton-experimental'},
'clear_settings': True, 'force_appid': '', 'lepton_args': ''})
json.dumps(p)
def test_linux_parms(self):
p = frame_titles.shortcut_parms('g', '/home/steamos/devkit-game/g', 'bin/game', 'SteamLinuxRuntime_4-arm64')
self.assertEqual(p['argv'], ['bin/game'])
self.assertEqual(p['settings'], {'steam_play': '0', 'compat_tool': 'SteamLinuxRuntime_4-arm64'})
def test_cleanup_names_only_this_title(self):
# A glob like Game-*.json would also delete Game-Deluxe's files.
self.assertEqual(frame_titles._json_files('Game').split(),
['devkit-game/Game-argv.json', 'devkit-game/Game-env.json',
'devkit-game/Game-settings.json', 'devkit-game/Game-framecontrol.json'])
def test_launch_needs_steam_to_answer(self):
# steam-devkit-rpc exits 0 after a timeout; only its 'success' line means Steam took it.
calls = []
old = frame_titles.ssh, frame_titles._check_id, frame_titles.ensure_utils
frame_titles._check_id, frame_titles.ensure_utils = (lambda g: g), (lambda: False)
try:
frame_titles.ssh = lambda cmd, **kw: calls.append(cmd) or 'Found steam client pid 1\ntimeout\n'
with self.assertRaisesRegex(FrameError, "didn't confirm"):
frame_titles.launch('Game')
frame_titles.ssh = lambda cmd, **kw: 'Found steam client pid 1\nsuccess\n{}'
self.assertEqual(frame_titles.launch('Game'), {'id': 'Game'})
finally:
frame_titles.ssh, frame_titles._check_id, frame_titles.ensure_utils = old
self.assertIn('steam-devkit-rpc run-game gameid=Game', calls[0])
def test_remove_waits_for_installs(self):
with frame_titles._install_lock:
with self.assertRaisesRegex(FrameError, 'install is running'):
frame_titles.remove('Game')
def test_vendored_utils_are_present(self):
for name in ('steamos-prepare-upload', 'steam-client-create-shortcut', 'steam-devkit-rpc',
'steamos-delete', 'devkit_utils/__init__.py', 'LICENSE'):
self.assertTrue(os.path.isfile(os.path.join(frame_titles.UTILS_LOCAL, name)), name)
self.assertEqual(len(frame_titles.utils_stamp()), 20)
if __name__ == '__main__':
unittest.main()
+58 -1
View File
@@ -6,14 +6,17 @@ request guards and input validation, which all run before any SSH call.
Run: python3 -m unittest discover -s tests
"""
import http.client
import io
import json
import os
import socket
import struct
import subprocess
import sys
import tempfile
import time
import unittest
import zipfile
from pathlib import Path
from urllib.parse import quote
@@ -54,7 +57,7 @@ class ServerGuards(unittest.TestCase):
@classmethod
def request(cls, method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", cls.port, timeout=10)
data = json.dumps(body).encode() if body is not None else None
data = body if isinstance(body, bytes) else json.dumps(body).encode() if body is not None else None
conn.request(method, path, body=data, headers=headers or {})
r = conn.getresponse()
payload = r.read()
@@ -130,12 +133,66 @@ class ServerGuards(unittest.TestCase):
status, _ = self.post("/api/launch", ["not", "an", "object"])
self.assertEqual(status, 400)
def test_title_upload_is_inspected_then_discarded(self):
# A zip holding a Windows x86-64 program: inspected locally, no SSH until install.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("Tiny Game/Tiny Game.exe",
b"MZ" + b"\0" * 0x3A + struct.pack("<I", 0x40) + b"PE\0\0" + struct.pack("<HHIIIHH", 0x8664, 1, 0, 0, 0, 0xF0, 0x22))
status, _, payload = self.request("POST", "/api/upload", buf.getvalue(),
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": quote("Tiny Game-win64.zip")})
r = json.loads(payload)
self.assertEqual(status, 200, r)
self.assertEqual((r["plan"]["id"], r["plan"]["target"], r["plan"]["runtime"]),
("Tiny_Game", "Tiny Game.exe", "proton-experimental"))
self.assertNotIn("root", r["plan"])
self.assertEqual(self.post("/api/titles", {"action": "discard", "token": r["token"]})[0], 200)
self.assertEqual(self.post("/api/titles", {"action": "install", "token": r["token"]})[0], 400)
def test_title_input_validation(self):
status, _, _ = self.request("POST", "/api/upload", b"not a zip",
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": "x.zip"})
self.assertEqual(status, 400)
for body in ({"action": "inspect", "path": "relative/game.zip"},
{"action": "inspect", "path": "/nonexistent/frame-control/game.zip"},
{"action": "install", "token": "nope"},
{"action": "launch", "id": "x; rm -rf ~"},
{"action": "remove", "id": "../etc"},
{"action": "explode"}):
status, payload = self.post("/api/titles", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
def test_web_install_needs_the_app_page(self):
# A website can only open frame-control:// links; it can't call these itself.
link = {"url": "https://cdn.example.com/game.apk"}
self.assertEqual(self.request("POST", "/api/webinstall/check", link)[0], 403)
self.assertEqual(self.request("POST", "/api/webinstall/start", {"id": "x"})[0], 403)
status, _, _ = self.request("POST", "/api/webinstall/check", link,
{"X-Frame-UI": "1", "Host": f"evil.example:{self.port}"})
self.assertEqual(status, 403)
def test_web_install_validation(self):
for body in ({}, {"url": 5}, {"url": "http://cdn.example.com/game.apk"}, {"url": "https://10.0.0.2/game.apk"},
{"url": "https://u:p@example.com/game.apk"}, {"url": "https://example.com/"},
{"url": "https://1.1.1.1/game.sh"}, {"manifest": "file:///etc/passwd"},
{"manifest": "https://example.com/m.json", "url": "https://example.com/g.apk"}):
status, payload = self.post("/api/webinstall/check", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
# Only an id from /check starts an install, and only once.
self.assertEqual(self.post("/api/webinstall/start", {"id": "made-up"})[0], 400)
self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404)
self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404)
def test_unknown_routes(self):
self.assertEqual(self.request("GET", "/nope")[0], 404)
self.assertEqual(self.post("/api/nope", {})[0], 404)
class StatusProbe(unittest.TestCase):
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
def test_runs_off_device_and_prints_one_json_object(self):
# The probe runs on the Frame; elsewhere every field must degrade to null/empty.
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_status.py")],
+438
View File
@@ -0,0 +1,438 @@
"""Install links from websites (ui/frame_webinstall.py, app/install-link.js). No network:
name lookups are stubbed and downloads come from a server on 127.0.0.1, which
the localhost-testing rule allows.
Run: python3 -m unittest discover -s tests
"""
import hashlib
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_webinstall as wi # noqa: E402
E = wi.WebInstallError
PAYLOAD = b"not really an apk, but bytes are bytes\n" * 1000
def fake_dns(*ips):
return lambda host, port, **_: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port)) for ip in ips]
class Urls(unittest.TestCase):
def test_https_ok(self):
self.assertEqual(wi.check_url("https://cdn.example.com/g/mygame.apk"), ("https", "cdn.example.com", 443, False))
self.assertEqual(wi.file_name("https://cdn.example.com/g/my%20game.apk?sig=1"), "my game.apk")
def test_http_only_for_localhost(self):
with self.assertRaises(E):
wi.check_url("http://cdn.example.com/mygame.apk")
self.assertTrue(wi.check_url("http://localhost:8000/mygame.apk", allow_local=True)[3])
self.assertTrue(wi.check_url("http://127.0.0.1:8000/mygame.apk", allow_local=True)[3])
def test_localhost_only_when_the_link_starts_there(self):
for url in ("http://localhost/x.apk", "https://127.0.0.1/x.apk"):
with self.assertRaises(E):
wi.check_url(url, allow_local=False)
def test_other_schemes_rejected(self):
for url in ("file:///etc/passwd", "ftp://example.com/x.apk", "javascript:alert(1)", "//example.com/x.apk", ""):
with self.assertRaises(E, msg=url):
wi.check_url(url)
def test_private_and_local_addresses_rejected(self):
for host in ("10.0.0.5", "192.168.1.20", "172.16.3.4", "127.0.0.2", "169.254.169.254", "100.64.1.1",
"0.0.0.0", "[::1]", "[fe80::1]", "[fd00::1]", "[fec0::1]", "[::ffff:192.168.1.1]",
"[2002:c0a8:101::1]", "224.0.0.1"):
with self.assertRaises(E, msg=host):
wi.check_url(f"https://{host}/x.apk")
wi.check_url("https://93.184.216.34/x.apk")
def test_names_resolving_to_private_addresses_rejected(self):
with mock.patch.object(wi, "_getaddrinfo", fake_dns("192.168.1.9")):
with self.assertRaises(E):
wi._resolve("sneaky.example.com", 443, False)
# Every address counts, not just the first.
with mock.patch.object(wi, "_getaddrinfo", fake_dns("93.184.216.34", "10.1.2.3")):
with self.assertRaises(E):
wi._resolve("mixed.example.com", 443, False)
with mock.patch.object(wi, "_getaddrinfo", fake_dns("93.184.216.34")):
self.assertEqual(wi._resolve("cdn.example.com", 443, False), "93.184.216.34")
def test_credentials_rejected(self):
for url in ("https://user:pw@example.com/x.apk", "https://user@example.com/x.apk", "https://:pw@example.com/x.apk"):
with self.assertRaises(E, msg=url):
wi.check_url(url)
def test_directory_urls_rejected(self):
for url in ("https://example.com/", "https://example.com", "https://example.com/games/",
"https://example.com/%2e%2e", "https://example.com/.hidden.apk", "https://example.com/a%2Fb.apk"):
with self.assertRaises(E, msg=url):
wi.file_name(url)
def test_file_types(self):
self.assertEqual(wi.file_kind("Game.APK"), "apk")
self.assertEqual(wi.file_kind("game.zip"), "title")
self.assertEqual(wi.file_kind("setup.exe"), "title")
for name in ("game.sh", "game.tar.gz", "game"):
with self.assertRaises(E, msg=name):
wi.file_kind(name)
class Manifests(unittest.TestCase):
FILE = {"url": "https://cdn.example.com/mygame-arm64.apk"}
def test_both_schemas(self):
for schema in ("framedrop.install/v1", "frame-control.install/v1"):
m = wi.parse_manifest({"schema": schema, "name": "My Game", "files": [dict(self.FILE, sha256="AB" * 32)]})
self.assertEqual(m["name"], "My Game")
self.assertEqual(m["file"]["url"], self.FILE["url"])
self.assertEqual(m["file"]["sha256"], "ab" * 32)
def test_bad_schema(self):
for schema in (None, "framedrop.install/v2", "something"):
with self.assertRaises(E, msg=schema):
wi.parse_manifest({"schema": schema, "files": [self.FILE]})
def test_missing_or_bad_fields(self):
base = {"schema": "framedrop.install/v1"}
for obj in ([], base, dict(base, files=[]), dict(base, files="x"), dict(base, files=[{}]),
dict(base, files=[{"url": ""}]), dict(base, files=[dict(self.FILE, sha256="abc")]),
dict(base, files=[dict(self.FILE, size=-1)]), dict(base, name=5, files=[self.FILE])):
with self.assertRaises(E, msg=obj):
wi.parse_manifest(obj)
def test_name_optional_and_cleaned(self):
self.assertIsNone(wi.parse_manifest({"schema": "framedrop.install/v1", "files": [self.FILE]})["name"])
m = wi.parse_manifest({"schema": "framedrop.install/v1", "name": " A\x1b[31mB\n ", "files": [self.FILE]})
self.assertEqual(m["name"], "A[31mB")
def test_multiple_files_refused_clearly(self):
with self.assertRaisesRegex(E, "2 files"):
wi.parse_manifest({"schema": "framedrop.install/v1", "files": [self.FILE, self.FILE]})
class Stub(BaseHTTPRequestHandler):
routes = {}
def log_message(self, *_):
pass
def do_HEAD(self):
self.do_GET(body=False)
def do_GET(self, body=True):
route = self.routes.get(self.path)
if route is None:
self.send_response(404)
self.end_headers()
return
status, headers, data = route
self.send_response(status)
for k, v in headers.items():
self.send_header(k, v)
if "Content-Length" not in headers:
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if body:
self.wfile.write(data)
class Downloads(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
threading.Thread(target=cls.httpd.serve_forever, daemon=True).start()
sha = hashlib.sha256(PAYLOAD).hexdigest()
Stub.routes = {
"/game.apk": (200, {}, PAYLOAD),
"/game.zip": (200, {}, PAYLOAD),
"/redirect.apk": (302, {"Location": "/game.apk"}, b""),
"/to-lan.apk": (302, {"Location": "https://192.168.1.5/game.apk"}, b""),
"/to-http.apk": (302, {"Location": "http://cdn.example.com/game.apk"}, b""),
"/loop.apk": (302, {"Location": "/loop.apk"}, b""),
"/manifest.json": (200, {}, json.dumps({"schema": "framedrop.install/v1", "name": "Stub Game",
"files": [{"url": f"{cls.base}/game.apk", "sha256": sha}]}).encode()),
"/bad-sha.json": (200, {}, json.dumps({"schema": "frame-control.install/v1", "name": "Bad",
"files": [{"url": f"{cls.base}/game.apk", "sha256": "0" * 64}]}).encode()),
"/huge.json": (200, {}, b"{" + b" " * (wi.MAX_MANIFEST + 10) + b"}"),
"/notjson.json": (200, {}, b"<html>"),
"/short.apk": (200, {"Content-Length": str(len(PAYLOAD) + 100)}, PAYLOAD),
}
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
def setUp(self):
self.tmp = tempfile.mkdtemp()
env = mock.patch.dict(os.environ, {wi.LOCAL_LINKS_ENV: "1"})
env.start()
self.addCleanup(env.stop)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_localhost_links_need_the_developer_switch(self):
# Without it, a website's link can't make the app fetch from local services.
with mock.patch.dict(os.environ, {wi.LOCAL_LINKS_ENV: ""}):
for kw in ({"manifest": f"{self.base}/manifest.json"}, {"url": f"{self.base}/game.apk"}):
with self.assertRaisesRegex(wi.WebInstallError, wi.LOCAL_LINKS_ENV):
wi.plan(**kw)
def test_manifest_round_trip(self):
p = wi.plan(manifest=f"{self.base}/manifest.json")
self.assertEqual((p["name"], p["file"], p["kind"], p["host"], p["size"]),
("Stub Game", "game.apk", "apk", "127.0.0.1", len(PAYLOAD)))
seen = []
path = wi.download(p, self.tmp, progress=lambda done, total: seen.append((done, total)))
self.assertEqual(Path(path).read_bytes(), PAYLOAD)
self.assertEqual(seen[-1], (len(PAYLOAD), len(PAYLOAD)))
self.assertEqual(os.listdir(self.tmp), ["game.apk"])
def test_direct_url_and_redirect(self):
p = wi.plan(url=f"{self.base}/redirect.apk")
self.assertEqual((p["name"], p["file"]), ("redirect.apk", "redirect.apk"))
self.assertEqual(Path(wi.download(p, self.tmp)).read_bytes(), PAYLOAD)
def test_redirects_checked_again(self):
for path in ("/to-lan.apk", "/to-http.apk", "/loop.apk"):
with self.assertRaises(E, msg=path):
wi._open(f"{self.base}{path}", allow_local=True)
def test_sha256_mismatch_leaves_nothing(self):
p = wi.plan(manifest=f"{self.base}/bad-sha.json")
with self.assertRaisesRegex(E, "sha256"):
wi.download(p, self.tmp)
self.assertEqual(os.listdir(self.tmp), [])
def test_size_cap(self):
with mock.patch.object(wi, "MAX_FILE", 1000):
with self.assertRaisesRegex(E, "limit"):
wi.plan(url=f"{self.base}/game.apk")
p = {"url": f"{self.base}/game.apk", "file": "game.apk", "allowLocal": True, "size": None, "sha256": None}
with self.assertRaisesRegex(E, "limit"):
wi.download(p, self.tmp)
self.assertEqual(os.listdir(self.tmp), [])
def test_bad_manifests(self):
for path in ("/huge.json", "/notjson.json", "/missing.json"):
with self.assertRaises(E, msg=path):
wi.plan(manifest=f"{self.base}{path}")
def test_cut_off_download(self):
p = {"url": f"{self.base}/short.apk", "file": "short.apk", "allowLocal": True, "size": None, "sha256": None}
with self.assertRaises(E):
wi.download(p, self.tmp)
self.assertEqual(os.listdir(self.tmp), [])
def test_aborted_connection_never_connects(self):
port = self.httpd.server_address[1]
for cls in (wi._HTTPConnection, wi._HTTPSConnection):
conn = cls("127.0.0.1", "127.0.0.1", port, 5)
wi.abort(conn) # before connect, e.g. cancelled while looking up the name
with self.assertRaisesRegex(OSError, "aborted"):
conn.connect()
def test_cancel(self):
p = wi.plan(url=f"{self.base}/game.apk")
with self.assertRaises(wi.Cancelled):
wi.download(p, self.tmp, cancelled=lambda: True)
self.assertEqual(os.listdir(self.tmp), [])
class Dispatch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def file(self, name):
path = os.path.join(self.tmp, name)
Path(path).write_bytes(PAYLOAD)
return path
def test_apk_goes_to_the_android_installer(self):
import frame_android
with mock.patch.object(frame_android, "install", return_value={"label": "Stub"}) as install:
res = wi.dispatch(self.file("game.apk"), name="Ignored", source="https://example.com/game.apk")
install.assert_called_once_with(os.path.join(self.tmp, "game.apk"), source="https://example.com/game.apk")
self.assertEqual(res["kind"], "apk")
self.assertIn("Stub", res["message"])
def test_titles_without_the_titles_module(self):
with mock.patch.dict(sys.modules, {"frame_titles": None}):
with self.assertRaisesRegex(E, "newer Frame Control"):
wi.dispatch(self.file("game.zip"))
def test_titles_go_to_frame_titles(self):
fake = mock.Mock()
fake.install.return_value = {"message": "Installed Stub"}
with mock.patch.dict(sys.modules, {"frame_titles": fake}):
res = wi.dispatch(self.file("game.exe"), name="Stub", exe=None)
fake.install.assert_called_once_with(os.path.join(self.tmp, "game.exe"), name="Stub", exe=None, progress=None)
self.assertEqual(res["message"], "Installed Stub")
def test_other_files_refused(self):
with self.assertRaises(E):
wi.dispatch(self.file("game.sh"))
class ServerJobs(unittest.TestCase):
"""The server's install worker (ui/server.py), with download and dispatch stubbed."""
@classmethod
def setUpClass(cls):
with mock.patch.dict(os.environ, {"FRAME_ALIAS": "frame-control-test.invalid"}):
import server
cls.server = server
def run_job(self, download=None, mkdtemp_error=None):
s = self.server
job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None, "cancel": False}
plan = {"name": "Stub", "exe": None, "url": "https://example.com/stub.apk"}
with mock.patch.object(s, "ensure_master"), \
mock.patch.object(s.frame_webinstall, "download", side_effect=lambda *a, **k: download(job, a[1])), \
mock.patch.object(s.tempfile, "mkdtemp", side_effect=mkdtemp_error or tempfile.mkdtemp), \
mock.patch.object(s.frame_webinstall, "dispatch", return_value={"message": "ok"}) as dispatch:
s._webinstall_run(plan, job)
return job, dispatch
def test_cancel_after_the_last_chunk_still_stops_the_install(self):
def download(job, tmp):
job["cancel"] = True # arrives after the downloader's last check
return os.path.join(tmp, "stub.apk")
job, dispatch = self.run_job(download)
dispatch.assert_not_called()
self.assertEqual(job["phase"], "error")
def test_finished_download_is_dispatched(self):
job, dispatch = self.run_job(lambda job, tmp: os.path.join(tmp, "stub.apk"))
dispatch.assert_called_once()
self.assertEqual((job["phase"], job["message"]), ("done", "ok"))
def stall_then_shutdown(self, scheme, reply):
if os.name == "nt":
# shutdown() from another thread doesn't wake a blocked recv on Windows, and
# closing the handle under a TLS read isn't safe; see web-install.md.
self.skipTest("Windows: a stalled download is only dropped when the app stops the server")
"""Start a download from a server that stalls after sending reply; shutdown must stop it quickly."""
stall = socket.socket()
stall.bind(("127.0.0.1", 0))
stall.listen(1)
port = stall.getsockname()[1]
stalled = threading.Event()
def serve():
c, _ = stall.accept()
if reply is not None:
c.recv(65536)
c.sendall(reply)
stalled.set()
time.sleep(20) # longer than the test may take; TIMEOUT is 30 s
c.close()
threading.Thread(target=serve, daemon=True).start()
s = self.server
pid = "shutdown-test"
s._web_plans[pid] = {"name": "Stub", "exe": None, "url": f"{scheme}://127.0.0.1:{port}/stub.apk",
"file": "stub.apk", "allowLocal": True, "size": None, "sha256": None,
"sizeFromManifest": False}
try:
s.webinstall_start({"id": pid})
job = s._web_jobs[pid]
self.assertTrue(stalled.wait(5))
time.sleep(0.1) # let the client block
t0 = time.time()
s.webinstall_shutdown()
self.assertLess(time.time() - t0, 3)
self.assertEqual(s._web_workers, set())
self.assertEqual((job["phase"], job["error"]), ("error", "download cancelled"))
s._web_plans["late"] = {"size": None}
with self.assertRaises(s.Failure) as caught: # nothing new starts once quitting
s.webinstall_start({"id": "late"})
self.assertEqual(caught.exception.status, 503)
finally:
s._web_closing = False
s._web_jobs.clear()
s._web_plans.clear()
stall.close()
def test_shutdown_interrupts_a_stalled_body(self):
self.stall_then_shutdown("http", b"HTTP/1.0 200 OK\r\nContent-Length: 1000000\r\n\r\npartial")
def test_shutdown_interrupts_stalled_headers(self):
self.stall_then_shutdown("http", b"HTTP/1.1 200 OK\r\n")
def test_shutdown_interrupts_a_stalled_tls_handshake(self):
self.stall_then_shutdown("https", None)
def test_dead_servers_leftovers_swept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
# Downloads and title staging (unzipped titles) are both swept.
for prefix in (self.server.WEB_TMP_PREFIX, self.server.frame_titles.TMP_PREFIX):
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-")
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-")
try:
self.server.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
def test_temp_dir_failure_ends_the_job(self):
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
dispatch.assert_not_called()
self.assertEqual(job["phase"], "error")
self.assertIn("disk full", job["error"])
@unittest.skipUnless(shutil.which("node"), "needs node")
class LinkParsing(unittest.TestCase):
def parse(self, links):
script = ("const { parseInstallLink, linkFromArgv } = require(process.argv[1]);"
"const links = JSON.parse(process.argv[2]);"
"console.log(JSON.stringify({ parsed: links.map(parseInstallLink),"
" argv: linkFromArgv(['/x/frame-control', '--flag', links[0]]) }));")
out = subprocess.run(["node", "-e", script, str(ROOT / "app" / "install-link.js"), json.dumps(links)],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 0, out.stderr)
return json.loads(out.stdout)
def test_links(self):
m = "https://example.com/m.json"
good = ["frame-control://install?manifest=" + "https%3A%2F%2Fexample.com%2Fm.json",
"frame-control://install/?url=https%3A%2F%2Fcdn.example.com%2Fg.apk",
"FRAME-CONTROL://install?manifest=http%3A%2F%2Flocalhost%3A8000%2Fm.json"]
bad = ["framedrop://install?manifest=" + m, "frame-control://uninstall?manifest=" + m,
"frame-control://install?manifest=" + m + "&url=" + m, "frame-control://install?manifest=a&manifest=b",
"frame-control://install?manifest=file%3A%2F%2F%2Fetc%2Fpasswd", "frame-control://install?other=" + m,
"frame-control://install?url=https%3A%2F%2Fu%3Ap%40example.com%2Fg.apk", "frame-control://install",
"frame-control://install/sub?url=" + m, "https://example.com"]
res = self.parse(good + bad)
self.assertEqual(res["parsed"][0], {"kind": "manifest", "target": m})
self.assertEqual(res["parsed"][1], {"kind": "url", "target": "https://cdn.example.com/g.apk"})
self.assertEqual(res["parsed"][2]["kind"], "manifest")
self.assertEqual(res["parsed"][len(good):], [None] * len(bad))
self.assertEqual(res["argv"], good[0])
if __name__ == "__main__":
unittest.main()
+26 -47
View File
@@ -8,7 +8,10 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
"""
import glob, json, os, re, shlex, subprocess, sys, threading, time, zipfile, zlib
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
import frame_apk
import frame_host
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
@@ -27,7 +30,9 @@ class FrameError(RuntimeError):
def ssh(cmd, input=None, timeout=120):
try:
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True,
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}')
@@ -52,47 +57,12 @@ def game_id(shortcut_appid):
return (int(shortcut_appid) << 32) | 0x02000000
def aapt2():
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
return found[-1] if found else None
def apk_info(path):
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
tool = aapt2()
if not tool:
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
'sdkmanager "build-tools;36.0.0"')
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
if not m:
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
label = re.search(r"application-label(?:-en(?:-US)?)?:'([^']*)'", out) or \
re.search(r"application: label='([^']*)'", out)
icons = re.findall(r"application-icon-(\d+):'([^']+)'", out)
abis = re.search(r"native-code: (.*)", out)
sdk = re.search(r"(?:minSdkVersion|sdkVersion):'(\d+)'", out)
info = {'package': m[1], 'version': m[2], 'label': (label[1] if label else '') or m[1],
'abis': re.findall(r"'([^']+)'", abis[1]) if abis else [],
'min_sdk': int(sdk[1]) if sdk else None, 'icon_png': None}
try:
z = zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError) as e:
raise FrameError(f'not a readable APK: {e}')
with z:
names = set(z.namelist())
for _, icon in sorted(icons, key=lambda d: -int(d[0])):
if icon.endswith('.png') and icon in names:
info['icon_png'] = z.read(icon)
break
else: # adaptive icons are XML; fall back to the largest launcher PNG
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
key=lambda n: z.getinfo(n).file_size)
if pngs:
info['icon_png'] = z.read(pngs[-1])
return info
return frame_apk.apk_info(path)
except frame_apk.ApkError as e:
raise FrameError(f'{os.path.basename(path)}: {e}')
def check_installable(info):
@@ -105,14 +75,23 @@ def check_installable(info):
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
def _rsync(src, dest, *extra, timeout=600):
def _copy(src, dest, executable=False, timeout=600):
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
name = os.path.basename(src)
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
if rsync:
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
'-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}']
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
check=True, capture_output=True, text=True, timeout=timeout)
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {os.path.basename(src)} to the Frame timed out')
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
raise FrameError(f'copying {os.path.basename(src)} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
if executable and not rsync:
ssh(f'chmod u+x {shlex.quote(dest)}')
def _shortcut_ids():
@@ -146,8 +125,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
ok = False
try:
ssh(f'mkdir -p {d}')
_rsync(apk_path, f'{d}/app.apk.part')
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
_copy(apk_path, f'{d}/app.apk.part')
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
icon = ''
if info['icon_png']:
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
+260
View File
@@ -0,0 +1,260 @@
"""Read an APK's package, label, version, SDK level, ABIs and icon, stdlib only.
Replaces `aapt2 dump badging`, so installing APKs needs no Android SDK. It
parses the binary AndroidManifest.xml and, for values the manifest points at
(the label, version name and icon are often @string or @mipmap references),
the resource table in resources.arsc.
"""
import struct
import zipfile
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
# APKs can come from websites (install links), so nothing read from one may be
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
MAX_MANIFEST = 16 * 1024**2
MAX_ARSC = 128 * 1024**2 # real ones are a few MB; the largest apps' tens of MB
MAX_ICON = 8 * 1024**2
MAX_VALUES = 256 # resolved values per reference, across all its hops
MAX_STEPS = 4096 # entries examined per reference, dead ends and cycles included
class ApkError(Exception):
pass
def _string_pool(buf, off):
"""Strings of the ResStringPool chunk at off."""
_, hsize, _, count, _, flags, start = struct.unpack_from('<HHIIIII', buf, off)
utf8 = flags & 0x100
offsets = struct.unpack_from(f'<{count}I', buf, off + hsize)
base = off + start
out = []
for o in offsets:
p = base + o
if utf8:
for _ in range(2): # UTF-16 length, then UTF-8 byte length
n = buf[p]
if n & 0x80:
n = ((n & 0x7f) << 8) | buf[p + 1]
p += 2
else:
p += 1
out.append(buf[p:p + n].decode('utf-8', 'replace'))
else:
n, = struct.unpack_from('<H', buf, p)
p += 2
if n & 0x8000:
n = ((n & 0x7fff) << 16) | struct.unpack_from('<H', buf, p)[0]
p += 2
out.append(buf[p:p + 2 * n].decode('utf-16-le', 'replace'))
return out
def _chunks(buf, off, end):
while off + 8 <= end:
ctype, hsize, size = struct.unpack_from('<HHI', buf, off)
if size < 8 or off + size > end:
break
yield ctype, hsize, off, size
off += size
def manifest_elements(data):
"""[(tag, {attr: (type, data, raw string or None)})] for each start tag."""
if len(data) < 8 or struct.unpack_from('<H', data, 0)[0] != 0x0003:
raise ApkError('AndroidManifest.xml is not binary XML')
strings, resmap, out = [], [], []
for ctype, hsize, off, size in _chunks(data, 8, len(data)):
if ctype == 0x0001:
strings = _string_pool(data, off)
elif ctype == 0x0180:
resmap = struct.unpack_from(f'<{(size - hsize) // 4}I', data, off + hsize)
elif ctype == 0x0102:
name, astart, asize, count = struct.unpack_from('<4xIHHH', data, off + hsize)
attrs = {}
for i in range(count):
a = off + hsize + astart + i * asize
aname, raw, dtype, value = struct.unpack_from('<4xII3xBI', data, a)
raw = strings[raw] if raw < len(strings) else None
if raw is None and dtype == T_STRING and value < len(strings):
raw = strings[value] # some repackers keep only the typed value
android = ATTR.get(resmap[aname]) if aname < len(resmap) else None
if android: # android: attributes win over same-named ones in other namespaces
attrs[android] = (dtype, value, raw)
else:
attrs.setdefault(strings[aname] if aname < len(strings) else '', (dtype, value, raw))
out.append((strings[name] if name < len(strings) else '', attrs))
return out
class Resources:
"""Just enough of resources.arsc to resolve a reference to its values."""
def __init__(self, data):
self.entries = {} # resid -> [(language, density, type, data)]
self.strings = []
if len(data) < 12 or struct.unpack_from('<H', data, 0)[0] != 0x0002:
return
hsize, = struct.unpack_from('<H', data, 2)
for ctype, _, off, size in _chunks(data, hsize, len(data)):
if ctype == 0x0001 and not self.strings:
self.strings = _string_pool(data, off)
elif ctype == 0x0200:
self._package(data, off, size)
def _package(self, data, off, size):
pid, = struct.unpack_from('<I', data, off + 8)
phsize, = struct.unpack_from('<H', data, off + 2)
for ctype, thsize, t, tsize in _chunks(data, off + phsize, off + size):
if ctype != 0x0201:
continue
tid, flags, count, estart = struct.unpack_from('<BB2xII', data, t + 8)
cfg = t + 20
language = data[cfg + 8:cfg + 10].rstrip(b'\0').decode('latin-1')
density, = struct.unpack_from('<H', data, cfg + 14)
if flags & 0x01: # sparse: (entry index, offset / 4) pairs
pairs = [struct.unpack_from('<HH', data, t + thsize + 4 * i) for i in range(count)]
offsets = [(i, o * 4) for i, o in pairs]
elif flags & 0x02: # 16-bit offsets / 4
offsets = [(i, o * 4) for i, o in enumerate(struct.unpack_from(f'<{count}H', data, t + thsize))
if o != 0xffff]
else:
offsets = [(i, o) for i, o in enumerate(struct.unpack_from(f'<{count}I', data, t + thsize))
if o != 0xffffffff]
for index, o in offsets:
e = t + estart + o
esize, eflags = struct.unpack_from('<HH', data, e)
if eflags & 0x08: # compact entry: type in the flags' high byte
dtype, value = eflags >> 8, struct.unpack_from('<I', data, e + 4)[0]
elif eflags & 0x01: # bag (style, plural...): not a plain value
continue
else:
dtype, value = struct.unpack_from('<3xBI', data, e + esize)
resid = (pid << 24) | (tid << 16) | index
self.entries.setdefault(resid, []).append((language, density, dtype, value))
def values(self, resid, depth=0, seen=frozenset(), steps=None):
"""[(language, density, type, data)] with references followed: never round a
cycle, at most MAX_VALUES results and MAX_STEPS entries examined in all."""
steps = steps if steps is not None else [MAX_STEPS]
out = []
seen = seen | {resid}
for lang, dens, dtype, value in self.entries.get(resid, []):
steps[0] -= 1
if steps[0] < 0 or len(out) >= MAX_VALUES:
break
if dtype == T_REF and depth < 5:
if value not in seen:
out += [(lang or l2, dens or d2, t2, v2)
for l2, d2, t2, v2 in self.values(value, depth + 1, seen, steps)]
else:
out.append((lang, dens, dtype, value))
return out[:MAX_VALUES]
def string(self, dtype, value):
return self.strings[value] if dtype == T_STRING and value < len(self.strings) else None
def _text(attr, res):
"""An attribute's string value, preferring the default then English resource."""
if not attr:
return None
dtype, value, raw = attr
if raw is not None:
return raw
if dtype in (T_INT_DEC, T_INT_HEX):
return str(value)
if dtype == T_REF:
vals = [(lang, res.string(t, v)) for lang, _, t, v in res.values(value)]
vals = [(lang, s) for lang, s in vals if s is not None]
for want in ('', 'en'):
for lang, s in vals:
if lang == want:
return s
return vals[0][1] if vals else None
return None
def _icons(attr, res):
"""Icon file paths, largest density first."""
if not attr or attr[0] != T_REF:
return []
vals = [(0 if d in (0xfffe, 0xffff) else d, res.string(t, v)) for _, d, t, v in res.values(attr[1])]
return [s for _, s in sorted(vals, key=lambda x: -x[0]) if s]
def _read(z, name, limit):
"""A member's bytes, inflating at most limit + 1 of them whatever its header claims
(ZipFile.read inflates everything first, then trims to the declared size)."""
info = z.getinfo(name)
# Android only reads stored and deflated entries, and only those bound what
# a read inflates (Python 3.9's bzip2 and lzma readers don't).
if info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
raise ApkError(f'{name} in the APK uses a compression Android does not')
size = info.file_size
if size > limit:
raise ApkError(f'{name} in the APK is {size / 1024**2:.0f} MB, more than a real one ({limit // 1024**2} MB)')
with z.open(name) as f:
data = f.read(limit + 1)
if len(data) > limit:
raise ApkError(f'{name} in the APK is larger than a real one ({limit // 1024**2} MB)')
return data
def apk_info(path):
"""Package, label, version, min_sdk, abis and the best PNG icon inside the APK."""
try:
z = zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError) as e:
raise ApkError(f'not a readable APK: {e}')
with z:
names = set(z.namelist())
if 'AndroidManifest.xml' not in names:
raise ApkError('not an APK: no AndroidManifest.xml')
try:
elements = manifest_elements(_read(z, 'AndroidManifest.xml', MAX_MANIFEST))
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
except (struct.error, IndexError, zipfile.BadZipFile) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
for tag, attrs in elements:
tags.setdefault(tag, attrs)
manifest, app, sdk = tags.get('manifest', {}), tags.get('application', {}), tags.get('uses-sdk', {})
package = _text(manifest.get('package'), res)
if not package:
raise ApkError('the APK manifest has no package name')
min_sdk = sdk.get('minSdkVersion')
info = {
'package': package,
'version': _text(manifest.get('versionName'), res) or '',
'label': _text(app.get('label'), res) or package,
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None,
'icon_png': None,
}
try:
info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res))
except Exception: # noqa: BLE001 - any unreadable icon just means no icon
pass
return info
def _icon_png(z, names, icons):
for icon in icons:
if icon.endswith('.png') and icon in names:
return _read(z, icon, MAX_ICON)
# Adaptive icons are XML; fall back to the largest launcher PNG.
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
key=lambda n: z.getinfo(n).file_size)
return _read(z, pngs[-1], MAX_ICON) if pngs else None
if __name__ == '__main__':
import sys
for p in sys.argv[1:]:
i = apk_info(p)
i['icon_png'] = len(i['icon_png'] or b'')
print(p, i)
+4 -1
View File
@@ -10,9 +10,12 @@ sys.path.insert(0, CATALOG)
import build as catalog_build # noqa: E402
import reports # noqa: E402
import frame_android # noqa: E402
import frame_host # noqa: E402
import frame_compat_db as compat_db # noqa: E402
CACHE = (os.path.expanduser('~/Library/Caches/Frame Control/apk') if '.app/Contents/Resources' in CATALOG
# Inside the installed app the catalogue folder is read-only, so downloads go to
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
else os.path.join(CATALOG, 'data', 'cache'))
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
_lock = threading.Lock()
+15 -10
View File
@@ -1,20 +1,23 @@
"""Frame Control's compatibility database: a private Lakebed capsule
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
write, using a key kept in the macOS Keychain (service frame-control-compat-db,
account app-key).
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
frame-control-compat-db, account app-key). Without one (anyone but the
maintainer), reports stay local.
New reports go to a local outbox first and are sent from there, so nothing is
lost offline. A mirror of every report is kept for offline reads. Both live in
~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
frame_host.data_dir('compat-db'). Python stdlib only.
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
(import restores a backup; reports already in the database are skipped.)
"""
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
import frame_host
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
KEYCHAIN = ('frame-control-compat-db', 'app-key')
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
STATE = str(frame_host.data_dir('compat-db'))
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
MIRROR = os.path.join(STATE, 'compat-mirror.json')
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
@@ -32,17 +35,19 @@ def key():
k = os.environ.get('FRAME_CONTROL_KEY')
if k:
return k
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
capture_output=True, text=True)
if p.returncode != 0 or not p.stdout.strip():
raise DBError('No compatibility-database key in the Keychain '
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
p = None
if frame_host.MAC:
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
capture_output=True, text=True)
if p is None or p.returncode != 0 or not p.stdout.strip():
raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
return p.stdout.strip()
def shared():
"""Whether reports reach the shared database. Without the key (anyone but the
maintainer), reports stay in this Mac's outbox and ratings come from the catalogue."""
maintainer), reports stay in this computer's outbox and ratings come from the catalogue."""
try:
key()
return True
+419
View File
@@ -0,0 +1,419 @@
"""Connect this computer to the Steam Frame: find it, create keys, add a `Host frame`
alias to ~/.ssh/config and get a key onto the headset. It first asks Valve's
SteamOS devkit service (port 32000) to pair, which needs only a tap on the
headset; if that service isn't there or says no, it copies the key over SSH,
asking for the Developer Mode password once. The Linux and Windows twin of
scripts/connect.sh (which the Mac app uses); same config block, so either can
re-run over the other. Idempotent.
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
"""
import base64
import json
import os
import platform
import re
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
USER_FROM_ENV = "FRAME_USER" in os.environ
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
SSH_DIR = Path.home() / ".ssh"
KEY = SSH_DIR / "id_ed25519_frame"
# The devkit service only accepts ssh-rsa keys (write_key in Valve's
# steamos-devkit-service), so pairing uses a second key next to the ed25519 one.
DEVKIT_KEY = SSH_DIR / "id_rsa_frame_devkit"
CONFIG = SSH_DIR / "config"
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
# Both go into ~/.ssh/config, so nothing that could add a line or a directive.
for _name, _value in (("FRAME_ALIAS", FRAME_ALIAS), ("FRAME_USER", FRAME_USER)):
if not NAME_RE.fullmatch(_value):
sys.exit(f"{_name} must be a plain name, not {_value!r}")
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
# Appends the key from stdin unless it's already there. base64 keeps it intact
# through Windows' command-line quoting.
ADD_KEY = """umask 077
mkdir -p ~/.ssh
k=$(cat)
grep -qxF "$k" ~/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$k" >> ~/.ssh/authorized_keys
"""
ADD_KEY_CMD = 'sh -c "$(echo %s | base64 -d)"' % base64.b64encode(ADD_KEY.encode()).decode()
def say(msg):
print(msg, flush=True)
# --- Valve's SteamOS devkit pairing (steamos-devkit-service on the headset). HTTP on
# port 32000: GET /properties.json names the user to log in as; POST /register with
# "ssh-rsa <key> <comment> <magic>" shows an approve prompt in the headset (the
# comment is what it displays, 30 s to answer), then installs the key and turns sshd on.
# The prompt only appears while Steam is on Settings > Developer > Pair new host;
# otherwise /register answers 403 "please put the Steam client in pairing mode".
DEVKIT_PORT = 32000
DEVKIT_SERVICE = "_steamos-devkit._tcp"
MAGIC_PHRASE = "900b919520e4cf601998a71eec318fec" # fixed token Valve's client appends
REGISTER_TIMEOUT = 60
PAIRING_MODE_WAIT = 120 # seconds to keep asking while the user opens "Pair new host"
# A LAN host: never go through an HTTP(S)_PROXY from the environment.
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def key_comment(node):
""""frame-control@<short host name>" as one word: the headset splits the body on spaces."""
name = re.sub(r"[^A-Za-z0-9._-]+", "-", (node or "").split(".")[0]).strip("-.") or "computer"
return f"frame-control@{name}"
def register_body(pub, comment):
fields = pub.split()
if len(fields) < 2 or fields[0] != "ssh-rsa":
raise ValueError("the devkit service only takes ssh-rsa keys")
return f"ssh-rsa {fields[1]} {comment} {MAGIC_PHRASE}\n"
def parse_login(raw):
"""The `login` from /properties.json if it's a plain user name, else None. "root"
means several users are configured and Valve's client switches between them; we
can't, so it counts as no answer."""
props = json.loads(raw)
if not isinstance(props, dict):
raise ValueError("properties.json isn't a JSON object")
login = props.get("login")
if isinstance(login, str) and NAME_RE.fullmatch(login) and login != "root":
return login
return None
def devkit_error(status, raw):
"""A readable reason from a failed /register: its {"error": ...} JSON, or the text."""
text = raw.decode("utf-8", "replace").strip()
try:
err = json.loads(text).get("error")
except (ValueError, AttributeError):
err = None
return str(err or text or f"HTTP {status}")[:300]
def devkit_url(host, port, path):
return f"http://[{host}]:{port}{path}" if ":" in host else f"http://{host}:{port}{path}"
def why(e):
return str(getattr(e, "reason", None) or e)
def fetch_login(host, port=DEVKIT_PORT, timeout=5):
"""GET /properties.json. Raises OSError (HTTP errors included) or ValueError."""
with _opener.open(devkit_url(host, port, "/properties.json"), timeout=timeout) as r:
return parse_login(r.read())
def register(host, body, port=DEVKIT_PORT, timeout=REGISTER_TIMEOUT):
"""POST /register, which waits while someone answers the prompt. -> (ok, message)"""
req = urllib.request.Request(devkit_url(host, port, "/register"), data=body.encode("ascii"),
headers={"Content-Type": "text/plain"}, method="POST")
try:
with _opener.open(req, timeout=timeout) as r:
return True, r.read().decode("utf-8", "replace").strip()
except urllib.error.HTTPError as e:
with e:
return False, devkit_error(e.code, e.read())
except OSError as e:
return False, f"no answer ({why(e)})"
def devkit_pair(host, pub, comment, port=DEVKIT_PORT, on_login=None):
"""The password-free route. -> None once paired, else the reason, which means: fall
back to copying the key with the password. on_login(user) runs before the prompt
with the login properties.json names, so the fallback uses that user too."""
try:
login = fetch_login(host, port)
except (OSError, ValueError) as e:
return f"devkit service not reachable on port {port}: {why(e)}"
if login and on_login:
on_login(login)
say(" In the headset: Steam Settings > Developer > Pair new host, then approve the request")
body = register_body(pub, comment)
ok, msg = register(host, body, port)
# The headset refuses at once unless Steam is on its "Pair new host" screen
# (verified on a Frame, 2026-09-26), so keep asking while the user opens it.
deadline = time.monotonic() + PAIRING_MODE_WAIT
while not ok and "pairing mode" in msg and time.monotonic() < deadline:
time.sleep(3)
ok, msg = register(host, body, port)
return None if ok else f"devkit pairing failed: {msg}"
def split_port(arg):
""""host:2222" -> ("host", 2222); anything else (IPv6 too) keeps port 22."""
host, sep, port = arg.rpartition(":")
if sep and port.isdigit() and ":" not in host:
return host, int(port)
return arg, 22
def port_open(host, port=22):
try:
with socket.create_connection((host, port), timeout=3):
return True
except OSError:
return False
def reachable(host, port):
"""sshd, or the devkit service, which turns sshd on once a pairing is approved."""
try:
socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError:
return False
return port_open(host, port) or port_open(host, DEVKIT_PORT)
# --- mDNS. There's no stdlib client, so this borrows dns-sd (macOS; Bonjour for
# Windows) or avahi-browse (Linux) when present, with short timeouts.
def run_for(args, seconds):
"""What a command printed within `seconds`; dns-sd never exits by itself."""
try:
out = subprocess.run(args, capture_output=True, timeout=seconds).stdout
except subprocess.TimeoutExpired as e:
out = e.stdout
except OSError:
out = b""
return (out or b"").decode("utf-8", "replace")
def parse_dns_sd_browse(text):
"""Instance names from `dns-sd -B _steamos-devkit._tcp`, deduplicated, in order."""
pat = re.compile(r"\sAdd\s+\d+\s+\d+\s+\S+\s+" + re.escape(DEVKIT_SERVICE) + r"\.\s+(.+?)\s*$")
names = []
for line in text.splitlines():
m = pat.search(line)
if m and m.group(1) not in names:
names.append(m.group(1))
return names
def parse_dns_sd_resolve(text):
"""The target host from `dns-sd -L` ("... can be reached at frame.local.:32000")."""
m = re.search(r"can be reached at (\S+?)\.?:\d+", text)
return m.group(1) if m else None
def parse_avahi(text):
"""Host names, then IPv4 addresses, from `avahi-browse -rpt` resolved ("=") lines."""
names, addrs = [], []
for line in text.splitlines():
f = line.split(";")
if len(f) >= 9 and f[0] == "=" and f[2] == "IPv4":
names.append(f[6])
addrs.append(f[7])
return list(dict.fromkeys(names + addrs))
def discover_devkit():
if shutil.which("dns-sd"):
hosts = []
for name in parse_dns_sd_browse(run_for(["dns-sd", "-B", DEVKIT_SERVICE, "local."], 3))[:4]:
host = parse_dns_sd_resolve(run_for(["dns-sd", "-L", name, DEVKIT_SERVICE, "local."], 2))
if host and host not in hosts:
hosts.append(host)
return hosts
if shutil.which("avahi-browse"):
return parse_avahi(run_for(["avahi-browse", "-rpt", DEVKIT_SERVICE], 5))
return []
HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.:%-]*")
def pick_host(arg):
if arg and not HOST_RE.fullmatch(arg):
say(f" - {arg!r} isn't a host name or IP address")
return None
for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
host, port = split_port(cand)
if reachable(host, port):
return host, port
say(f" - {cand}: not resolvable, or ports {port} and {DEVKIT_PORT} closed")
if arg:
return None
say(f" - asking mDNS for {DEVKIT_SERVICE}")
for host in discover_devkit():
if HOST_RE.fullmatch(host) and reachable(host, 22):
return host, 22
say(f" - {host}: advertised, but not reachable")
return None
def make_ssh_dir():
# Windows: no mode. Python 3.12.4+ turns 0o700 into an owner-only ACL, which locks
# the user out if the folder's owner is Administrators; the profile's ACL suffices.
if os.name == "nt":
SSH_DIR.mkdir(exist_ok=True)
else:
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
def make_key(path, kind, comment):
if path.exists():
say(f" exists: {path}")
return
bits = ["-b", "3072"] if kind == "rsa" else []
subprocess.run(["ssh-keygen", "-q", "-t", kind, *bits, "-N", "", "-C", comment, "-f", str(path)], check=True)
say(f" created {path}")
def config_block(host, port=22, user=FRAME_USER):
return [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []),
f" User {user}",
" IdentityFile ~/.ssh/id_ed25519_frame", " IdentityFile ~/.ssh/id_rsa_frame_devkit",
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
def write_config(host, port=22, user=FRAME_USER):
"""Replace our managed block and put it first: ssh uses the first value it sees per
option. The trailing "Host *" returns the rest of the file to global scope."""
make_ssh_dir()
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
kept, skip = [], False
for line in old.splitlines():
if line == BEGIN:
skip = True
elif line == END:
skip = False
elif not skip:
kept.append(line)
block = config_block(host, port, user)
tmp = CONFIG.with_name("config.frame-control.tmp")
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
if os.name != "nt":
tmp.chmod(0o600)
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
# and locked, so the swap can fail for a moment; keep trying for a while.
for attempt in range(60):
try:
os.replace(tmp, CONFIG)
return
except PermissionError:
if attempt == 0:
say(" ~/.ssh/config is in use by another ssh; waiting for it...")
time.sleep(0.5)
tmp.unlink(missing_ok=True)
raise SystemExit("~/.ssh/config stayed locked by another program. Quit Frame Control "
"and any ssh windows, then run the setup again.")
def key_login_works():
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
def configured_user():
"""The User in our managed block, so a re-run keeps one the headset named earlier."""
if not CONFIG.exists():
return None
inside = False
for line in CONFIG.read_text(encoding="utf-8").splitlines():
if line in (BEGIN, END):
inside = line == BEGIN
elif inside and line.startswith(" User "):
name = line[7:].strip()
return name if NAME_RE.fullmatch(name) else None
return None
def pair_with_devkit(host, port, user):
"""Try devkit pairing and confirm key login. -> (user, None) or (user, reason to fall back)."""
say("==> Pairing through the headset's SteamOS devkit service (no password)")
chosen = [user]
def use_login(login):
if login == chosen[0]:
return
if USER_FROM_ENV:
say(f" the headset logs in as '{login}'; keeping FRAME_USER={user}")
else:
chosen[0] = login
say(f" the headset logs in as '{login}'")
write_config(host, port, login)
try:
pub = DEVKIT_KEY.with_suffix(".pub").read_text(encoding="utf-8")
except OSError as e:
return user, f"can't read the pairing key: {e}"
reason = devkit_pair(host, pub, key_comment(platform.node()), on_login=use_login)
if reason:
return chosen[0], reason
# The approval is what turns sshd on, so it may take a moment to answer.
for _ in range(10):
if key_login_works():
return chosen[0], None
time.sleep(1)
return chosen[0], "paired, but key login still fails"
def main(argv):
if argv and argv[0] in ("-h", "--help"):
sys.exit(__doc__)
say("==> Looking for the Steam Frame")
found = pick_host(argv[0] if argv else None)
while not found:
say("Could not reach the Frame over SSH.")
say("Check: Developer Mode on and a user password set; same network; no client isolation.")
try:
typed = input("Type the Frame's IP address (Quick Settings shows it), or press Enter to quit: ").strip()
except EOFError:
typed = ""
if not typed:
return 1
found = pick_host(typed)
host, port = found
say(f" found: {host}" + (f" port {port}" if port != 22 else ""))
say("==> SSH keys")
make_ssh_dir()
make_key(KEY, "ed25519", f"{platform.node() or 'computer'}->steam-frame")
make_key(DEVKIT_KEY, "rsa", key_comment(platform.node()))
user = FRAME_USER if USER_FROM_ENV else (configured_user() or FRAME_USER)
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
write_config(host, port, user)
say("==> Checking key login")
if key_login_works():
say(" key login already works")
else:
user, reason = pair_with_devkit(host, port, user)
if not reason:
say(" paired; key login OK")
else:
say(f" {reason}; falling back to the password")
say(" copying the key: enter the Developer Mode password when asked")
pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip()
r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no",
"-p", str(port), f"{user}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True)
if r.returncode != 0 or not key_login_works():
say("Key login still isn't working. Check the password and run this again.")
return 1
say(" key login OK")
say(f"\nDone. Frame Control can reach the Frame now. In a terminal: ssh {FRAME_ALIAS}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+289
View File
@@ -0,0 +1,289 @@
"""What differs between the computers Frame Control runs on: macOS, Linux, Windows.
Everything here runs on your computer, not the Frame. Python stdlib only.
CLI (used by the Electron app, so terminal handling lives in one place):
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
"""
import os
import shlex
import shutil
import ssl
import subprocess
import sys
from pathlib import Path
MAC = sys.platform == "darwin"
WINDOWS = os.name == "nt"
LINUX = not MAC and not WINDOWS
NAME = "macOS" if MAC else "Windows" if WINDOWS else "Linux"
FILE_MANAGER = "Finder" if MAC else "File Explorer" if WINDOWS else "your file manager"
# Windows' OpenSSH client can't share one connection between commands
# (no ControlMaster), so there each command opens its own.
MUX = not WINDOWS
# Popen() keyword arguments that detach a child from our console and signals.
DETACHED = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if WINDOWS
else {"start_new_session": True})
class HostError(RuntimeError):
pass
def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME."""
if MAC:
base = Path.home() / "Library" / "Application Support" / "Frame Control"
elif WINDOWS:
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
else:
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "frame-control"
return base.joinpath(*parts)
def cache_dir(*parts):
if MAC:
base = Path.home() / "Library" / "Caches" / "Frame Control"
elif WINDOWS:
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "Frame Control" / "Cache"
else:
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "frame-control"
return base.joinpath(*parts)
def control_path():
"""ssh ControlPath for the shared connection, or None where it isn't supported.
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
"""
return f"/tmp/frame-ui-{os.getuid()}-%C" if MUX else None
def which(name, *extra):
"""First executable among PATH and the extra candidate paths."""
for cand in (shutil.which(name), *extra):
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
return cand
return None
def install_hint(tool):
"""How to get a missing command-line tool on this computer."""
hints = {
"adb": {"mac": "brew install android-platform-tools",
"win": "winget install Google.PlatformTools",
"linux": "install your distribution's adb package (e.g. sudo apt install adb)"},
}
return hints[tool]["mac" if MAC else "win" if WINDOWS else "linux"]
def android_sdk_dirs():
"""Where the Android SDK usually lives, for adb."""
dirs = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")]
if MAC:
dirs += ["~/Library/Android/sdk", "/opt/homebrew/share/android-commandlinetools",
"~/.homebrew/share/android-commandlinetools"]
elif WINDOWS:
dirs += [os.path.join(os.environ.get("LOCALAPPDATA", ""), "Android", "Sdk")]
else:
dirs += ["~/Android/Sdk", "/usr/lib/android-sdk"]
return [os.path.expanduser(d) for d in dirs if d]
def adb():
exe = "adb.exe" if WINDOWS else "adb"
extra = [os.path.join(d, "platform-tools", exe) for d in android_sdk_dirs()]
if MAC:
extra += ["/opt/homebrew/bin/adb", str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"]
# The app bundles adb as a last resort: an adb you already use goes first, so
# two different adb versions don't keep restarting each other's server.
tools = os.environ.get("FRAME_CONTROL_TOOLS")
if tools:
extra.append(os.path.join(tools, exe))
env = os.environ.get("ADB")
found = (env if env and os.access(env, os.X_OK) else None) or which("adb", *extra)
if not found:
raise HostError(f"adb isn't installed on this computer: {install_hint('adb')}")
return found
def trust_bundled_cas():
"""Trust the app's CA bundle for HTTPS as well as the system's certificates.
Python on Windows only sees the root certificates already in the Windows
store, and a fresh install fetches those lazily, so Steam and F-Droid can
fail with CERTIFICATE_VERIFY_FAILED. The app bundles curl's copy of Mozilla's
CA list (app/build/fetch-deps.js); outside the app this does nothing. Call it
before the first urlopen: urllib keeps the HTTPS context it builds then.
"""
tools = os.environ.get("FRAME_CONTROL_TOOLS")
cafile = os.path.join(tools, "cacert.pem") if tools else None
if not cafile or not os.path.isfile(cafile):
return
def context(*args, **kwargs):
ctx = ssl.create_default_context(*args, **kwargs)
ctx.load_verify_locations(cafile)
return ctx
ssl._create_default_https_context = context # urllib's default for HTTPS
def open_path(path):
"""Show a folder or file in the file manager."""
path = str(path)
if WINDOWS:
os.startfile(path) # noqa: pylint only on Windows
return
opener = "open" if MAC else which("xdg-open")
if not opener:
raise HostError("xdg-open isn't installed, so the folder can't be opened")
subprocess.Popen([opener, path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **DETACHED)
open_url = open_path # the same openers hand URLs to the default browser
def _spawn(argv):
subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **DETACHED)
def open_terminal(argv, title="Frame Control"):
"""Run argv in a new terminal window, for anything that asks for a password.
The window stays open after the command ends, so its output can be read.
"""
argv = [str(a) for a in argv]
if MAC:
command = shlex.join(argv).replace("\\", "\\\\").replace('"', '\\"')
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
"-e", f'do script "{command}"', "-e", "activate", "-e", "end tell"],
capture_output=True, text=True)
if r.returncode != 0:
# Usually macOS Automation consent for Terminal was denied.
raise HostError(f"Couldn't open Terminal: {r.stderr.strip()}")
return "Terminal"
if WINDOWS:
# `start` gives the command its own console window; cmd /k keeps it open.
# One hand-built command line: quoting it twice through list2cmdline would
# produce backslash-escaped quotes, which cmd doesn't understand.
# Every argument is quoted, so cmd treats & | < > ^ in them literally. cmd has
# no escape for a quote inside quotes (and expands %VAR% regardless), so refuse those.
if any(c in a for a in argv for c in '"%\r\n'):
raise HostError("Can't pass quotes or % to a Windows terminal")
inner = " ".join(f'"{a}"' for a in argv)
subprocess.Popen(f'cmd.exe /c start "{title}" cmd.exe /k "{inner}"', **DETACHED)
return "a terminal window"
script = f'{shlex.join(argv)}; echo; read -r -p "Press Enter to close. " _'
# flags=None: the terminal takes the whole command as one string after -e.
for name, flags in (("x-terminal-emulator", ["-e"]), ("gnome-terminal", ["--"]), ("ptyxis", ["--"]),
("kgx", ["--"]), ("konsole", ["-e"]), ("xfce4-terminal", ["-x"]),
("tilix", None), ("lxterminal", None), ("kitty", []), ("alacritty", ["-e"]),
("wezterm", ["start", "--"]), ("foot", []), ("xterm", ["-e"])):
exe = which(name)
if not exe:
continue
if flags is None:
_spawn([exe, "-e", "bash -c " + shlex.quote(script)])
elif name == "x-terminal-emulator" and "lxterminal" in os.path.realpath(exe):
_spawn([exe, "-e", "bash -c " + shlex.quote(script)]) # Debian alternative -> lxterminal
else:
_spawn([exe, *flags, "bash", "-c", script])
return name
raise HostError("No terminal program found (tried gnome-terminal, konsole, xterm and others)")
def clipboard_text():
"""The text on this computer's clipboard."""
if MAC:
cmds = [["pbpaste"]]
elif WINDOWS:
cmds = [["powershell.exe", "-NoProfile", "-Command",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard -Raw"]]
else:
cmds = [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"],
["xsel", "--clipboard", "--output"]]
for cmd in cmds:
if not shutil.which(cmd[0]):
continue
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
if r.returncode == 0:
text = r.stdout.decode("utf-8", errors="replace")
return text[:-2] if WINDOWS and text.endswith("\r\n") else text
if LINUX:
raise HostError("Can't read the clipboard: install wl-clipboard (Wayland) or xclip (X11)")
raise HostError("Can't read the clipboard")
def ssh_hostname(alias):
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
try:
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return alias
for line in out.splitlines():
if line.startswith("hostname "):
return line.split(None, 1)[1].strip()
return alias
# Apps the UI can hand off to, per platform: (installed-check, launch argv) pairs,
# and where to get the app when none is installed.
def open_steam_link():
if MAC:
if subprocess.run(["open", "-a", "Steam Link"], capture_output=True).returncode == 0:
return "Opened Steam Link"
elif WINDOWS:
for base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles")):
exe = base and os.path.join(base, "Steam Link", "SteamLink.exe")
if exe and os.path.isfile(exe):
_spawn([exe])
return "Opened Steam Link"
else:
if which("steamlink"):
_spawn([which("steamlink")])
return "Opened Steam Link"
if which("flatpak") and subprocess.run(["flatpak", "info", "com.valvesoftware.SteamLink"],
capture_output=True).returncode == 0:
_spawn(["flatpak", "run", "com.valvesoftware.SteamLink"])
return "Opened Steam Link"
open_url("https://store.steampowered.com/remoteplay")
return "Steam Link isn't installed; opened its download page"
def open_rdp(alias):
"""Remote desktop to the Frame's xrdp (user steamos)."""
host = ssh_hostname(alias)
if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
return "Opened Windows App"
open_url("https://apps.apple.com/app/windows-app/id1295203466")
return "Windows App isn't installed; opened its App Store page"
if WINDOWS:
_spawn(["mstsc.exe", f"/v:{host}"])
return f"Opened Remote Desktop to {host}"
if which("remmina"):
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
return f"Opened Remmina to {host}"
for name in ("xfreerdp3", "xfreerdp"):
if which(name):
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}"
raise HostError("No RDP client found: install Remmina or FreeRDP")
def main(argv):
if len(argv) >= 3 and argv[0] == "terminal" and argv[1] == "--":
try:
print(f"Opened {open_terminal(argv[2:])}")
except HostError as e:
sys.exit(str(e))
return
sys.exit(__doc__)
if __name__ == "__main__":
main(sys.argv[1:])
+1 -1
View File
@@ -1,4 +1,4 @@
"""Mac side: search the Steam store and look up each result's Steam Frame rating.
"""Computer side: search the Steam store and look up each result's Steam Frame rating.
Uses the store's public endpoints (no key, no login):
api/storesearch name search, price in the IP's currency
+819
View File
@@ -0,0 +1,819 @@
"""Linux and Windows builds on the Frame as Steam "Devkit Games".
A .zip, a folder or a single executable becomes a title in the Steam library,
through the same path as Valve's SteamOS Devkit Client: its devkit-utils
(vendored in frame/devkit-utils, synced to ~/devkit-utils on the Frame) make
~/devkit-game/<id>/, the files are copied there, and steam-client-create-shortcut
asks the running Steam client to register it with a runtime:
Windows .exe -> Proton Experimental (steam_play=1; x86-64 runs through FEX)
aarch64 ELF -> Steam Linux Runtime 4.0 ARM64 (steam_play=0)
x86-64 ELF -> Steam Linux Runtime 4.0 (steam_play=0; runs through FEX)
Everything device-side is inferred from Valve's steamos-devkit source until
checked on a headset; see docs/sideloading.md.
Python stdlib only. CLI:
python3 ui/frame_titles.py inspect PATH
python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R]
python3 ui/frame_titles.py list | launch ID | remove ID
"""
import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
import frame_android
import frame_host
from frame_android import FrameError
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UTILS_LOCAL = os.path.join(ROOT, 'frame', 'devkit-utils')
UTILS = 'devkit-utils' # on the Frame, relative to $HOME (where Valve's client puts it)
GAMES = 'devkit-game' # ditto; steamos-prepare-upload makes <id>/ in here
STAMP = '.frame-control-stamp'
PY = 'python3 ~/' + UTILS + '/'
# Valve's reserved sideload names: uploading one of these replaces the Steam client itself.
# devkit-steam is the trampoline file that switches SteamOS to a sideloaded client
# (select_steam.sh); a folder there breaks Valve's devkit tools.
RESERVED_IDS = ('steam', 'steamdeckard', 'steamvr', 'steamvrdeckard', 'devkit-steam')
ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$')
DIR_RE = re.compile(r'^/[A-Za-z0-9_./-]+$')
# Zip limits: well above any real game, well below a zip bomb.
MAX_UNPACKED = 64 * 1024**3
MAX_ENTRIES = 200000
TMP_PREFIX = 'frame-title-' # then the server's PID, so server.sweep_tmp can clear a killed run's
MAX_RATIO = 200 # uncompressed / compressed, once past 1 GB
# The Steam compat tool aliases Valve's client uses (devkit_client RUNTIME_ALIASES).
RUNTIMES = {
'proton-experimental': {'label': 'Proton Experimental', 'steam_play': True},
'proton-stable': {'label': 'Proton (stable)', 'steam_play': True},
'SteamLinuxRuntime_4-arm64': {'label': 'Steam Linux Runtime 4.0 (ARM64)', 'steam_play': False},
'SteamLinuxRuntime_4': {'label': 'Steam Linux Runtime 4.0 (x86-64, through FEX)', 'steam_play': False},
}
# Experimental rather than stable: the Frame's ARM64 Proton + FEX stack is new,
# and Proton fixes reach Experimental first. --runtime proton-stable switches.
DEFAULT_PROTON = 'proton-experimental'
ELF_MACHINES = {0xB7: 'arm64', 0x3E: 'x86_64', 0x03: 'x86', 0x28: 'arm'}
PE_MACHINES = {0x8664: 'x86_64', 0xAA64: 'arm64', 0x14C: 'x86', 0x1C4: 'arm'}
# Executables that are never the game: crash reporters, installers, redistributables.
SKIP_RE = re.compile(r'crash|unins|setup|install|redist|dxsetup|dxwebsetup|dotnet|prereq|'
r'easyanticheat|eac_|updater|uploader|report|sandbox|helper', re.I)
SKIP_DIRS = re.compile(r'^(_*commonredist|redist|redistributables?|directx|vcredist|__installer|'
r'installers?|prereqs?|support|engine|__macosx)$', re.I)
# Trailing words of an archive name that describe the build, not the game.
BUILD_WORDS = re.compile(r'([ ._-]+(win(dows)?(32|64)?|linux(32|64)?|x64|x86(_64)?|amd64|arm64|aarch64|'
r'build|release|portable|steamos|v\d+([._]\d+)*|\d+([._]\d+)+))+$', re.I)
def classify(path):
"""{'format': 'elf'|'pe'|'script', 'arch', 'exe'} for an executable file, else None."""
try:
with open(path, 'rb') as f:
head = f.read(4096)
if head[:4] == b'\x7fELF':
return _elf(f, head)
if head[:2] == b'MZ':
return _pe(f, head)
except (OSError, struct.error, ValueError, OverflowError):
return None # unreadable, or a header that lies about its sizes
if head[:2] == b'#!' or path.lower().endswith('.sh'):
return {'format': 'script', 'arch': None, 'exe': True}
return None
def _elf(f, head):
if len(head) < 64 or head[4] not in (1, 2) or head[5] not in (1, 2):
return None
wide, end = head[4] == 2, '<' if head[5] == 1 else '>'
e_type, machine = struct.unpack_from(end + 'HH', head, 16)
arch = ELF_MACHINES.get(machine, f'elf-0x{machine:x}')
if e_type == 2: # ET_EXEC
return {'format': 'elf', 'arch': arch, 'exe': True}
if e_type != 3: # not ET_DYN either: object file, core dump
return {'format': 'elf', 'arch': arch, 'exe': False}
# ET_DYN is a PIE executable or a shared library; only executables ask for an interpreter.
if wide:
phoff, = struct.unpack_from(end + 'Q', head, 32)
phentsize, phnum = struct.unpack_from(end + 'HH', head, 54)
else:
phoff, = struct.unpack_from(end + 'I', head, 28)
phentsize, phnum = struct.unpack_from(end + 'HH', head, 42)
if phentsize < (56 if wide else 32) or phnum > 256:
return {'format': 'elf', 'arch': arch, 'exe': False}
f.seek(phoff)
table = f.read(phentsize * phnum)
interp = any(struct.unpack_from(end + 'I', table, i * phentsize)[0] == 3 # PT_INTERP
for i in range(len(table) // phentsize))
return {'format': 'elf', 'arch': arch, 'exe': interp}
def _pe(f, head):
if len(head) < 0x40:
return None
lfanew, = struct.unpack_from('<I', head, 0x3C)
f.seek(lfanew)
coff = f.read(24)
if len(coff) < 24 or coff[:4] != b'PE\0\0':
return None # a DOS program, or not an executable at all
machine, = struct.unpack_from('<H', coff, 4)
characteristics, = struct.unpack_from('<H', coff, 22)
return {'format': 'pe', 'arch': PE_MACHINES.get(machine, f'pe-0x{machine:x}'),
'exe': not characteristics & 0x2000} # IMAGE_FILE_DLL
def title_id(name):
"""The Devkit Game id Steam shows as the title's name: [A-Za-z0-9_-], at most 64."""
s = re.sub(r'[^A-Za-z0-9_-]+', '_', str(name or '').strip())
s = re.sub(r'_+', '_', s).strip('_-')[:64].strip('_-')
if not s:
raise FrameError('the title needs a name with some letters or digits')
if s.lower() in RESERVED_IDS:
s += '-game'
return s
def display_name(filename):
"""A title name from a zip, folder or exe name: no extension or build/platform words."""
base = os.path.basename(str(filename).rstrip('/\\'))
stem, ext = os.path.splitext(base)
if ext.lower() in ('.zip', '.exe', '.sh', '.x86_64', '.arm64', '.aarch64', '.bin'):
base = stem
return BUILD_WORDS.sub('', base) or base
def _norm(s):
return re.sub(r'[^a-z0-9]', '', s.lower())
# ---- payload: zip, folder or single file -> a local tree to upload ----------
def extract_zip(zpath, dest):
"""Unpack a zip into dest, refusing paths that escape it and absurd sizes."""
try:
z = zipfile.ZipFile(zpath)
except (zipfile.BadZipFile, OSError) as e:
raise FrameError(f'{os.path.basename(zpath)} is not a readable zip: {e}')
with z:
infos = z.infolist()
if len(infos) > MAX_ENTRIES:
raise FrameError(f'the zip has {len(infos)} entries; the limit is {MAX_ENTRIES}')
total = sum(i.file_size for i in infos)
packed = max(1, os.path.getsize(zpath))
if total > MAX_UNPACKED or (total > 1024**3 and total > packed * MAX_RATIO):
raise FrameError(f'the zip would unpack to {total / 1024**3:.1f} GB, which looks wrong')
free = shutil.disk_usage(dest).free
if total + 256 * 1024**2 > free:
raise FrameError(f'not enough space on this computer to unpack the zip '
f'({total / 1024**3:.1f} GB needed, {free / 1024**3:.1f} GB free)')
try:
_extract_members(z, infos, os.path.realpath(dest))
except FrameError:
raise # a RuntimeError too, but already worded
except (zipfile.BadZipFile, RuntimeError, NotImplementedError, EOFError, OSError) as e:
# Encrypted or corrupt members, unsupported compression, clashing names, a full disk.
raise FrameError(f'could not unpack {os.path.basename(zpath)}: {e}')
def _extract_members(z, infos, root):
# No symlink is ever created here, so no write can be redirected through one
# (chained links, Windows without the privilege). Links inside the zip are
# resolved on paper and materialised as copies once the real files are out.
links = {}
for info in infos:
rel = _safe_member(info.filename)
if rel is None:
continue
target = _inside(root, rel, info.filename)
mode = info.external_attr >> 16
if info.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
if stat.S_ISLNK(mode):
if info.file_size > 4096: # a link's content is a path, never this big
raise FrameError(f'the zip has an oversized link: {info.filename}')
link = z.read(info).decode('utf-8', 'replace').replace('\\', '/')
dest = posixpath.normpath(posixpath.join(posixpath.dirname(rel), link))
if link.startswith('/') or ':' in link or dest == '..' or dest.startswith('../'):
raise FrameError(f'the zip has a link that points outside it: {info.filename}')
links[rel] = link # as written: resolved later, one component at a time
continue
with z.open(info) as src, open(target, 'wb') as out:
shutil.copyfileobj(src, out, 1 << 20)
if mode & 0o111:
os.chmod(target, 0o755)
_materialise_links(root, links)
def _inside(root, rel, name):
"""rel's path under root, refusing anything that resolves outside it."""
target = os.path.join(root, *rel.split('/'))
if not (os.path.realpath(target) + os.sep).startswith(root + os.sep):
raise FrameError(f'the zip has a path that climbs out of it: {name}')
return target
def _resolve_link(path, links):
"""path with every link in it followed, on paper; None if it loops or leaves the zip.
Like the kernel: each component in turn, so 'dirlink/..' is the parent of
where dirlink points, not the folder dirlink sits in.
"""
todo, done, hops = path.split('/'), [], 0
while todo:
part = todo.pop(0)
if part in ('', '.'):
continue
if part == '..':
if not done:
return None
done.pop()
continue
done.append(part)
text = links.get('/'.join(done))
if text is not None:
hops += 1
if hops > 40:
return None
done.pop() # link text is relative to the link's folder
todo = text.split('/') + todo
return '/'.join(done)
def _materialise_links(root, links):
"""Copy the file each link names into its place (lib.so.1 -> lib.so.1.2.3 and the like).
Only links to files: a folder link could hold itself, and game builds link
libraries, not folders. Folder, looping and dangling links are dropped.
"""
copies = []
for rel in sorted(links):
dest = _resolve_link(rel, links)
if not dest:
continue # loops, escapes, or the zip's own top folder
src, target = _inside(root, dest, rel), _inside(root, rel, rel)
if os.path.isfile(src) and not os.path.lexists(target): # a real entry may have the name
copies.append((src, target))
# Many links to one big file could fill the disk: the same limits as the zip itself.
need = sum(os.path.getsize(src) for src, _ in copies)
if need + _tree_size(root) > MAX_UNPACKED:
raise FrameError('the zip\'s links would copy more than it holds, which looks wrong')
if need + 256 * 1024**2 > shutil.disk_usage(root).free:
raise FrameError(f'not enough space on this computer for the zip\'s linked files ({need / 1024**3:.1f} GB)')
for src, target in copies:
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copy2(src, target)
def _safe_member(name):
"""The member's relative path with / separators, None to skip it; raises if it escapes."""
rel = name.replace('\\', '/')
if rel.startswith('/') or re.match(r'^[A-Za-z]:', rel):
raise FrameError(f'the zip has an absolute path: {name}')
parts = [p for p in rel.split('/') if p not in ('', '.')]
if any(p == '..' for p in parts):
raise FrameError(f'the zip has a path that climbs out of it: {name}')
if any(':' in p for p in parts):
# Drive-qualified parts ('C:..') climb out on Windows; ':' is an NTFS stream elsewhere.
raise FrameError(f'the zip has a path with a drive or stream name: {name}')
if not parts or parts[0] == '__MACOSX' or parts[-1] in ('.DS_Store', 'Thumbs.db'):
return None
return '/'.join(parts)
def _redirected(path, expected):
"""True if path is a symlink, or resolves somewhere else (a Windows junction isn't islink)."""
return os.path.islink(path) or os.path.normcase(os.path.realpath(path)) != os.path.normcase(expected)
def _has_links(root):
real = os.path.realpath(root)
for dirpath, dirnames, filenames in os.walk(real):
for n in dirnames + filenames:
if _redirected(os.path.join(dirpath, n), os.path.join(dirpath, n)):
return True
return False
def _stage_folder(src, dest):
"""Copy src to dest; links to files inside src become copies, all other links are left out."""
real = os.path.realpath(src)
inside = lambda p: os.path.normcase(p).startswith(os.path.normcase(real) + os.sep) # noqa: E731
folders, copies = [], [] # decide everything first, so the space check sees the same files
for dirpath, dirnames, filenames in os.walk(real):
out = os.path.join(dest, os.path.relpath(dirpath, real))
folders.append(out)
# Only descend into real folders: not symlinked ones, not junctions (os.walk follows those).
linked = [d for d in dirnames if _redirected(os.path.join(dirpath, d), os.path.join(dirpath, d))]
dirnames[:] = [d for d in dirnames if d not in linked]
for fn in filenames + linked:
target = os.path.realpath(os.path.join(dirpath, fn))
if inside(target) and os.path.isfile(target):
copies.append((target, os.path.join(out, fn)))
if shutil.disk_usage(os.path.dirname(dest)).free < sum(os.path.getsize(t) for t, _ in copies) + 256 * 1024**2:
raise FrameError('not enough space on this computer to stage the folder')
for out in folders:
os.makedirs(out, exist_ok=True)
for target, out in copies:
shutil.copy2(target, out)
return dest
def _below(top, root):
"""The folders _unwrap stepped through from top to root, as 'a/b', or ''. Taken
before staging moves root elsewhere (possibly another drive on Windows)."""
rel = os.path.relpath(root, os.path.realpath(top)).replace(os.sep, '/')
return '' if rel == '.' else rel
def _unwrap(root):
"""Step into a single top-level folder, the usual shape of a zipped build.
Never through a link or junction: the folder you chose (or unpacked) stays the boundary.
"""
root = os.path.realpath(root)
for _ in range(4):
entries = [e for e in os.listdir(root) if e not in ('__MACOSX', '.DS_Store', 'Thumbs.db')]
if len(entries) != 1:
break
only = os.path.join(root, entries[0])
if not os.path.isdir(only) or _redirected(only, only):
break
root = only
return root
def candidates(root, title=''):
"""Executables under root, best launch target first."""
found = []
want = _norm(title)
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath, d)))
rel_dir = os.path.relpath(dirpath, root)
parts = [] if rel_dir == '.' else rel_dir.split(os.sep)
for fn in sorted(filenames):
full = os.path.join(dirpath, fn)
if os.path.islink(full) or not os.path.isfile(full):
continue
c = classify(full)
if not c or not c['exe']:
continue
stem = _norm(os.path.splitext(fn)[0])
skip = bool(SKIP_RE.search(fn)) or any(SKIP_DIRS.match(p) for p in parts)
match = 2 if want and stem == want else 1 if want and stem and (want in stem or stem in want) else 0
found.append({'path': '/'.join(parts + [fn]), 'format': c['format'], 'arch': c['arch'],
'size': os.path.getsize(full), 'depth': len(parts), 'skip': skip, 'match': match})
found.sort(key=_rank)
_prefer_launcher_script(found)
return found
def _platform_rank(c):
# Native ARM64 first, then Proton, then x86-64 Linux through FEX; scripts are placed separately.
order = {('elf', 'arm64'): 0, ('pe', 'x86_64'): 1, ('elf', 'x86_64'): 2, ('pe', 'x86'): 3, ('pe', 'arm64'): 3}
return order.get((c['format'], c['arch']), 5 if c['format'] == 'script' else 6)
def _rank(c):
return (c['skip'], _platform_rank(c), -c['match'], c['depth'], -c['size'], c['path'])
def _prefer_launcher_script(found):
"""A top-level shell script beats a Linux binary one folder down (run.sh + bin/game)."""
if not found or found[0]['format'] != 'elf' or found[0]['depth'] == 0:
return
for i, c in enumerate(found):
if c['format'] == 'script' and c['depth'] == 0 and not c['skip']:
found.insert(0, found.pop(i))
return
def runtime_for(target, found=()):
"""(compat tool alias, note) for a launch target, or raise FrameError if it can't run."""
fmt, arch = target['format'], target['arch']
if fmt == 'script':
# A script runs in the runtime of the binaries next to it; alone, natively.
elf = next((c for c in found if c['format'] == 'elf' and not c['skip']), None)
if elf:
return runtime_for(elf)[0], 'A shell script; runtime chosen from the Linux binary next to it.'
return 'SteamLinuxRuntime_4-arm64', 'A shell script with no Linux binary beside it; run natively.'
if fmt == 'pe':
if arch not in ('x86_64', 'x86', 'arm64'):
raise FrameError(f"{target['path']} is a Windows program for {arch}, which Proton can't run")
note = 'Windows x86-64 build: Proton runs it through FEX.' if arch == 'x86_64' else \
f'Windows {arch} build under Proton.'
return DEFAULT_PROTON, note
if fmt == 'elf' and arch == 'arm64':
return 'SteamLinuxRuntime_4-arm64', 'Native ARM64 Linux build.'
if fmt == 'elf' and arch == 'x86_64':
return 'SteamLinuxRuntime_4', ("x86-64 Linux build: probably won't start. It needs the x86-64 Steam "
"Linux Runtime 4.0, which the Frame didn't install for a sideloaded title "
"(2026-09-26). Use an ARM64 or Windows build if there is one.")
raise FrameError(f"{target['path']} is a {arch} Linux program; the Frame runs ARM64 and x86-64 (through FEX) only")
def allowed_runtimes(target, found=()):
alias, _ = runtime_for(target, found)
return ['proton-experimental', 'proton-stable'] if RUNTIMES[alias]['steam_play'] else [alias]
def inspect(path, name=None):
"""Read a .zip, folder or executable into an install plan (a JSON-safe dict).
A zip is unpacked into a temporary folder, plan['work']; pass the plan to
discard() when done with it. Raises FrameError if nothing in it can run.
"""
path = os.path.abspath(path)
if not os.path.exists(path):
raise FrameError(f'{path} does not exist')
work = None
try:
if os.path.isdir(path):
root = _unwrap(path)
unwrapped = _below(path, root)
if _has_links(root):
# scp -r follows links, so a link out of the folder could upload
# anything; copy the folder with its links made safe first.
work = tempfile.mkdtemp(prefix=f'{TMP_PREFIX}{os.getpid()}-')
root = _stage_folder(root, os.path.join(work, os.path.basename(root)))
elif path.lower().endswith('.zip'):
work = tempfile.mkdtemp(prefix=f'{TMP_PREFIX}{os.getpid()}-')
extract_zip(path, work)
root = _unwrap(work)
unwrapped = _below(work, root)
elif classify(path):
# A single executable is uploaded on its own; don't copy a whole Downloads folder.
work = tempfile.mkdtemp(prefix=f'{TMP_PREFIX}{os.getpid()}-')
shutil.copy2(path, os.path.join(work, os.path.basename(path)))
root, unwrapped = work, ''
else:
raise FrameError(f'{os.path.basename(path)} is not a .zip, a folder or a program')
title = name or display_name(os.path.basename(path.rstrip('/\\')))
found = candidates(root, title)
if not found:
raise FrameError(f'no Linux or Windows program found in {os.path.basename(path)}')
plan = {'source': os.path.basename(path.rstrip('/\\')), 'name': title, 'id': title_id(title),
'root': root, 'work': work, 'candidates': found,
'unwrapped': unwrapped,
'size': _tree_size(root), 'warnings': []}
_choose(plan, found[0]['path'])
return plan
except BaseException:
if work:
shutil.rmtree(work, ignore_errors=True)
raise
def _tree_size(root):
total = 0
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
full = os.path.join(dirpath, fn)
if os.path.isfile(full) and not os.path.islink(full):
total += os.path.getsize(full)
return total
def _choose(plan, rel, runtime=None):
"""Set plan's launch target (a path relative to root) and its runtime."""
rel = rel.replace('\\', '/')
# A manifest may name the program as it is in the archive, above the folder
# _unwrap stepped into. A path that works as it is always wins.
prefix = plan.get('unwrapped') and plan['unwrapped'] + '/'
if (prefix and rel.startswith(prefix) and not any(c['path'] == rel for c in plan['candidates'])
and not os.path.isfile(os.path.join(plan['root'], *rel.split('/')))):
rel = rel[len(prefix):]
target = next((c for c in plan['candidates'] if c['path'] == rel), None)
if target is None:
full = os.path.realpath(os.path.join(plan['root'], *rel.replace('\\', '/').split('/')))
root = os.path.realpath(plan['root'])
if not (full + os.sep).startswith(root + os.sep) or not os.path.isfile(full):
raise FrameError(f'{rel} is not a file in the title')
c = classify(full)
if not c or not c['exe']:
raise FrameError(f"{rel} isn't a program the Frame can start")
target = {'path': os.path.relpath(full, root).replace(os.sep, '/'), 'format': c['format'],
'arch': c['arch'], 'size': os.path.getsize(full), 'depth': rel.count('/'),
'skip': False, 'match': 0}
alias, note = runtime_for(target, plan['candidates'])
allowed = allowed_runtimes(target, plan['candidates'])
if runtime:
if runtime not in allowed:
raise FrameError(f"{target['path']} can't use {runtime}; choose one of {', '.join(allowed)}")
alias = runtime
plan.update(target=target['path'], format=target['format'], arch=target['arch'], runtime=alias,
runtime_label=RUNTIMES[alias]['label'], runtimes=allowed, note=note)
plan['warnings'] = (['This looks like an installer or helper, not the game itself.'] if target['skip'] else [])
return plan
def discard(plan):
if plan and plan.get('work'):
shutil.rmtree(plan['work'], ignore_errors=True)
def argv_for(rel):
# Valve's client sends the start command as one string (it may carry arguments),
# so a path with spaces is quoted. How Steam splits it is inferred.
return ['"' + rel + '"' if re.search(r'\s', rel) else rel]
def shortcut_parms(gameid, directory, rel, runtime):
"""The JSON steam-client-create-shortcut takes, as devkit_client.new_or_ensure_game builds it."""
settings = {'steam_play': '1' if RUNTIMES[runtime]['steam_play'] else '0'}
if RUNTIMES[runtime]['steam_play']:
# gui2._update_game sends these with every Proton title; debugging stays off.
settings.update(steam_play_debug='0', steam_play_debug_version='2019')
settings['compat_tool'] = runtime
return {'gameid': gameid, 'directory': directory, 'argv': argv_for(rel), 'env': {},
'settings': settings, 'clear_settings': True, 'force_appid': '', 'lepton_args': ''}
# ---- the Frame side ----------------------------------------------------------
def ssh(cmd, input=None, timeout=120):
return frame_android.ssh(cmd, input=input, timeout=timeout)
def _json_out(out, what):
"""The JSON object a devkit-utils script prints last (its logging goes to stderr)."""
for line in reversed(out.strip().splitlines()):
line = line.strip()
if line.startswith('{') or line.startswith('['):
try:
return json.loads(line)
except ValueError:
break
raise FrameError(f'{what} gave no usable answer: {out.strip()[-300:]!r}')
def utils_stamp():
"""Hash of the vendored devkit-utils, compared with the copy on the Frame."""
h = hashlib.sha256()
for dirpath, dirnames, filenames in os.walk(UTILS_LOCAL):
dirnames[:] = sorted(d for d in dirnames if d != '__pycache__')
for fn in sorted(filenames):
if fn.endswith('.pyc'):
continue
full = os.path.join(dirpath, fn)
h.update(os.path.relpath(full, UTILS_LOCAL).replace(os.sep, '/').encode() + b'\0')
with open(full, 'rb') as f:
h.update(f.read())
return h.hexdigest()[:20]
def _json_files(gid):
# Exact names: a glob like Game-*.json would also match another title called Game-Deluxe.
return ' '.join(f'{GAMES}/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))
_utils_lock = threading.Lock()
def ensure_utils():
"""Copy frame/devkit-utils to ~/devkit-utils on the Frame unless it's already this version."""
with _utils_lock: # one sync at a time: they share a staging folder
return _ensure_utils()
def _ensure_utils():
stamp = utils_stamp()
have = ssh(f'cat {UTILS}/{STAMP} 2>/dev/null || true', timeout=30).strip()
if have == stamp:
return False
# Merge rather than replace: Valve's own client may have put newer files there.
tmp = f'.{UTILS}.frame-control'
ssh(f'rm -rf {tmp}', timeout=30)
_copy_tree(UTILS_LOCAL, tmp, timeout=300)
ssh(f'mkdir -p {UTILS} && cp -R {tmp}/. {UTILS}/ && rm -rf {tmp} {UTILS}/__pycache__ '
f'&& echo {stamp} > {UTILS}/{STAMP}', timeout=60)
return True
def _copy_tree(src, dest, timeout=3 * 3600, delete=False):
"""Copy a local folder's contents to dest on the Frame (dest ends up a copy of src).
rsync where installed (not on Windows; see server.push_file), else scp -r
into a fresh dest, which is what Windows has. dest must be a plain path.
"""
name = os.path.basename(src.rstrip('/\\'))
opts = frame_android.SSH_OPTS # read now: the server swaps in its multiplexed options
if _rsync():
cmd = ['rsync', '-a', *(['--delete'] if delete else []), '-e', shlex.join(['ssh', *opts]),
src.rstrip('/') + '/', f'{frame_android.FRAME}:{dest.rstrip("/")}/']
else:
# scp -r copies src *into* dest when dest exists, so dest must not.
ssh(f'rm -rf {shlex.quote(dest)}', timeout=60)
cmd = ['scp', *opts, '-r', src, f'{frame_android.FRAME}:{dest}']
try:
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
def _rsync():
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take the POSIX -e quoting.
return not frame_host.WINDOWS and bool(shutil.which('rsync'))
_install_lock = threading.Lock()
def install(path, name=None, exe=None, runtime=None, progress=None):
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
name: the Steam name (sanitised to the title id), default from the file name.
exe: launch target relative to the title's root, default the best candidate.
runtime: a compat tool alias from RUNTIMES that suits the target (e.g.
'proton-stable' instead of the default Proton Experimental).
progress: optional callable(stage_text, fraction 0..1).
"""
plan = inspect(path, name)
try:
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
finally:
discard(plan)
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
"""Install an inspect() plan, optionally with another name, target or runtime."""
if name:
plan['name'], plan['id'] = name, title_id(name)
if exe or runtime:
_choose(plan, exe or plan['target'], runtime)
step = progress or (lambda *a: None)
with _install_lock:
return _install(plan, step)
def _install(plan, step):
gid = plan['id']
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
raise FrameError(f'bad title id {gid!r}')
step("Syncing Valve's devkit tools to the Frame", 0.02)
ensure_utils()
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
step('Preparing the title folder', 0.05)
ready = _json_out(ssh(f'{PY}steamos-prepare-upload --gameid {gid}', timeout=60), 'steamos-prepare-upload')
directory = str(ready.get('directory') or '')
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
registered = False
try:
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
if _rsync():
_copy_tree(plan['root'], directory, delete=True)
else:
part = f"{directory.rsplit('/', 1)[0]}/.{gid}.upload"
_copy_tree(plan['root'], part)
ssh(f'rm -rf {directory} && mv {part} {directory}', timeout=120)
# Same modes Valve's client gives an upload (rsync --chmod=Du=rwx,Dgo=rx,Fu=rwx,Fog=rx).
ssh(f'chmod -R 755 {directory}', timeout=300)
step('Registering with Steam', 0.9)
parms = shortcut_parms(gid, directory, plan['target'], plan['runtime'])
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
timeout=90), 'steam-client-create-shortcut')
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')}
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
registered = True # the files stay: Steam registers them once it's running
if 'error' in reply:
raise FrameError(f"Uploaded, but Steam didn't register it: {reply['error']}. "
"With Steam running on the Frame, install it again.")
step('Done', 1.0)
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
return meta
finally:
if not registered and not existed:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
ssh(f'rm -rf {GAMES}/{gid} {GAMES}/.{gid}.upload {_json_files(gid)}', timeout=60)
except FrameError:
pass
LIST_SCRIPT = r'''
import json, os
root = os.path.expanduser('~/devkit-game')
reserved = %r
out = []
for d in sorted(os.listdir(root)) if os.path.isdir(root) else []:
if d.startswith('.') or d.lower() in reserved or not os.path.isdir(os.path.join(root, d)):
continue
t = {'id': d}
for key, suffix in (('settings', '-settings.json'), ('argv', '-argv.json'), ('meta', '-framecontrol.json')):
try:
with open(os.path.join(root, d + suffix)) as f:
t[key] = json.load(f)
except (OSError, ValueError):
t[key] = None
out.append(t)
print(json.dumps(out))
''' % (RESERVED_IDS,)
def list_titles():
"""The Devkit Games on the Frame (any uploaded by Valve's client too)."""
raw = _json_out(ssh('python3 -', input=LIST_SCRIPT, timeout=30), 'the title list')
titles = []
for t in raw if isinstance(raw, list) else []:
if not ID_RE.match(str(t.get('id', ''))):
continue
settings, meta = t.get('settings') or {}, t.get('meta') or {}
argv = t.get('argv') if isinstance(t.get('argv'), list) else []
alias = str(settings.get('compat_tool') or '')
titles.append({'id': t['id'], 'name': str(meta.get('name') or t['id']),
'target': str(meta.get('target') or (argv[0] if argv else '')),
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
'frame_control': bool(meta)})
return titles
def _check_id(gid):
gid = str(gid or '')
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
raise FrameError(f'bad title id {gid!r}')
if ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() != 'yes':
raise FrameError(f'{gid} is not installed')
return gid
def launch(gid):
gid = _check_id(gid)
ensure_utils()
# steam-devkit-rpc logs 'success' when Steam answers, but exits 0 after a
# 5 s timeout too, so read its log (stderr) rather than trust the exit code.
out = ssh(f'{PY}steam-devkit-rpc run-game gameid={gid} 2>&1', timeout=60)
if 'success' not in [line.strip() for line in out.splitlines()]:
raise FrameError(f"Steam didn't confirm the launch: {out.strip()[-300:] or 'no answer'}")
return {'id': gid}
def remove(gid):
# Not while an install runs: it could be this title, half copied or about to register.
if not _install_lock.acquire(blocking=False):
raise FrameError('an install is running; remove the title when it has finished')
try:
gid = _check_id(gid)
ensure_utils()
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
ssh(f'rm -f {_json_files(gid)}', timeout=30)
return {'id': gid}
finally:
_install_lock.release()
def public(plan):
"""A plan without its local paths, for the UI, with the runtimes each candidate may use."""
out = {k: v for k, v in plan.items() if k not in ('root', 'work', 'candidates')}
out['candidates'] = []
for c in plan['candidates']:
c = dict(c)
try:
c['runtimes'] = allowed_runtimes(c, plan['candidates'])
except FrameError as e:
c['runtimes'], c['blocked'] = [], str(e)
out['candidates'].append(c)
out['runtime_labels'] = {k: v['label'] for k, v in RUNTIMES.items()}
return out
def main():
cmd, *args = sys.argv[1:] or ['help']
def opt(flag):
return args[args.index(flag) + 1] if flag in args and args.index(flag) + 1 < len(args) else None
try:
if cmd == 'inspect' and args:
plan = inspect(args[0], opt('--name'))
try:
if opt('--exe') or opt('--runtime'):
_choose(plan, opt('--exe') or plan['target'], opt('--runtime'))
r = public(plan)
finally:
discard(plan)
elif cmd == 'install' and args:
r = install(args[0], name=opt('--name'), exe=opt('--exe'), runtime=opt('--runtime'),
progress=lambda text, _: print(text + '…', file=sys.stderr))
elif cmd == 'list':
r = list_titles()
elif cmd in ('launch', 'remove') and args:
r = globals()[cmd](args[0])
else:
sys.exit(__doc__)
except FrameError as e:
sys.exit(f'error: {e}')
print(json.dumps(r, indent=1))
if __name__ == '__main__':
main()
+468
View File
@@ -0,0 +1,468 @@
"""Install links from websites: frame-control://install?manifest=URL or ?url=URL.
The app hands the link to the page, the page shows what it will install and
asks the user first, and only then does this module download the file and pass
it to the installer for its type (dispatch()). See docs/web-install.md.
A manifest is the same JSON FrameDrop uses, so one works for both tools:
{"schema": "framedrop.install/v1", "name": "My Game",
"files": [{"url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "..."}]}
"frame-control.install/v1" is accepted with the same shape.
Rules: HTTPS only, except http(s)://localhost or 127.0.0.1 for testing, and then
only with FRAME_CONTROL_LOCAL_LINKS=1 set and when the link itself points there.
No credentials in URLs, no private, loopback, link-local or CGNAT addresses
(checked on every redirect, and the connection goes to the address that was
checked, so DNS can't change it in between). The file URL must end in a file name.
Python stdlib only, 3.9 compatible.
"""
import hashlib
import http.client
import ipaddress
import json
import os
import errno
import re
import select
import socket
import ssl
import tempfile
import time
from urllib.parse import unquote, urljoin, urlsplit
SCHEMAS = ("framedrop.install/v1", "frame-control.install/v1")
MAX_FILE = 4 * 1024**3 # largest download accepted
MAX_MANIFEST = 256 * 1024 # largest manifest accepted
MAX_URL = 2048
MAX_REDIRECTS = 5
TIMEOUT = 30 # seconds per socket operation
CHUNK = 1 << 20
LOCAL_HOSTS = ("localhost", "127.0.0.1")
# Off by default: otherwise any website could make the app fetch from local services.
LOCAL_LINKS_ENV = "FRAME_CONTROL_LOCAL_LINKS"
USER_AGENT = "FrameControl (+https://github.com/saphid/steam-frame)"
# What dispatch() can install, by file extension.
KINDS = {".apk": "apk", ".zip": "title", ".exe": "title"}
KIND_LABEL = {"apk": "Android app (APK)", "title": "Linux/Windows title"}
SHA256 = re.compile(r"[0-9a-fA-F]{64}")
CGNAT = ipaddress.ip_network("100.64.0.0/10")
# connect_ex() results meaning "still connecting" (the last is Windows' WSAEWOULDBLOCK).
_CONNECTING = {errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, getattr(errno, "WSAEWOULDBLOCK", 10035)}
# Swapped out by the tests, which have no network.
_getaddrinfo = socket.getaddrinfo
class WebInstallError(Exception):
pass
class Cancelled(WebInstallError):
pass
# ---- URLs -------------------------------------------------------------------
def is_public(ip):
"""True for addresses on the public internet, and nothing a LAN or this computer uses."""
ip = ipaddress.ip_address(ip)
if ip.version == 6:
if ip.ipv4_mapped:
ip = ip.ipv4_mapped
elif ip.is_site_local: # fec0::/10: deprecated, but is_global doesn't catch it
return False
elif ip.sixtofour and not is_public(ip.sixtofour):
return False
if ip.version == 4 and ip in CGNAT:
return False
return ip.is_global and not ip.is_multicast
def check_url(url, allow_local=False):
"""Validate a URL against the rules above; returns (scheme, host, port, is_local).
Resolving the name is left to connect time (see _resolve), so this needs no network.
"""
if not isinstance(url, str) or not url or len(url) > MAX_URL:
raise WebInstallError("the link must be a URL of at most %d characters" % MAX_URL)
if any(c.isspace() or ord(c) < 32 for c in url):
raise WebInstallError("the URL has spaces or control characters in it")
try:
u = urlsplit(url)
port = u.port
except ValueError as e:
raise WebInstallError(f"not a valid URL: {e}")
scheme = u.scheme.lower()
if scheme not in ("https", "http"):
raise WebInstallError(f"only https:// links are allowed, not {scheme or 'a relative URL'}")
if u.username is not None or u.password is not None or "@" in u.netloc:
raise WebInstallError("URLs with a user name or password in them aren't allowed")
host = (u.hostname or "").lower().rstrip(".")
if not host:
raise WebInstallError("the URL has no host")
local = host in LOCAL_HOSTS
if local and not allow_local:
raise WebInstallError(f"localhost links are for testing: set {LOCAL_LINKS_ENV}=1, and the link itself must point there")
if scheme == "http" and not local:
raise WebInstallError("only https:// is allowed (http:// only for localhost while testing)")
if not local:
try:
literal = ipaddress.ip_address(host)
except ValueError:
literal = None
if literal is not None and not is_public(literal):
raise WebInstallError(f"{host} is a private or local address")
return scheme, host, port or (443 if scheme == "https" else 80), local
def file_name(url):
"""The file name the URL ends in, e.g. mygame-arm64.apk."""
path = urlsplit(url).path
name = unquote(path.rsplit("/", 1)[-1])
if not name or name in (".", "..") or "/" in name or "\\" in name or name.startswith(".") \
or any(ord(c) < 32 for c in name) or len(name) > 200:
raise WebInstallError("the file URL must end in a file name, e.g. https://example.com/mygame.apk")
return name
def file_kind(name):
ext = os.path.splitext(name.lower())[1]
kind = KINDS.get(ext)
if not kind:
raise WebInstallError(f"{name}: Frame Control installs .apk, .zip and .exe files, not {ext or 'this type'}")
return kind
def _resolve(host, port, local):
"""One address to connect to; every address the name has must be public."""
if local:
return "127.0.0.1"
try:
infos = _getaddrinfo(host, port, type=socket.SOCK_STREAM)
except (OSError, UnicodeError) as e:
raise WebInstallError(f"couldn't look up {host}: {e}")
ips = [info[4][0].split("%", 1)[0] for info in infos]
if not ips:
raise WebInstallError(f"couldn't look up {host}")
for ip in ips:
if not is_public(ip):
raise WebInstallError(f"{host} points to a private or local address ({ip})")
return ips[0]
# ---- HTTP -------------------------------------------------------------------
class _Abortable:
"""Connects to an address checked beforehand, whatever DNS says by then.
raw_sock is the socket to shut down to stop the connection from another
thread (abort()): http.client drops conn.sock once a response will close
the connection, yet keeps reading the body from it.
"""
raw_sock = None
aborted = False
def _tcp(self):
"""Connect without blocking, so abort() can stop a connect that hangs."""
sock = socket.socket(socket.AF_INET6 if ":" in self._ip else socket.AF_INET, socket.SOCK_STREAM)
try:
sock.setblocking(False)
err = sock.connect_ex((self._ip, self.port))
deadline = time.monotonic() + self.timeout
while err in _CONNECTING:
if self.aborted:
raise OSError("aborted")
if time.monotonic() > deadline:
raise socket.timeout(f"timed out connecting to {self.host}")
_, writable, failed = select.select([], [sock], [sock], 0.2)
if writable or failed:
err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
if err:
raise OSError(err, os.strerror(err))
sock.settimeout(self.timeout)
self.raw_sock = sock
if self.aborted: # abort() ran just now and found nothing to shut down
raise OSError("aborted")
except BaseException:
sock.close()
raise
return sock
class _HTTPConnection(_Abortable, http.client.HTTPConnection):
def __init__(self, host, ip, port, timeout):
super().__init__(host, port, timeout=timeout)
self._ip = ip
def connect(self):
self.sock = self._tcp()
class _HTTPSConnection(_Abortable, http.client.HTTPSConnection):
"""As above, still verifying the certificate for the host name."""
def __init__(self, host, ip, port, timeout):
# urllib's default context, so the app's bundled CA list (frame_host.trust_bundled_cas) applies too.
super().__init__(host, port, timeout=timeout, context=ssl._create_default_https_context())
self._ip = ip
def connect(self):
# Wrapping detaches the plain socket, so publish the TLS one before the handshake.
sock = self._context.wrap_socket(self._tcp(), server_hostname=self.host, do_handshake_on_connect=False)
self.raw_sock = sock
try:
if self.aborted:
raise OSError("aborted")
sock.do_handshake()
except (AttributeError, ValueError) as e:
# abort()'s shutdown() can tear down the TLS state mid-way.
sock.close()
if self.aborted:
raise OSError("aborted")
raise OSError(str(e))
except BaseException:
sock.close()
raise
self.sock = sock
def _open(url, allow_local, method="GET", connected=None):
"""(connection, response) for url after redirects, each hop checked. Caller closes the connection.
connected(conn) gets each connection before it's used, for abort().
"""
for _ in range(MAX_REDIRECTS + 1):
scheme, host, port, local = check_url(url, allow_local)
ip = _resolve(host, port, local)
cls = _HTTPSConnection if scheme == "https" else _HTTPConnection
conn = cls(host, ip, port, TIMEOUT)
if connected:
connected(conn)
u = urlsplit(url)
target = (u.path or "/") + ("?" + u.query if u.query else "")
try:
conn.request(method, target, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"})
r = conn.getresponse()
except (OSError, http.client.HTTPException) as e:
conn.close()
raise WebInstallError(f"couldn't reach {host}: {e}")
if r.status in (301, 302, 303, 307, 308) and r.getheader("Location"):
url = urljoin(url, r.getheader("Location").strip())
conn.close()
continue
if r.status != 200:
conn.close()
raise WebInstallError(f"{host} answered HTTP {r.status} {r.reason}".strip())
return conn, r
raise WebInstallError(f"more than {MAX_REDIRECTS} redirects")
def _length(r):
try:
n = int(r.getheader("Content-Length") or "")
except ValueError:
return None
return n if n >= 0 else None
# ---- manifests --------------------------------------------------------------
def parse_manifest(obj):
"""{"name": ..., "file": {"url", "sha256", "size", "exe"}} from a manifest object."""
if not isinstance(obj, dict):
raise WebInstallError("the manifest must be a JSON object")
schema = obj.get("schema")
if schema not in SCHEMAS:
raise WebInstallError(f"unsupported manifest schema {schema!r} (expected {' or '.join(SCHEMAS)})")
files = obj.get("files")
if not isinstance(files, list) or not files:
raise WebInstallError("the manifest has no files")
if len(files) > 1:
raise WebInstallError(f"the manifest lists {len(files)} files; Frame Control installs one file per link for now")
entry = files[0]
if not isinstance(entry, dict) or not isinstance(entry.get("url"), str) or not entry["url"]:
raise WebInstallError("the manifest's file has no url")
sha = entry.get("sha256")
if sha is not None and (not isinstance(sha, str) or not SHA256.fullmatch(sha)):
raise WebInstallError("sha256 must be 64 hex digits")
size = entry.get("size")
if size is not None and (type(size) is not int or size <= 0):
raise WebInstallError("size must be a positive integer")
exe = entry.get("exe")
if exe is not None and (not isinstance(exe, str) or not exe or len(exe) > 300):
raise WebInstallError("exe must be a path inside the archive")
name = obj.get("name")
if name is not None and not isinstance(name, str):
raise WebInstallError("name must be a string")
return {"name": clean_name(name), "file": {"url": entry["url"], "sha256": sha.lower() if sha else None,
"size": size, "exe": exe}}
def clean_name(name):
name = re.sub(r"[\x00-\x1f\x7f]", "", name or "").strip()
return name[:120] or None
def fetch_manifest(url, allow_local):
conn, r = _open(url, allow_local)
try:
n = _length(r)
if n is not None and n > MAX_MANIFEST:
raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB")
data = r.read(MAX_MANIFEST + 1)
except (OSError, http.client.HTTPException) as e:
raise WebInstallError(f"couldn't read the manifest: {e}")
finally:
conn.close()
if len(data) > MAX_MANIFEST:
raise WebInstallError(f"the manifest is over {MAX_MANIFEST // 1024} KB")
try:
obj = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
raise WebInstallError("the manifest isn't valid JSON")
return parse_manifest(obj)
def _head_size(url, allow_local):
"""Content-Length from a HEAD request, or None; only for showing the size up front."""
try:
conn, r = _open(url, allow_local, method="HEAD")
except WebInstallError:
return None
try:
return _length(r)
finally:
conn.close()
def plan(manifest=None, url=None):
"""Everything the confirm dialog shows, fetched and checked; nothing is downloaded yet.
Exactly one of manifest (a manifest URL) or url (a direct file URL).
"""
if (manifest is None) == (url is None):
raise WebInstallError("give either manifest or url")
link = manifest if manifest is not None else url
# localhost is for testing a link on your own computer: only with the developer
# switch on, and only for a link that starts there (a public manifest can't
# point at localhost).
allow_local = check_url(link, allow_local=os.environ.get(LOCAL_LINKS_ENV) == "1")[3]
if manifest is not None:
m = fetch_manifest(manifest, allow_local)
name, f = m["name"], m["file"]
else:
name, f = None, {"url": url, "sha256": None, "size": None, "exe": None}
_, host, _, _ = check_url(f["url"], allow_local)
fname = file_name(f["url"])
kind = file_kind(fname)
size = f["size"] or _head_size(f["url"], allow_local)
if size is not None and size > MAX_FILE:
raise WebInstallError(f"{fname} is {size / 1024**3:.1f} GB; the limit is {MAX_FILE / 1024**3:.0f} GB")
return {"name": name or fname, "url": f["url"], "file": fname, "kind": kind, "kindLabel": KIND_LABEL[kind],
"host": host, "linkHost": urlsplit(link).hostname, "size": size, "sha256": f["sha256"],
"exe": f["exe"], "source": link, "allowLocal": allow_local, "sizeFromManifest": bool(f["size"])}
def abort(conn):
"""Stop conn from another thread (cancel, shutdown): unblocks a read, or makes the connect fail."""
conn.aborted = True
sock = conn.raw_sock
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def download(p, dest_dir, progress=None, cancelled=None, connected=None):
"""Download plan p's file into dest_dir; returns its path. Checks the size cap and sha256.
progress(done, total_or_None) is called as bytes arrive; cancelled() may return True to stop;
connected(conn) gets each connection before it's used, for abort().
"""
dest = os.path.join(dest_dir, p["file"])
try:
conn, r = _open(p["url"], p["allowLocal"], connected=connected)
except WebInstallError:
if cancelled and cancelled():
raise Cancelled("download cancelled")
raise
fd, part = tempfile.mkstemp(prefix=".part-", dir=dest_dir)
out = os.fdopen(fd, "wb")
ok = False
try:
total = _length(r)
expected = p["size"] if p.get("sizeFromManifest") else None
if total is not None and total > MAX_FILE:
raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit")
if expected is not None and total is not None and total != expected:
raise WebInstallError(f"the server says {total} bytes; the manifest says {expected}")
digest = hashlib.sha256()
done = 0
while True:
if cancelled and cancelled():
raise Cancelled("download cancelled")
try:
chunk = r.read(CHUNK)
except (OSError, http.client.HTTPException) as e:
if cancelled and cancelled():
raise Cancelled("download cancelled")
raise WebInstallError(f"download failed: {e}")
if not chunk:
if cancelled and cancelled(): # abort() makes the read end early
raise Cancelled("download cancelled")
break
done += len(chunk)
if done > MAX_FILE:
raise WebInstallError(f"the file is over the {MAX_FILE / 1024**3:.0f} GB limit")
digest.update(chunk)
out.write(chunk)
if progress:
progress(done, total or expected)
out.close()
if total is not None and done != total:
raise WebInstallError(f"download cut off at {done} of {total} bytes")
if expected is not None and done != expected:
raise WebInstallError(f"downloaded {done} bytes; the manifest says {expected}")
if p["sha256"] and digest.hexdigest() != p["sha256"]:
raise WebInstallError(f"{p['file']} doesn't match the manifest's sha256; not installing it")
os.replace(part, dest)
ok = True
return dest
finally:
out.close()
conn.close()
if not ok:
try:
os.remove(part)
except OSError:
pass
# ---- installing -------------------------------------------------------------
def dispatch(path, name=None, exe=None, progress=None, source=None):
"""Install a downloaded file with the installer for its type; returns {"message", "kind", "result"}.
.apk goes to frame_android (its own Lepton instance and Steam shortcut, named by
the APK's label); .zip and .exe to frame_titles. The caller has the SSH
connection ready.
"""
kind = file_kind(os.path.basename(path))
if kind == "apk":
import frame_android
try:
m = frame_android.install(path, source=source or os.path.basename(path))
except frame_android.FrameError as e:
raise WebInstallError(str(e))
return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
try:
import frame_titles
except ImportError as e:
if e.name != "frame_titles":
raise
raise WebInstallError("Linux/Windows titles need a newer Frame Control")
result = frame_titles.install(path, name=name, exe=exe, progress=progress)
msg = result.get("message") if isinstance(result, dict) else None
return {"message": msg or f"Installed {name or os.path.basename(path)}", "kind": kind, "result": result}
+332 -21
View File
@@ -221,12 +221,23 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#repDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#repDlg, #titleDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#repDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea { margin-top: 5px; }
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 8px 10px; font: inherit; }
#titleForm select:focus { outline: none; border-color: var(--blue); }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note.warn { color: #d9a23a; }
#titleList { margin-top: 8px; }
#wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; }
#wiFacts dt { color: var(--muted); }
#wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
#wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; }
#wiProg:not([hidden]) { display: block; }
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
@@ -384,8 +395,8 @@
<section id="shots">
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
<button class="small" id="shotsRefresh">Refresh</button>
<button class="small" id="shotsFolder" title="Open ~/Pictures/SteamFrame">Show in Finder</button>
<button class="action small" id="shotsSaveNew" disabled>Save new to Mac</button>
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
</div>
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
@@ -458,14 +469,17 @@
<div class="shelf-head"><h2>Send to Frame</h2></div>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
<b>Drop files here</b>
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app.
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
<input type="file" id="fileInput" multiple hidden>
</div>
<div class="progress" id="prog"><i></i></div>
<div class="shelf-head" style="margin-top:16px"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span></div>
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button>
<button class="small" id="clipMac">Send Mac clipboard</button>
<button class="small" id="clipMac">Send this computer's clipboard</button>
</div>
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
</section>
@@ -522,7 +536,7 @@
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
</div>
<div class="hint">Sleep, Restart and Shut down open Terminal for the Developer Mode password.</div>
<div class="hint">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
<div class="links">
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
@@ -567,6 +581,32 @@
<button type="submit" class="action small" id="repSave">Save report</button></div>
</form>
</dialog>
<dialog id="titleDlg" aria-labelledby="titleTitle">
<form method="dialog" id="titleForm">
<h2 id="titleTitle">Add to the Steam library</h2>
<div class="sub" id="titleSrc"></div>
<label>Name<input type="text" id="titleName" maxlength="120" required></label>
<div class="note" id="titleIdNote"></div>
<label>Launches<select id="titleExe"></select></label>
<label>Runtime<select id="titleRt"></select></label>
<div class="note" id="titleNote"></div>
<div class="note warn" id="titleWarn"></div>
<div class="row rep-actions"><span class="sub" id="titleMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="titleCancel">Cancel</button>
<button type="button" class="small" id="titlePush">Copy to ~/Downloads instead</button>
<button type="submit" class="action small" id="titleInstall">Install</button></div>
</form>
</dialog>
<dialog id="wiDlg" aria-labelledby="wiTitle">
<h2 id="wiTitle">Install from a website</h2>
<dl id="wiFacts"></dl>
<p id="wiWarn">A website asked Frame Control to install this. Nothing is downloaded until you click Install.
Only install software from sites you trust.</p>
<div class="progress" id="wiProg" hidden><i></i></div>
<div class="row rep-actions"><span class="sub" id="wiMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="wiCancel">Cancel</button>
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
</dialog>
<div class="toast" id="toast"></div>
<script>
@@ -613,6 +653,15 @@ $("bottombar").onclick = () => {
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
};
// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
$("clipMac").textContent = `Send ${h.computer} clipboard`;
}
async function api(path, body) {
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
@@ -1013,9 +1062,9 @@ document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
// ---- controls ----
$("refreshAll").onclick = refresh;
$("refreshAll").onclick = () => { refresh(); loadTitles(); }; // titles too: the Frame may have been asleep at start
document.addEventListener("keydown", e => {
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) refresh();
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
});
document.body.addEventListener("click", async (e) => {
const b = e.target.closest("button");
@@ -1060,7 +1109,14 @@ $("clipSend").onclick = () => {
if (!text) return toast("Nothing to send", true);
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
};
$("clipMac").onclick = () => act("Send Mac clipboard", () => api("/api/clipboard", { fromMac: true }), $("clipMac"));
// In the app, Electron reads the clipboard; in a browser, the server does.
async function sendComputerClipboard() {
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
const text = await window.frameApp.readClipboard();
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
return api("/api/clipboard", { text });
}
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
// ---- file drop ----
const drop = $("drop");
@@ -1069,7 +1125,12 @@ drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefau
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
["dragenter", "dragover"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.add("over"); }));
["dragleave", "drop"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.remove("over"); }));
drop.addEventListener("drop", e => sendFiles([...e.dataTransfer.files]));
// Folders only show up as directory entries, and only during the drop event itself.
drop.addEventListener("drop", e => {
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
sendFiles([...e.dataTransfer.files], dirs);
});
function upload(file, mode) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
@@ -1089,9 +1150,17 @@ function upload(file, mode) {
xhr.send(file);
});
}
async function sendFiles(files) {
for (const f of files) {
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; use scripts/push.sh`, true); continue; }
const TITLE_EXT = /\.(zip|exe)$/i;
async function sendFiles(files, dirs = new Set()) {
for (const [i, f] of files.entries()) {
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
if (dirs.has(i)) {
if (path) await sideload(f, path, true);
else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true);
continue;
}
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
}
@@ -1099,6 +1168,143 @@ async function sendFiles(files) {
refresh();
}
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"];
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
const trim = x => x.replace(/^[_-]+|[_-]+$/g, "");
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_")).slice(0, 64));
if (RESERVED_IDS.includes(s.toLowerCase())) s += "-game";
return s;
}
const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };
function candLabel(c) {
const kind = c.format === "pe" ? `Windows ${ARCH[c.arch] || c.arch}` : c.format === "elf" ? `Linux ${ARCH[c.arch] || c.arch}` : "script";
return `${c.path} · ${kind} · ${gb(c.size)}${c.skip ? " · helper?" : ""}`;
}
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
// One at a time, so a second drop doesn't take over the dialog of the first.
let sideloadQueue = Promise.resolve();
function sideload(...args) {
const run = sideloadQueue.then(() => sideloadOne(...args));
sideloadQueue = run.catch(() => {});
return run;
}
async function sideloadOne(file, path, isDir = false) {
const canPush = !isDir && file.size > 0;
log(`Reading ${file.name}…`);
let r;
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title"); }
catch (e) {
log(`${file.name}: ${e.message}`, "e");
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
return;
}
// Its own fresh list, so the dialog can say whether this replaces an installed title.
let installed = null;
try { installed = new Set((await api("/api/titles")).titles.map(t => String(t.id).toLowerCase())); } catch {}
const choice = await confirmTitle(r.plan, canPush, installed);
if (choice === "push") {
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
return;
}
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
await installTitle(r.token, choice);
}
function confirmTitle(plan, canPush, installed) { // installed: a Set of lower-case ids, or null if unknown
return new Promise(resolve => {
const dlg = $("titleDlg");
$("titlePush").hidden = !canPush;
$("titleSrc").textContent = `${plan.source} · ${gb(plan.size)}`;
$("titleName").value = plan.name;
const idNote = () => { const id = titleId($("titleName").value);
$("titleIdNote").textContent = !id ? "Needs some letters or digits"
: !installed ? `Shows in Steam as ${id}. Couldn't check whether it's installed already; if it is, this replaces it`
: installed.has(id.toLowerCase()) ? `Replaces the installed ${id}, and everything in its folder on the Frame`
: `Shows in Steam as ${id}`; };
$("titleName").oninput = idNote; idNote();
$("titleExe").innerHTML = plan.candidates.map(c =>
`<option value="${esc(c.path)}"${c.path === plan.target ? " selected" : ""}${c.blocked ? " disabled" : ""}>${esc(candLabel(c))}</option>`).join("");
const runtimes = () => {
const c = plan.candidates.find(x => x.path === $("titleExe").value) || {};
const list = c.runtimes || [];
$("titleRt").innerHTML = list.map(k => `<option value="${esc(k)}">${esc(plan.runtime_labels[k] || k)}</option>`).join("");
$("titleRt").disabled = list.length < 2;
if (c.path === plan.target) {
$("titleRt").value = plan.runtime;
$("titleNote").textContent = plan.note + " Device behaviour is inferred from Valve's devkit tools.";
} else $("titleNote").textContent = "Runtime follows the chosen program.";
$("titleWarn").textContent = c.skip ? "This looks like an installer or helper, not the game itself." : "";
};
$("titleExe").onchange = runtimes; runtimes();
$("titleMsg").textContent = "";
// Resolve on this dialog's own close event (it arrives a frame later), so a
// queued next title can't receive it. Escape closes with the choice left null.
let choice = null;
dlg.addEventListener("close", () => resolve(choice), { once: true });
const finish = v => { choice = v; dlg.close(); };
$("titleCancel").onclick = () => finish(null);
$("titlePush").onclick = () => finish("push");
$("titleForm").onsubmit = e => {
e.preventDefault();
if (!titleId($("titleName").value)) return ($("titleMsg").textContent = "Enter a name");
finish({ name: $("titleName").value.trim(), exe: $("titleExe").value, runtime: $("titleRt").value });
};
dlg.showModal();
$("titleName").focus();
});
}
async function installTitle(token, choice) {
const label = `Install ${titleId(choice.name)}`;
const start = await act(label, () => api("/api/titles", { action: "install", token, ...choice }));
if (!start) return;
const bar = $("prog").firstElementChild;
$("prog").style.display = "block"; bar.style.width = "0";
let stage = "";
try {
for (;;) {
await new Promise(ok => setTimeout(ok, 1000));
let j;
try { j = await api(`/api/titles/job?token=${encodeURIComponent(start.job)}`); }
catch (e) { log(`${label}: ${e.message}`, "e"); return; }
bar.style.width = (100 * (j.fraction || 0)) + "%";
if (j.stage && j.stage !== stage && !j.done) { stage = j.stage; log(`${stage}…`); $("lastLog").textContent = stage + "…"; }
if (j.done) {
if (j.error) { log(`${label} failed: ${j.error}`, "e"); toast(`${label} failed: ${j.error}`, true); }
else { log(j.message, "ok"); toast(j.message); }
break;
}
}
} finally { $("prog").style.display = "none"; loadTitles(); }
}
let titlesSeq = 0; // a slower, older request mustn't overwrite a newer list
async function loadTitles() {
const seq = ++titlesSeq;
let list, err;
try { list = (await api("/api/titles")).titles; } catch (e) { err = e; }
if (seq !== titlesSeq) return;
if (err) { $("titleList").innerHTML = `<div class="sub">${esc(err.message)}</div>`; return; }
$("titleCount").textContent = list.length ? `${list.length}` : "";
$("titleList").innerHTML = list.length ? list.map(t => `
<div class="item">
<div class="grow"><div class="t">${esc(t.id)}</div>
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
</div>`).join("") : `<div class="sub">None yet. Drop a game's .zip, folder or .exe above.</div>`;
}
$("titleList").onclick = async e => {
const b = e.target.closest("[data-title]"); if (!b) return;
const id = b.dataset.id;
if (b.dataset.title === "remove") {
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
loadTitles();
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
};
loadTitles();
// ---- Steam games: install owned games, find and buy on the store ----
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
@@ -1461,7 +1667,7 @@ async function loadReports() {
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
$("repHint").textContent = shared
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
: "Reports are saved on this Mac and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
const k = r.rating || r.result;
@@ -1522,6 +1728,7 @@ $("repForm").onsubmit = async e => {
finally { $("repSave").disabled = false; }
};
loadReports();
api("/api/host").then(applyHostWording).catch(() => {});
// ---- Steam screenshots from the headset ----
const shots = { list: [], urls: [] };
@@ -1547,14 +1754,14 @@ async function loadShots() {
} finally { $("shotsRefresh").disabled = false; }
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
const unsaved = shots.list.filter(s => !s.saved).length;
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this Mac` : "") : "";
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
$("shotsSaveNew").disabled = !unsaved;
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
<div class="row"><div class="grow">
<div class="t">${esc(shotApp(s.appid))}</div>
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
${s.saved ? `<span class="tag">On Mac</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
${s.saved ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
</div></div>`).join("")
: `<div class="sub">No screenshots on the Frame yet.</div>`;
// Thumbnails one at a time over the shared SSH connection.
@@ -1606,7 +1813,7 @@ $("shotGrid").onclick = e => {
};
$("shotsRefresh").onclick = loadShots;
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
$("shotsFolder").onclick = e => act("Show in Finder", () => api("/api/open", { what: "shots" }), e.currentTarget);
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
// ---- nav highlight follows scroll ----
const spy = new IntersectionObserver(entries => {
@@ -1615,6 +1822,110 @@ const spy = new IntersectionObserver(entries => {
}, { rootMargin: "-80px 0px -55% 0px" });
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
// The app passes each link here. The server checks it and reads the manifest;
// nothing downloads until the user clicks Install in this dialog.
const wi = { queue: [], open: false, gen: 0, plan: null, job: null, starting: false };
function wiSize(n) {
if (n == null) return "Not given";
return n >= 1e9 ? gb(n) : n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
}
function wiFacts(rows) { $("wiFacts").innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join(""); }
function wiButtons(cancel, go) {
$("wiCancel").textContent = cancel[0]; $("wiCancel").disabled = !cancel[1];
$("wiGo").hidden = !go; $("wiGo").disabled = go !== "on";
}
async function wiNext() {
if (wi.open || !wi.queue.length) return;
const req = wi.queue.shift(), gen = ++wi.gen;
wi.open = true; wi.plan = null; wi.job = null;
let host = "";
try { host = new URL(req.target).hostname; } catch {}
wiFacts([["From", host], [req.kind === "manifest" ? "Manifest" : "File", req.target]]);
$("wiProg").hidden = true; $("wiMsg").textContent = "Checking the link…";
wiButtons(["Cancel", true], "off");
$("wiDlg").showModal();
log(`Install link from ${host}`);
try {
const p = await api("/api/webinstall/check", { [req.kind]: req.target });
if (gen !== wi.gen) return;
wi.plan = p;
const from = p.host === p.linkHost ? p.host : `${p.linkHost} (file on ${p.host})`;
wiFacts([["Title", p.name], ["From", from], ["File", p.file], ["Type", p.kindLabel], ["Size", wiSize(p.size)],
["SHA-256", p.sha256 ? "Given; checked after downloading" : "Not given; the download can't be checked"]]);
$("wiMsg").textContent = "";
wiButtons(["Cancel", true], "on");
$("wiGo").focus();
} catch (e) {
if (gen !== wi.gen) return;
$("wiMsg").textContent = e.message;
wiButtons(["Close", true], null);
log(`Install link refused: ${e.message}`, "e");
}
}
async function wiPoll(gen) {
if (gen !== wi.gen || !wi.job) return;
let j;
try {
j = await api(`/api/webinstall/job?id=${encodeURIComponent(wi.job)}`);
} catch (e) {
$("wiMsg").textContent = e.message;
return setTimeout(() => wiPoll(gen), 1500);
}
if (gen !== wi.gen) return;
const bar = $("wiProg").firstElementChild;
$("wiProg").hidden = false;
if (j.phase === "download") {
bar.style.width = j.total ? (100 * j.done / j.total) + "%" : "0";
$("wiMsg").textContent = `Downloading ${wiSize(j.done)}` + (j.total ? ` of ${wiSize(j.total)}` : "");
wiButtons(["Stop download", true], "off");
} else if (j.phase === "install") {
bar.style.width = "100%";
$("wiMsg").textContent = j.detail || "Installing on the Frame…";
wiButtons(["Stop download", false], "off");
} else {
wi.job = null;
$("wiProg").hidden = true;
if (j.phase === "done") {
log(j.message, "ok"); toast(j.message);
$("wiDlg").close();
loadAndroid(); loadTitles(); refresh();
} else {
$("wiMsg").textContent = j.error;
log(`Install from link failed: ${j.error}`, "e"); toast(`Install failed: ${j.error}`, true);
wiButtons(["Close", true], null);
}
return;
}
setTimeout(() => wiPoll(gen), 500);
}
$("wiGo").onclick = async () => {
const p = wi.plan, gen = wi.gen;
if (!p) return;
wi.plan = null; // one click, one install
wiButtons(["Cancel", false], "off");
$("wiMsg").textContent = "Starting…";
wi.starting = true; // the dialog stays open until the job is known
try {
wi.job = (await api("/api/webinstall/start", { id: p.id })).job;
log(`Installing ${p.name} from ${p.host}…`);
wiPoll(gen);
} catch (e) {
$("wiMsg").textContent = e.message;
wiButtons(["Close", true], null);
} finally { wi.starting = false; }
};
$("wiCancel").onclick = async () => {
if (!wi.job) return $("wiDlg").close();
try { await api("/api/webinstall/cancel", { job: wi.job }); } catch (e) { $("wiMsg").textContent = e.message; }
};
// Escape doesn't close the dialog while an install runs; it keeps showing progress.
$("wiDlg").addEventListener("cancel", e => { if (wi.job || wi.starting) e.preventDefault(); });
$("wiDlg").addEventListener("close", () => { wi.gen++; wi.open = false; wi.plan = null; wi.job = null; setTimeout(wiNext); });
if (window.frameApp && window.frameApp.onInstallLink) {
window.frameApp.onInstallLink(req => { if (wi.queue.length < 5) wi.queue.push(req); wiNext(); });
}
setView("headset");
refresh().then(loadShots); // after status, so app names resolve
setInterval(() => { if (!document.hidden) refresh(); }, 30000);
+502 -95
View File
@@ -1,18 +1,21 @@
#!/usr/bin/env python3
"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
"""Frame Control: a small local web UI for managing the Steam Frame from a computer.
Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
up by scripts/connect.sh or ui/frame_connect.py.
Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
Env: FRAME_ALIAS (default frame)
"""
import argparse
import base64
import http.client
import json
import os
import queue
import re
import select
import secrets
import shlex
import shutil
import signal
@@ -26,19 +29,29 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
import frame_android
import frame_catalog
import frame_store
# The app runs Python with -I, which leaves the script's own folder off
# sys.path, so add it for the sibling modules below.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_android # noqa: E402
import frame_catalog # noqa: E402
import frame_host # noqa: E402
import frame_store # noqa: E402
import frame_titles # noqa: E402
import frame_webinstall # noqa: E402
frame_host.trust_bundled_cas()
HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent / "scripts"
FRAME = os.environ.get("FRAME_ALIAS", "frame")
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
# supports it (not on Windows: there every command connects on its own).
CONTROL = frame_host.control_path()
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
# Commands use the master when it's up and connect directly when it isn't.
SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
# Android helpers share the multiplexed connection when it's up.
frame_android.SSH_OPTS = SSH[1:]
@@ -82,9 +95,12 @@ def ensure_master():
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
"""
global _master
if not CONTROL:
return
def up():
try:
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
timeout=5).returncode == 0
except subprocess.TimeoutExpired:
return False
@@ -97,7 +113,7 @@ def ensure_master():
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2", "-N", FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
for _ in range(60):
if up() or _master.poll() is not None:
return
@@ -107,7 +123,10 @@ def ensure_master():
def ssh(remote, *, stdin=None, timeout=30, text=True):
try:
ensure_master()
r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True,
# Never let ssh inherit our stdin: under the app it's the pipe held open for
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}")
@@ -119,40 +138,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
return r.stdout
def script(name, *args, stdin=None, timeout=900):
"""Run one of ../scripts and return its combined output."""
try:
r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env={**os.environ, "FRAME_ALIAS": FRAME})
except subprocess.TimeoutExpired:
raise Failure(f"{name} timed out")
out = strip_ansi(r.stdout).strip()
if r.returncode != 0:
raise Failure(out or f"{name} exited {r.returncode}")
return out
def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
def terminal(command):
"""Open Terminal.app running `command` (for anything needing a password)."""
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
capture_output=True, text=True)
if r.returncode != 0:
# Usually macOS Automation consent for Terminal was denied.
raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
def open_app(name, fallback_url):
if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
return f"Opened {name}"
subprocess.run(["open", fallback_url])
return f"{name} isn't installed; opened its download page"
def terminal(argv):
"""Open a terminal window running argv (for anything needing a password)."""
try:
return frame_host.open_terminal(argv)
except frame_host.HostError as e:
raise Failure(str(e), 500)
# ---- actions ---------------------------------------------------------------
@@ -259,7 +254,7 @@ def save_shots(body):
try:
try:
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, text=True, timeout=300)
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
@@ -368,13 +363,44 @@ def set_volume(body):
return {"message": "Volume updated"}
# Runs on the Frame, clipboard text on stdin. Verified 2026-09-25 (SteamOS 0.3.0
# vr, build 20260922): the headset desktop is a nested Plasma Wayland session
# inside gamescope with its own D-Bus bus, and wl-copy/xclip are not installed.
# Klipper (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so
# borrow plasmashell's bus address. Same as scripts/paste-to-frame.sh.
PASTE = r"""set -u
text=$(cat; printf x); text=${text%x}
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
if [ -z "$pid" ]; then
echo "plasmashell is not running: open the desktop in the headset first." >&2
exit 2
fi
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
org.kde.klipper.klipper.setClipboardContents "$text" >/dev/null; then
echo "copied via Klipper (${#text} chars)"
else
echo "Klipper call failed (bus: ${bus:-none})" >&2
exit 2
fi
"""
# base64 keeps the script intact through every local shell's quoting rules.
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
def clipboard(body):
if body.get("fromMac"):
return {"message": script("paste-to-frame.sh", timeout=30)}
text = body.get("text")
if not isinstance(text, str) or not text:
raise Failure("nothing to send", 400)
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
if body.get("fromMac") or body.get("fromComputer"):
try:
text = frame_host.clipboard_text()
except frame_host.HostError as e:
raise Failure(str(e), 500)
if not text:
raise Failure("The clipboard is empty (or holds something other than text)", 400)
else:
text = body.get("text")
if not isinstance(text, str) or not text:
raise Failure("nothing to send", 400)
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
def flatpak(body):
@@ -382,7 +408,11 @@ def flatpak(body):
if not FLATPAK_ID.match(app):
raise Failure("bad Flatpak app ID", 400)
if action == "install":
return {"message": script("install-apps.sh", app)}
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
ssh("flatpak remote-add --user --if-not-exists flathub "
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
return {"message": f"Installed {app}"}
if action == "uninstall":
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
@@ -391,25 +421,25 @@ def flatpak(body):
def open_thing(body):
what = body.get("what")
alias = shlex.quote(FRAME)
if what == "terminal":
terminal(f"ssh {alias}")
return {"message": "Opened an SSH session in Terminal"}
if what in ("reboot", "poweroff", "suspend"):
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
terminal(f"ssh -t {alias} sudo systemctl {what}")
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
if what == "steamlink":
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
if what == "rdp":
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
if what == "sftp":
terminal(f"sftp {alias}")
return {"message": "Opened an SFTP session in Terminal"}
if what == "shots":
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
subprocess.run(["open", str(SHOTS_DIR)])
return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
try:
if what == "terminal":
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
if what in ("reboot", "poweroff", "suspend"):
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
if what == "steamlink":
return {"message": frame_host.open_steam_link()}
if what == "rdp":
return {"message": frame_host.open_rdp(FRAME)}
if what == "sftp":
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
if what == "shots":
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
frame_host.open_path(SHOTS_DIR)
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
except frame_host.HostError as e:
raise Failure(str(e), 500)
raise Failure("unknown target", 400)
@@ -438,13 +468,127 @@ def android(body):
runtime=body.get("runtime") or "instance",
label=body.get("label"), source=body.get("source"))
name = r.get("label") or pkg
where = "" if frame_catalog.compat_db.shared() else " on this Mac"
where = "" if frame_catalog.compat_db.shared() else " on this computer"
return {"message": f"Saved your report for {name}{where}", "report": r}
except frame_android.FrameError as e:
raise Failure(str(e))
raise Failure("unknown action", 400)
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
#
# Installing is two steps: inspect (a dropped file is uploaded and a zip
# unpacked here, once) returns a token and the detected target and runtime for
# the page to confirm; install then runs in the background with progress the
# page polls. Unconfirmed uploads are dropped after STAGE_TTL.
STAGE_TTL = 3600
_titles_lock = threading.Lock()
_staged = {} # token -> {"plan", "dir" (an upload's temp dir or None), "time"}
_title_jobs = {} # token -> {"stage", "fraction", "done", "error", "title", "time"}
def _drop_staged(entry):
frame_titles.discard(entry["plan"])
if entry.get("dir"):
shutil.rmtree(entry["dir"], ignore_errors=True)
def _purge_titles(now=None):
now = now or time.time()
with _titles_lock:
stale = [_staged.pop(t) for t in [t for t, e in _staged.items() if now - e["time"] > STAGE_TTL]]
for t in [t for t, j in _title_jobs.items() if j["done"] and now - j["time"] > STAGE_TTL]:
del _title_jobs[t]
for e in stale:
_drop_staged(e)
def stage_title(path, temp_dir=None, name=None):
"""Inspect a .zip, folder or program and keep it for install; returns the plan and a token."""
_purge_titles()
try:
plan = frame_titles.inspect(path, name)
except BaseException as e:
# Whatever went wrong, nothing will ever claim this upload.
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
if isinstance(e, frame_android.FrameError):
raise Failure(str(e), 400)
raise
token = secrets.token_hex(12)
with _titles_lock:
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
"token": token, "plan": frame_titles.public(plan)}
def _run_title_install(token, entry, name, exe, runtime):
def update(**fields): # the page reads jobs from other threads; change them under the lock
with _titles_lock:
_title_jobs[token].update(fields)
try:
m = frame_titles.install_plan(entry["plan"], name=name, exe=exe, runtime=runtime,
progress=lambda stage, fraction: update(stage=stage, fraction=fraction))
update(title=m, message=f"Installed {m['id']} in the Steam library ({m['runtime_label']})")
except frame_android.FrameError as e:
update(error=str(e))
except Exception as e:
update(error=f"{type(e).__name__}: {e}")
finally:
_drop_staged(entry)
update(done=True, time=time.time())
def titles(body):
"""Sideloaded titles (frame_titles.py): inspect a local path, install, discard, launch, remove."""
action = body.get("action")
if action == "inspect":
# The app's page passes a dropped folder's path (Electron knows it); browsers upload instead.
path = str(body.get("path") or "")
if not os.path.isabs(path) or not os.path.exists(path):
raise Failure("inspect needs the absolute path of a .zip, folder or program", 400)
return stage_title(path, name=body.get("name") or None)
if action in ("install", "discard"):
token = str(body.get("token") or "")
with _titles_lock:
entry = _staged.pop(token, None)
if not entry:
raise Failure("that upload has expired; drop the file again", 400)
if action == "discard":
_drop_staged(entry)
return {"message": "Discarded"}
with _titles_lock:
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
"message": None, "title": None, "time": time.time()}
ensure_master()
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
threading.Thread(target=_run_title_install, daemon=True,
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
return {"message": f"Installing {entry['plan']['source']}", "job": token}
if action not in ("launch", "remove"):
raise Failure("unknown action", 400)
gid = str(body.get("id", ""))
if not frame_titles.ID_RE.match(gid):
raise Failure("bad title id", 400)
ensure_master()
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
raise Failure(str(e))
return {"message": f"{'Launching' if action == 'launch' else 'Removed'} {m['id']}"}
def title_job(query):
with _titles_lock:
job = _title_jobs.get((parse_qs(query).get("token") or [""])[0])
snapshot = job and {k: v for k, v in job.items() if k != "time"}
if not snapshot:
raise Failure("no such install", 404)
return snapshot
# ---- Android display (wm size / wm density / font_scale over ADB) -----------
#
# Each running Lepton instance listens for ADB on the Frame (5555 is Lepton
@@ -464,16 +608,15 @@ _live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the
def adb_path():
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
if cand and os.access(cand, os.X_OK):
return cand
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
try:
return frame_host.adb()
except frame_host.HostError as e:
raise Failure(str(e), 500)
def adb(adb_bin, *args, timeout=20):
try:
r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
r = subprocess.run([adb_bin, *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"adb {' '.join(args[-2:])} timed out")
@@ -490,7 +633,7 @@ def free_local_port():
class AdbTunnel:
"""SSH forwards from Mac loopback to Frame ADB ports, plus adb connections.
"""SSH forwards from local loopback to Frame ADB ports, plus adb connections.
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
disconnects adb and kills the ssh process, whatever happened inside.
@@ -588,7 +731,7 @@ class AdbTunnel:
self._stop_ssh()
for p in self.local:
try:
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10)
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
except (subprocess.TimeoutExpired, OSError):
pass
finally:
@@ -736,12 +879,245 @@ def android_display(body):
return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now}
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots}
# ---- install links from websites (frame-control://install, docs/web-install.md) ----
# The app hands the link to the page, which asks /check (fetches the manifest,
# downloads nothing), shows what it found and waits for the user's click before
# /start. A website can't call these itself: like all of /api/* they need the
# Host and X-Frame-UI checks in Handler.local_request.
_web_lock = threading.Lock()
_web_plans = {} # id -> checked plan waiting for the user to confirm
_web_jobs = {} # id -> progress of the confirmed install (only the latest is kept)
_web_workers = set() # threads running an install, joined on shutdown
_web_closing = False # set on shutdown; no new installs after that
MAX_WEB_PLANS = 8
WEB_TMP_PREFIX = "frame-webinstall-" # then the server's PID, for sweep_tmp
def webinstall_check(body):
manifest, url = body.get("manifest"), body.get("url")
for v in (manifest, url):
if v is not None and not isinstance(v, str):
raise Failure("manifest and url must be strings", 400)
try:
plan = frame_webinstall.plan(manifest=manifest, url=url)
except frame_webinstall.WebInstallError as e:
raise Failure(str(e), 400)
pid = secrets.token_urlsafe(16)
with _web_lock:
while len(_web_plans) >= MAX_WEB_PLANS:
_web_plans.pop(next(iter(_web_plans)))
_web_plans[pid] = plan
shown = ("name", "file", "kind", "kindLabel", "host", "linkHost", "size", "source")
return {"id": pid, **{k: plan[k] for k in shown}, "sha256": bool(plan["sha256"])}
def webinstall_start(body):
pid = body.get("id")
with _web_lock:
if any(j["phase"] in ("download", "install") for j in _web_jobs.values()):
raise Failure("another install from a link is still running", 409)
# One use per check: the page can only install what it showed.
plan = _web_plans.pop(pid, None) if isinstance(pid, str) else None
if not plan:
raise Failure("unknown or already used install id; open the link again", 400)
job = {"phase": "download", "done": 0, "total": plan["size"], "detail": "", "message": None,
"error": None, "cancel": False}
if _web_closing:
raise Failure("Frame Control is quitting", 503)
_web_jobs.clear()
_web_jobs[pid] = job
# Started under the lock, so shutdown never sees a thread it can't join.
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
_web_workers.add(worker)
worker.start()
return {"job": pid}
def _webinstall_run(plan, job):
tmp = None
try:
tmp = tempfile.mkdtemp(prefix=f"{WEB_TMP_PREFIX}{os.getpid()}-")
def progress(done, total):
job["done"], job["total"] = done, total
def detail(*args, **_kw): # frame_titles may report its steps as text
texts = [a for a in args if isinstance(a, str)]
if texts:
job["detail"] = texts[0][:200]
def connected(conn):
with _web_lock:
job["_conn"] = conn
stop = job["cancel"] # cancelled before this connection existed
if stop:
frame_webinstall.abort(conn)
path = frame_webinstall.download(plan, tmp, progress=progress, cancelled=lambda: job["cancel"],
connected=connected)
# Under the lock cancel uses, so a cancel it acknowledged is never followed by an install.
with _web_lock:
if job["cancel"]:
raise frame_webinstall.Cancelled("download cancelled")
job["phase"] = "install"
job.pop("_conn", None)
ensure_master()
res = frame_webinstall.dispatch(path, name=plan["name"], exe=plan["exe"], progress=detail, source=plan["url"])
job["message"], job["phase"] = res["message"], "done"
except Exception as e:
known = (frame_webinstall.WebInstallError, Failure, frame_android.FrameError)
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
job["phase"] = "error"
finally:
with _web_lock:
job.pop("_conn", None)
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
with _web_lock:
_web_workers.discard(threading.current_thread())
def webinstall_job(query):
job = _web_jobs.get((parse_qs(query).get("id") or [""])[0])
if not job:
raise Failure("unknown install job", 404)
with _web_lock: # the worker adds and drops _conn meanwhile
return {k: v for k, v in job.items() if k != "cancel" and not k.startswith("_")}
def webinstall_cancel(body):
jid = body.get("job")
job = _web_jobs.get(jid) if isinstance(jid, str) else None
if not job:
raise Failure("unknown install job", 404)
with _web_lock:
if job["phase"] != "download":
raise Failure("only the download can be cancelled", 409)
job["cancel"] = True
conn = job.get("_conn")
if conn:
frame_webinstall.abort(conn)
return {"message": "Cancelling the download"}
def webinstall_shutdown():
"""Stop downloads and give workers a moment to delete their temporary files.
An install already copying to the Frame may outlive this; sweep_tmp
removes what it leaves on a later start.
"""
global _web_closing
with _web_lock:
_web_closing = True
conns = []
for job in _web_jobs.values():
job["cancel"] = True
conns.append(job.get("_conn")) # once: the worker may drop it any time
workers = list(_web_workers)
for conn in conns:
if conn:
frame_webinstall.abort(conn)
deadline = time.time() + 4 # the app kills the server 5 s after asking it to stop
for worker in workers:
worker.join(max(0, deadline - time.time()))
def _pid_alive(pid):
if frame_host.WINDOWS:
# os.kill(pid, 0) would terminate the process there; ask the kernel instead.
import ctypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
if not handle:
return ctypes.get_last_error() == 5 # access denied: it exists
try:
code = ctypes.c_ulong()
return not k32.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value == 259 # STILL_ACTIVE
finally:
k32.CloseHandle(handle)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
return True # exists, owned by someone else
return True
def sweep_tmp():
"""Delete download and title staging folders left by a server killed mid-install.
Folders carry the server's PID, so only a dead server's are taken.
"""
for prefix in (WEB_TMP_PREFIX, frame_titles.TMP_PREFIX):
for d in Path(tempfile.gettempdir()).glob(f"{prefix}*"):
_sweep_one(prefix, d)
def _sweep_one(prefix, d):
m = re.fullmatch(re.escape(prefix) + r"(\d+)-.*", d.name)
if not m:
return
pid = int(m[1])
try:
if pid != os.getpid() and not _pid_alive(pid) and d.is_dir():
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel}
# ---- HTTP ------------------------------------------------------------------
def _pipe_reader(pipe):
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
chunks = queue.Queue() # unbounded: the pump never blocks, so it ends at EOF
def pump():
try:
while True:
chunk = pipe.read1(1 << 16) if hasattr(pipe, "read1") else os.read(pipe.fileno(), 1 << 16)
chunks.put(chunk)
if not chunk:
return
except (OSError, ValueError):
chunks.put(b"")
threading.Thread(target=pump, daemon=True).start()
return chunks
def _next_chunk(chunks, timeout):
"""The next chunk, or b"" at end of stream or after `timeout` seconds of silence."""
try:
return chunks.get(timeout=timeout)
except queue.Empty:
return b""
def push_file(path, dest="Downloads/"):
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
name = Path(path).name
try:
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
cmd = ["rsync", "-a", "-e", shlex.join(SSH), str(path), f"{FRAME}:{shlex.quote(dest)}"]
else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
except subprocess.TimeoutExpired:
raise Failure(f"Copying {name} timed out")
if r.returncode != 0:
raise Failure(strip_ansi(r.stderr or r.stdout).strip() or f"copy exited {r.returncode}")
return f"Sent {name} to ~/{dest}"
class Handler(BaseHTTPRequestHandler):
server_version = "FrameControl/1"
timeout = 60 # per socket operation, so a stalled client can't hold a thread
@@ -788,9 +1164,17 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/host":
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/android":
ensure_master()
self.send_json({"apps": frame_android.list_apps()})
elif path == "/api/titles":
ensure_master()
self.send_json({"titles": frame_titles.list_titles()})
elif path == "/api/titles/job":
self.send_json(title_job(url.query))
elif path == "/api/android/displays":
self.send_json(android_displays())
elif path == "/api/android/reports":
@@ -804,6 +1188,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(steam_frame("owned"))
elif path == "/api/steam/search":
self.send_json(steam_search(url.query))
elif path == "/api/webinstall/job":
self.send_json(webinstall_job(url.query))
elif path == "/api/shots":
self.send_json(list_shots())
elif path == "/api/shots/image":
@@ -869,11 +1255,10 @@ class Handler(BaseHTTPRequestHandler):
old, _stream_proc = _stream_proc, proc
if old and old.poll() is None:
old.terminate()
fd = proc.stdout.fileno()
chunks = _pipe_reader(proc.stdout)
# Nothing is sent until the first bytes arrive, so a failure to
# start still comes back as a JSON error.
ready, _, _ = select.select([fd], [], [], 20)
first = os.read(fd, 1 << 16) if ready else b""
first = _next_chunk(chunks, 20)
if not first:
proc.kill()
proc.wait()
@@ -894,8 +1279,7 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.flush()
# A stalled headset view ends the stream rather than
# holding this thread (and the page) forever.
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
chunk = os.read(fd, 1 << 16) if ready else b""
chunk = _next_chunk(chunks, STREAM_STALL)
except OSError:
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
finally:
@@ -911,7 +1295,8 @@ class Handler(BaseHTTPRequestHandler):
_live_tunnels.discard(proc)
def upload(self):
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install), 'apkinfo' (read only)
or 'title' (a .zip or program to sideload: inspected and kept for /api/titles install)."""
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
mode = self.headers.get("X-Mode", "push")
length = int(self.headers.get("Content-Length") or 0)
@@ -922,6 +1307,7 @@ class Handler(BaseHTTPRequestHandler):
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
raise Failure("APK install needs a .apk file", 400)
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
keep = False
try:
dest = tmp / name
with open(dest, "wb") as f:
@@ -945,6 +1331,9 @@ class Handler(BaseHTTPRequestHandler):
except frame_android.FrameError as e:
info["blocker"] = str(e)
return {"message": f"Read {info['label']} {info['version']}", "apk": info}
if mode == "title":
keep = True # stage_title owns tmp now, and removes it on failure
return stage_title(str(dest), temp_dir=str(tmp))
if mode == "apk":
ensure_master()
try:
@@ -952,30 +1341,48 @@ class Handler(BaseHTTPRequestHandler):
except frame_android.FrameError as e:
raise Failure(str(e), 400)
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
return {"message": script("push.sh", str(dest))}
return {"message": push_file(dest)}
finally:
shutil.rmtree(tmp, ignore_errors=True)
if not keep:
shutil.rmtree(tmp, ignore_errors=True)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
ap.add_argument("--exit-on-eof", action="store_true",
help="stop cleanly when stdin closes (the app closes it on quit; "
"Windows has no SIGTERM to catch)")
args = ap.parse_args()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
sweep_tmp()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
def watch_stdin():
sys.stdin.buffer.read()
threading.Thread(target=httpd.shutdown, daemon=True).start()
threading.Thread(target=watch_stdin, daemon=True).start()
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
# The app both closes stdin and sends SIGTERM on quit; a second signal
# mid-cleanup would abort it and leave the SSH master running.
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
webinstall_shutdown()
# The master was started with -N, so it stays up until told to exit.
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
if CONTROL:
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
if _master and _master.poll() is None:
_master.terminate()
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
if proc.poll() is None:
proc.terminate()
_purge_titles(now=float("inf")) # unconfirmed title uploads
if __name__ == "__main__":