Compare commits

...
16 Commits
Author SHA1 Message Date
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
saphidandClaude Opus 5.5 6d73912f8c Report an unreachable headset as 502, not a server error
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 07:40:48 +10:00
saphidandClaude Opus 5.5 60571dbfac Prepare Frame Control 0.2.0 for public testing
- Without the maintainer's key, Android compatibility reports stay on the
  Mac and the UI says so; the shared database is never contacted.
- Remove personal infrastructure details from scripts and docs: the Drive
  folder and gog wrapper now come from the environment, and the Chromium
  build host is required instead of defaulted.
- Add an MIT license, tester instructions in the README, and bump to 0.2.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 07:37:37 +10:00
saphidandClaude Opus 5.5 f324aac690 Live H.264 video of the headset view in Frame Control
Live in the headset view now streams video instead of polling stereo
screenshots (~2 fps). SteamVR's steamvr-v4l2cam.service mirrors the headset
view into /dev/video99; ffmpeg on the Frame encodes it with x264 (720p30 by
default, AUD + repeated SPS/PPS), /api/stream relays the raw H.264 over SSH,
and the page splits it on access unit delimiters and decodes it with
WebCodecs into the existing viewer. Capture still takes a stereo still; the
desktop panel keeps capture polling, and the page falls back to it if the
video can't start.

The remote ffmpeg runs under a shell that kills it when the SSH channel
closes, stderr goes to a temp file, and a 10 s stall ends the stream. One
stream at a time; a new one supersedes the last.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 01:11:33 +10:00
saphidandClaude Opus 5.5 8b7c46a63a Add WebXR Chromium build and install scripts
Flathub Chromium can't enter immersive WebXR on Linux because upstream
only wires the OpenXR device on Windows. Document why, and add scripts to
cross-compile arm64 Chromium with the unmerged Linux OpenXR CLs and to
install, launch and check it on the Frame.

The first build is still running; immersive-vr support is unverified.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 00:29:27 +10:00
32 changed files with 2068 additions and 392 deletions

No files matched your search

+2 -2
View File
@@ -1,11 +1,11 @@
---
name: steam-frame
description: Operate the user's Valve Steam Frame headset from the Mac through the ~/projects/steam-frame helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
description: Operate the user's Valve Steam Frame headset from the Mac through this repo's helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
---
# Steam Frame
The repo is `~/projects/steam-frame`. SSH works through the `frame` alias
SSH works through the `frame` alias
(user `steamos`). The headset has to be awake for anything that touches its
desktop or panels.
+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
+23 -1
View File
@@ -31,4 +31,26 @@ jobs:
- 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-python.js
# The server runs on each desktop OS the app ships for. Windows uses the same
# Python version the app bundles (app/build/fetch-python.js).
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
+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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 saphid
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.
+158 -177
View File
@@ -1,212 +1,189 @@
# Steam Frame ↔ Mac
<div align="center">
This repo holds notes and Mac-side helpers for controlling a Valve Steam Frame
(standalone VR headset: SteamOS 3, Arch-based, arm64, Snapdragon 8 Gen 3) from
this Mac, with as little typing on the headset's virtual keyboard as possible.
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
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.
# Frame Control
**Quick start:** set up SSH once (next section), then install
[Frame Control](#frame-control-mac-app) from the DMG.
**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.
## Minimum typing on the headset
[![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)
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.
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
On the Frame:
<br>
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.
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
On the Mac:
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
```sh
cd ~/projects/steam-frame
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
ssh frame # passwordless from now on
```
</div>
`connect.sh` does four things:
---
- 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
## Features
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
logins.
<table>
<tr>
<td width="50%" 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).
**👓 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.
**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>
<td width="50%" valign="top">
## Recommended options
**🔋 Battery and status**<br>
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
| Goal | Recommended | Confidence |
</td>
</tr>
<tr>
<td valign="top">
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
</td>
<td valign="top">
**🤖 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.
</td>
</tr>
<tr>
<td valign="top">
**📁 Files and clipboard**<br>
Drag files onto the window to send them. Send text or your clipboard straight to the headset's desktop.
</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. [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) | Python 3 (`xcode-select --install`) |
| **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: Python is bundled, and SSH is built into Windows |
| **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) | `python3` and `ssh` (most desktops have both) |
| **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) | same |
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).
Optional: `adb` for Android apps
([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` ·
Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`).
## 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`. Sending the clipboard needs
`wl-clipboard` (Wayland) or `xclip` (X11).
</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; single shot or about 2 fps live; saves
as PNG. The viewer fits the whole frame; zoom with − / + (or scroll, or
double-click), drag to pan, `0` to fit, `F` for full screen. It uses OpenVR's `IVRScreenshots` API through Python `ctypes`
(`ui/frame_vrshot.py`), so nothing is installed on the Frame. **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). The
ratings go into our private compatibility database (a Lakebed capsule only the
app can use, backed up daily to Google Drive; 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.
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).
`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).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
```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
```
## Feedback
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 Android ratings database needs
its key in the Keychain (see `compat-db/README.md`); without it, the app uses
its offline copy.
This is a first public test, so reports are really useful, especially from
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
with:
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.
- 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
## Scripts
## Going further
| 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 | Back up the compatibility database locally and to Google Drive (daily LaunchAgent) (**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` |
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**.
## Security notes
| | |
|---|---|
| [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 |
| [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,
@@ -215,7 +192,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.
@@ -223,16 +200,20 @@ 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
[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
Corporation. This project isn't affiliated with or endorsed by Valve.
+2 -2
View File
@@ -27,8 +27,8 @@ rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
## Compatibility reports
Reports live in Frame Control's private database, a Lakebed capsule at
`https://frame-compat.lakebed.app` that only the app can read or write (see
Reports are saved on your Mac. The maintainer's copy of Frame Control also
syncs them to a private Lakebed database (see
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
records whether an app stays up in its own instance (`result`); **Report**
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
+1
View File
@@ -1,2 +1,3 @@
node_modules/
dist/
build/python-win/
+49
View File
@@ -0,0 +1,49 @@
// Downloads the official Windows embeddable Python into build/python-win, which
// the Windows build bundles as resources/python (so Windows users need no Python).
// Pinned by version and SHA-256. Run: node build/fetch-python.js
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const path = require("path");
const { execFileSync } = require("child_process");
const VERSION = "3.12.10";
const SHA256 = "4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3";
const URL = `https://www.python.org/ftp/python/${VERSION}/python-${VERSION}-embed-amd64.zip`;
const OUT = path.join(__dirname, "python-win");
function get(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
return resolve(get(res.headers.location));
}
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("error", reject);
});
}
(async () => {
const stamp = path.join(OUT, ".version");
if (fs.existsSync(path.join(OUT, "python.exe")) && fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === VERSION) {
console.log(`Python ${VERSION} already in ${OUT}`);
return;
}
const zip = await get(URL);
const sum = crypto.createHash("sha256").update(zip).digest("hex");
if (sum !== SHA256) throw new Error(`checksum mismatch for ${URL}: ${sum}`);
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const file = path.join(OUT, "python.zip");
fs.writeFileSync(file, zip);
// bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
try { execFileSync("tar", ["-xf", file, "-C", OUT]); }
catch { execFileSync("unzip", ["-q", "-o", file, "-d", OUT]); }
fs.rmSync(file);
fs.writeFileSync(path.join(OUT, ".version"), VERSION);
console.log(`Python ${VERSION} -> ${OUT}`);
})().catch((e) => { console.error(e.message); process.exit(1); });
+92 -44
View File
@@ -1,6 +1,6 @@
// 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.
// 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, dialog, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
@@ -12,11 +12,15 @@ const path = require("path");
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 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 +29,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 +53,40 @@ async function loginPath() {
return joined;
}
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
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"];
const candidates = [];
if (IS_WIN) candidates.push(path.join(ROOT, "python", "python.exe"));
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, ["-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 = IS_MAC
? "Install the Xcode Command Line Tools (xcode-select --install) or Homebrew's python, then reopen the app."
: IS_WIN ? "The bundled Python is missing; reinstall Frame Control."
: "Install Python 3.8 or later from your distribution (e.g. sudo apt install python3), 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 +108,20 @@ 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(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" };
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.
// -X utf8: the bundled Windows Python ignores PYTHON* variables (isolated mode).
const child = spawn(python, ["-X", "utf8", 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 +143,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 +177,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,8 +221,8 @@ 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,
});
@@ -195,11 +233,12 @@ 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 },
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
: { icon: path.join(__dirname, "build", "icon.png") }),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
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);
@@ -212,36 +251,45 @@ function createWindow() {
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, [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", 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 +301,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") }],
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "frame-control",
"version": "0.1.1",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.1.1",
"license": "UNLICENSED",
"version": "0.3.0",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
"electron-builder": "^26.15.3"
+60 -6
View File
@@ -1,16 +1,18 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.1.1",
"description": "Mac app for managing a Valve Steam Frame over SSH",
"version": "0.3.0",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
"license": "UNLICENSED",
"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:dir": "electron-builder --mac --arm64 --dir",
"dist:linux": "electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -25,7 +27,8 @@
},
"files": [
"main.js",
"package.json"
"package.json",
"build/icon.png"
],
"extraResources": [
{
@@ -73,7 +76,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 +86,56 @@
"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": [
"python3",
"openssh-client"
]
},
"win": {
"target": [
"nsis",
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}",
"extraResources": [
{
"from": "build/python-win",
"to": "python",
"filter": [
"**/*"
]
}
]
},
"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"
}
}
+7 -7
View File
@@ -1,11 +1,12 @@
# compat-db: Frame Control's compatibility database
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
reports for Android apps on the Steam Frame. Only Frame Control can read or
write it.
reports for Android apps on the Steam Frame. For now only the maintainer's
copy of Frame Control has the key to read or write it. Everyone else's reports
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
owned by `saphid`, doesn't expire). The browser page only says it's private.
claimed, so it doesn't expire). The browser page only says it's private.
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
no Lakebed queries or mutations, so nothing else can reach the rows.
@@ -22,10 +23,9 @@ write it.
`scripts/compat-db-backup.sh` exports every report through the app key and
keeps dated copies in
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
When the data has changed, it also uploads them to Google Drive
(**the backup folder**, folder
`<drive-folder-id>`) with `gog`. The LaunchAgent
`frame-compat-backup` runs it daily at 03:40; the log is
When the data has changed, it also uploads them with `gog` to the Google
Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
`EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
nothing is uploaded, and every later run refuses too until you rerun with
+7 -8
View File
@@ -135,11 +135,11 @@ collects community reports for Steam games only and has no public API, and
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
(for example `lepton-stable`) also cover Steam games only
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
So we keep our own, in a private Lakebed database
(`https://frame-compat.lakebed.app`) that only Frame Control can read or write.
So Frame Control keeps its own. Your reports are saved on your Mac; the
maintainer's copy also syncs them to a private Lakebed database.
**Test** records whether the app stays up in its own instance, and **Report**
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
A daily job backs it up locally and to Google Drive. See
See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
@@ -188,13 +188,12 @@ But pasta runs with `--map-gw`, so the **gateway address inside Lepton
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
1. The LaunchAgent `~/Library/LaunchAgents/frame-t3-tunnel.plist`
keeps `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running. launchd
restarts it if it drops. Log: `~/Library/Logs/frame-t3-tunnel.log`.
1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
it drops.
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
The app on the Frame was built from the v2 nightly source (fork commit
`d0c468e3`) with `expo prebuild` and `gradlew assembleRelease
The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
`android-commandlinetools` SDK. It's signed with the debug key.
+133
View File
@@ -0,0 +1,133 @@
# 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)). Needs `adb`, and
`aapt2` for reading APK files.
- **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. 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/` and the rated catalogue from
`apk-catalog/`, and on Windows an embedded Python too.
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,
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`,
`adb` and Python work 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.** Python is bundled; `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 `python3` (3.8 or later) and `ssh`, which most desktops
have. The AppImage runs anywhere; the `.deb` pulls both in on Debian and
Ubuntu. Set Up Connection runs `ui/frame_connect.py` in your terminal emulator
(GNOME Terminal, Konsole, xterm and others). Sending the clipboard needs
`wl-clipboard` (Wayland) or `xclip` (X11). The log is at
`~/.config/Frame Control/logs/server.log`.
## 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 (fetches the embedded Python first)
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`).
+2 -1
View File
@@ -30,6 +30,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
| 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 |
| 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) |
@@ -43,7 +44,7 @@ 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 (verified 2026-09-26, seccomp sandbox off). What it looks like in the headset is still untested. 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` |
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+103
View File
@@ -0,0 +1,103 @@
# 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 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
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` |
+116
View File
@@ -0,0 +1,116 @@
# WebXR in Chromium on the Frame
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.
## Why Flathub Chromium can't
**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub
`org.chromium.Chromium` 154.0.8037.57 aarch64):
- `navigator.xr` exists, but `isSessionSupported("immersive-vr")` is `false`.
Flags don't change that, and neither does `--force-webxr-runtime=openxr`
with SteamVR exposed to the Flatpak.
- The binary has no OpenXR loader: no `XR_RUNTIME_JSON`,
`xrGetInstanceProcAddr` or `XR_LOADER_DEBUG` strings. The only OpenXR
strings are the `chrome://flags` entries.
**Cause (verified against Chromium source, same date).** M154 is the first
release that compiles OpenXR on Linux:
- `device/vr/buildflags/buildflags.gni` adds `is_linux` to `enable_openxr`.
- Flathub's tarball sets `checkout_openxr = true`.
- Flathub's GN args don't turn it off.
But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates
the OpenXR device under `ENABLE_OPENXR && IS_WIN`. That's true on 154, 155 and
`main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The
rest of the Linux port is in two unmerged CLs (bug 506004811):
- [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736)
runs the XR device service in a Linux sandbox that allows SteamVR's
sockets, `/dev/shm` and `flock`.
- [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979)
wires the provider to `OpenXrPlatformHelperLinux`. `kOpenXR` stays off by
default, so it needs `--enable-features=OpenXR`.
8132979 sits on top of 8441736, so fetching `refs/changes/79/8132979/<ps>`
gets both.
**The Frame side is ready.** `~/.config/openxr/1/active_runtime.json` names
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
## Building 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 (patchset 44), runs
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
Progress is logged to `~/chromium-xr/stage`. The build aborts if `/` drops
below 12 GB free.
First run, 2026-09-25, on a 12-core, 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.
**The extra fix.** The CL's 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 script
allows that one option.
## Running it on the Frame
[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh):
```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] # its own VR panel, --enable-features=OpenXR
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
```
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
gamescope's X display, the same way as [`panel-on-frame.sh`](panels.md), so
the Plasma desktop doesn't need to be open. It uses its own profile
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
visiting the site*.
**Seccomp is off.** `launch` 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.
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
this build):
- `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`.
**Not verified yet:** nobody was wearing the headset during the test, so
SteamVR kept it in standby. The session stayed at
`XR_SESSION_STATE_SYNCHRONIZED` (the page saw `visibilityState: "hidden"`)
and only the first frame ran. Still open:
- Whether the image shows up correctly in the headset, and at what frame rate.
- Whether VR180 or 360 video players (DeoVR, DL8 embeds) play in 3D.
- Controller and hand input in the session.
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, 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
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
# process (verified on the Frame 2026-09-26). Allow only that option.
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
index 435e13d396..297453f582 100644
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
@@ -11,6 +11,7 @@
#include "sandbox/linux/system_headers/linux_syscalls.h"
#include "sandbox/policy/linux/sandbox_linux.h"
+using sandbox::bpf_dsl::AllOf;
using sandbox::bpf_dsl::Allow;
using sandbox::bpf_dsl::Arg;
using sandbox::bpf_dsl::Error;
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
switch (system_call_number) {
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
- // stay disallowed; add a narrow level/optname restriction if ever needed.
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
#if defined(__NR_getpeername)
case __NR_getpeername:
#endif
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
case __NR_get_robust_list:
#endif
return Allow();
+#if defined(__NR_getsockopt)
+ case __NR_getsockopt: {
+ // SteamVR's IPC client checks who is on the other end of its socket
+ // with SO_PEERCRED. Nothing else is readable.
+ const Arg<int> level(1);
+ const Arg<int> optname(2);
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
+ .Else(Error(EPERM));
+ }
+#endif
#if defined(__NR_kill)
case __NR_kill: {
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
P
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
printf '%s\n' "$PEERCRED_PATCH" | git apply
stage "applied SO_PEERCRED patch"
fi
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)"
+109
View File
@@ -0,0 +1,109 @@
#!/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 as its own panel in the headset
# 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)
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
# need to be open. The app starts in $HOME, so the profile path is relative.
# Without --no-first-run and --password-store=basic, startup can stop at a
# first-run or keyring prompt before DevTools comes up.
# --disable-seccomp-filter-sandbox: under the XR seccomp policy, SteamVR's
# client reads /proc/self/status through the file broker, gets the
# broker's pid, and SteamVR binds the app to the wrong process, so
# xrCreateInstance fails. The namespace sandbox stays on, but seccomp is
# off for every process, so keep this profile for VR sites.
exec "$here/panel-on-frame.sh" --name chromium-xr -- '~/chromium-xr/chrome' \
--user-data-dir=.config/chromium-xr \
--enable-features=OpenXR \
--ozone-platform=x11 \
--no-first-run --no-default-browser-check --password-store=basic \
--disable-seccomp-filter-sandbox \
--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
+8 -7
View File
@@ -4,9 +4,9 @@
#
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
# 60), and uploads to Google Drive (the backup folder) when
# the data changed since the last upload. Run daily by the LaunchAgent
# frame-compat-backup (see docs/apks.md).
# 60), and uploads to the Google Drive folder DRIVE_FOLDER_ID when the data
# changed since the last upload. Maintainer-only: it needs the database key.
# Run it daily from a LaunchAgent (see compat-db/README.md).
#
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
@@ -14,8 +14,8 @@ set -euo pipefail
ROOT="${0:A:h}/.."
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-<drive-folder-id>}
GOG_WRAPPER=${GOG_WRAPPER:-$HOME/bin/gog-with-keyring.sh}
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-}
GOG_WRAPPER=${GOG_WRAPPER:-$(command -v gog || true)}
upload=1 force=0 accept_shrink=0
for arg in "$@"; do
case "$arg" in
@@ -60,9 +60,10 @@ if (( upload )); then
print "==> Unchanged since the last Drive upload; skipped"
exit 0
fi
[[ -x "$GOG_WRAPPER" ]] || { print -u2 "gog wrapper not found at $GOG_WRAPPER"; exit 1; }
[[ -n "$DRIVE_FOLDER_ID" ]] || { print -u2 "Set DRIVE_FOLDER_ID, or pass --no-upload"; exit 1; }
[[ -n "$GOG_WRAPPER" && -x "$GOG_WRAPPER" ]] || { print -u2 "gog not found; install it or set GOG_WRAPPER"; exit 1; }
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
print -r -- "$digest" > "$last"
print "==> Uploaded to Google Drive (the backup folder)"
print "==> Uploaded to Google Drive"
fi
+51
View File
@@ -0,0 +1,51 @@
"""Compatibility reports without the maintainer's key: saved locally, never sent.
Run: python3 -m unittest discover -s tests
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
class NoKey(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
state = tmp.name
for name, value in (("STATE", state), ("OUTBOX", os.path.join(state, "outbox.jsonl")),
("MIRROR", os.path.join(state, "mirror.json"))):
p = mock.patch.object(db, name, value)
p.start()
self.addCleanup(p.stop)
db._mem.update(at=0, reports=None, source=None)
env = mock.patch.dict(os.environ, {}, clear=False)
env.start()
self.addCleanup(env.stop)
os.environ.pop("FRAME_CONTROL_KEY", None)
# No Keychain entry, and any network use fails the test.
no_key = mock.patch.object(db.subprocess, "run",
return_value=mock.Mock(returncode=44, stdout=""))
no_key.start()
self.addCleanup(no_key.stop)
net = mock.patch.object(db._opener, "open", side_effect=AssertionError("network used"))
net.start()
self.addCleanup(net.stop)
def test_report_is_kept_locally(self):
self.assertFalse(db.shared())
r = db.add({"package": "org.example.app", "date": "2026-09-26T10:00:00", "rating": "works"})
reports = db.load()
self.assertEqual([x["id"] for x in reports], [r["id"]])
self.assertEqual(db._mem["source"], "mirror")
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -83,6 +83,7 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.request("GET", "/api/status")[0], 403)
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
@@ -116,6 +117,11 @@ class ServerGuards(unittest.TestCase):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, shot)
def test_stream_settings_checked_before_ssh(self):
for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"):
status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, query)
def test_bad_bodies(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
@@ -130,6 +136,8 @@ class ServerGuards(unittest.TestCase):
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")],
+27 -16
View File
@@ -8,7 +8,9 @@ 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 glob, json, os, re, shlex, shutil, subprocess, sys, threading, time, zipfile, zlib
import frame_host
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
@@ -27,7 +29,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}')
@@ -53,19 +57,17 @@ def game_id(shortcut_appid):
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
exe = 'aapt2.exe' if frame_host.WINDOWS else 'aapt2'
found = sorted(f for d in frame_host.android_sdk_dirs() for f in glob.glob(os.path.join(d, 'build-tools', '*', exe)))
return found[-1] if found else shutil.which('aapt2')
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
raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, stdin=subprocess.DEVNULL, 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)}')
@@ -105,14 +107,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 +157,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'])
+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()
+29 -11
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,14 +35,26 @@ 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 computer's outbox and ratings come from the catalogue."""
try:
key()
return True
except DBError:
return False
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never follow redirects: urllib would copy the key header to the new host."""
def redirect_request(self, *args, **kwargs):
@@ -169,6 +184,8 @@ def load():
now = time.time()
if _mem['reports'] is None or now - _mem['at'] > TTL:
try:
if not shared():
raise DBError('no key')
try:
flush()
except Exception:
@@ -196,8 +213,9 @@ def add(report):
with _lock, open(OUTBOX, 'a') as f:
f.write(json.dumps(r, ensure_ascii=False) + '\n')
try:
flush()
_mem['at'] = 0 # refetch on next load
if shared():
flush()
_mem['at'] = 0 # refetch on next load
except Exception:
pass # stays queued; load() shows it and a later call sends it
return r
+175
View File
@@ -0,0 +1,175 @@
"""Connect this computer to the Steam Frame: find it, create a key, add a `Host frame`
alias to ~/.ssh/config and copy the key over, 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 os
import platform
import re
import socket
import subprocess
import sys
import time
from pathlib import Path
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
SSH_DIR = Path.home() / ".ssh"
KEY = SSH_DIR / "id_ed25519_frame"
CONFIG = SSH_DIR / "config"
# 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 re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", _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)
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
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 port_open(host, port):
return host, port
say(f" - {cand}: not resolvable or port {port} closed")
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 write_config(host, port=22):
"""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 = [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []),
f" User {FRAME_USER}",
" IdentityFile ~/.ssh/id_ed25519_frame", " IdentitiesOnly yes",
" ServerAliveInterval 30", "Host *", END]
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 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 key")
make_ssh_dir()
if KEY.exists():
say(f" exists: {KEY}")
else:
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C",
f"{platform.node() or 'computer'}->steam-frame", "-f", str(KEY)], check=True)
say(f" created {KEY}")
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
write_config(host, port)
say("==> Checking key login")
if key_login_works():
say(" key login already works")
else:
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"{FRAME_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:]))
+265
View File
@@ -0,0 +1,265 @@
"""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 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)"},
"aapt2": {"mac": 'brew install --cask android-commandlinetools, then sdkmanager "build-tools;36.0.0"',
"win": 'install Android Studio\'s command-line tools, then sdkmanager "build-tools;36.0.0"',
"linux": 'install Android\'s command-line tools, then sdkmanager "build-tools;36.0.0"'},
}
return hints[tool]["mac" if MAC else "win" if WINDOWS else "linux"]
def android_sdk_dirs():
"""Where the Android SDK usually lives, for adb and aapt2."""
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"]
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 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
+141 -17
View File
@@ -321,7 +321,7 @@
</div>
<div class="spacer"></div>
<button class="action" id="shotBtn">Capture</button>
<button id="liveBtn" title="Keep capturing">Live</button>
<button id="liveBtn" title="Keep updating: video of the headset view, or repeated captures of the desktop panel">Live</button>
<button id="saveBtn" disabled>Save</button>
</div>
<div class="viewer" id="viewer">
@@ -384,8 +384,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>
@@ -434,8 +434,8 @@
<div class="panel">
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
<div class="list" id="repList"><div class="sub">Loading…</div></div>
<div class="hint">Reports go to Frame Control's private compatibility database and change the
verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid.</div>
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
reported, including ones not on F-Droid.</div>
</div>
</div>
<div class="panel">
@@ -465,7 +465,7 @@
<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 +522,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>
@@ -575,7 +575,7 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
const HINTS = {
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live refreshes about twice a second. Captures show everything on screen, including anything private.",
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
};
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
@@ -613,6 +613,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) };
@@ -751,10 +760,12 @@ function render(s) {
// ---- view ----
function setView(v) {
const changed = v !== view;
view = v;
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
$("eyeSeg").style.visibility = v === "headset" ? "visible" : "hidden";
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
$("viewHint").textContent = HINTS[v];
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
}
function setEye(e) {
eye = e;
@@ -841,6 +852,7 @@ function isBlank(ctx, w, h) {
return max - min < 6;
}
async function capture() {
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
let url = null;
@@ -883,7 +895,115 @@ function toggleLive(on) {
liveFailures = 0;
$("liveBtn").classList.toggle("on", live);
$("liveBadge").hidden = !live;
if (live) liveLoop(); else clearTimeout(liveTimer);
clearTimeout(liveTimer);
stopVideo();
if (!live) return;
if (view === "headset" && "VideoDecoder" in window) {
const started = startVideo();
const ctl = video.ctl;
started.catch(e => {
if (!live || ctl !== video.ctl || e.name === "AbortError") return;
log("Live video failed: " + e.message, "e");
toast("Live video failed, using captures instead: " + e.message, true);
stopVideo();
liveLoop();
});
} else liveLoop();
}
// ---- live video of the headset view ----
// The server relays raw H.264 (Annex B) from SteamVR's headset-view device.
// Every frame starts with an access unit delimiter (NAL type 9), which is how
// the stream is cut into frames for WebCodecs.
const STREAM_QUERY = "h=720&fps=30", STREAM_FPS = 30;
const video = { ctl: null, dec: null, gen: 0 };
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1; }
function nalTypes(au) {
const types = [];
for (let i = 0; i + 3 < au.length; i++) if (startCode(au, i)) { types.push(au[i + 3] & 0x1f); i += 3; }
return types;
}
async function startVideo() {
const gen = ++video.gen, ctl = new AbortController();
video.ctl = ctl;
$("eyeSeg").style.visibility = "hidden";
if (!lastImg) $("viewer").classList.add("busy");
let frames = 0, shown = 0, second = performance.now(), needKey = true, ts = 0;
const c = $("canvas"), ctx = c.getContext("2d");
const dec = video.dec = new VideoDecoder({
output: frame => {
if (gen !== video.gen) return frame.close();
if (c.width !== frame.displayWidth || c.height !== frame.displayHeight) {
c.width = frame.displayWidth; c.height = frame.displayHeight;
$("viewer").style.aspectRatio = `${c.width} / ${c.height}`;
setZoom(1);
}
ctx.drawImage(frame, 0, 0);
frame.close();
if (!shown++) {
viewGen++; // a capture still in flight mustn't replace the video
lastImg = null; lastSource = "video"; lastShot = null;
$("viewer").classList.remove("busy");
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
$("srcBadge").hidden = false; $("srcBadge").textContent = "Headset view · video";
$("stamp").hidden = false; $("saveBtn").disabled = false;
}
frames++;
const now = performance.now();
if (now - second >= 1000) {
$("stamp").textContent = `${Math.round(frames * 1000 / (now - second))} fps`;
frames = 0; second = now;
}
},
error: e => log("Video decoder: " + e.message, "e"),
});
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const reader = r.body.getReader();
let buf = new Uint8Array(0), scan = 0, auStart = -1;
const onAU = au => {
const types = nalTypes(au), key = types.includes(5);
if (dec.state === "unconfigured") {
const sps = types.indexOf(7);
if (sps < 0) return;
let i = 0, n = -1; // find the SPS bytes: profile, constraints, level follow its header
for (; i + 3 < au.length; i++) if (startCode(au, i) && ++n === sps) break;
if (i + 6 >= au.length) return;
const hex = [au[i + 4], au[i + 5], au[i + 6]].map(b => b.toString(16).padStart(2, "0")).join("");
dec.configure({ codec: `avc1.${hex}`, optimizeForLatency: true });
}
// If decoding falls behind, drop frames until the next keyframe rather than lag.
if (dec.decodeQueueSize > 3) needKey = true;
if (needKey && !key) return;
needKey = false;
dec.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: au }));
ts += 1e6 / STREAM_FPS;
};
for (;;) {
const { value, done } = await reader.read();
if (done || gen !== video.gen) break;
const next = new Uint8Array(buf.length + value.length);
next.set(buf); next.set(value, buf.length);
buf = next;
if (buf.length > 8 << 20) throw new Error("the stream isn't split into frames");
for (; scan + 3 < buf.length; scan++) {
if (!startCode(buf, scan) || (buf[scan + 3] & 0x1f) !== 9) continue;
if (auStart >= 0) onAU(buf.subarray(auStart, scan));
auStart = scan;
scan += 3;
}
if (auStart > 0) { buf = buf.slice(auStart); scan -= auStart; auStart = 0; }
}
if (gen === video.gen && live) throw new Error(shown ? "the stream ended" : "no video arrived");
}
function stopVideo() {
video.gen++;
if (video.ctl) video.ctl.abort();
if (video.dec && video.dec.state !== "closed") video.dec.close();
video.ctl = video.dec = null;
$("viewer").classList.remove("busy");
if (lastSource === "video") { $("srcBadge").textContent = "Headset view · video (stopped)"; $("stamp").hidden = true; }
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
}
$("shotBtn").onclick = () => capture();
$("liveBtn").onclick = () => toggleLive(!live);
@@ -949,7 +1069,7 @@ $("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"));
$("clipMac").onclick = () => act($("clipMac").textContent, () => api("/api/clipboard", { fromComputer: true }), $("clipMac"));
// ---- file drop ----
const drop = $("drop");
@@ -980,7 +1100,7 @@ function upload(file, mode) {
}
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; }
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; zip the folder first`, true); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
}
@@ -1345,9 +1465,12 @@ const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", run
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
async function loadReports() {
let reps;
try { reps = (await api("/api/android/reports")).reports; }
let reps, shared;
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
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 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;
@@ -1408,6 +1531,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: [] };
@@ -1433,14 +1557,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.
@@ -1492,7 +1616,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 => {
+273 -88
View File
@@ -1,16 +1,19 @@
#!/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 shlex
import shutil
@@ -25,19 +28,25 @@ 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
# Windows' embedded Python (bundled with the app) doesn't put the script's own
# folder on 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
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:]
@@ -81,9 +90,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
@@ -96,7 +108,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
@@ -106,7 +118,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}")
@@ -118,40 +133,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 ---------------------------------------------------------------
@@ -258,7 +249,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:
@@ -272,6 +263,41 @@ def save_shots(body):
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
# Live video of the headset view. SteamVR's steamvr-v4l2cam.service copies the
# headset view (one undistorted 1920x1080 image) into the v4l2loopback device
# /dev/video99. ffmpeg encodes it with x264 (the hardware encoder crashes
# ffmpeg) and the raw H.264 comes back over SSH for the page to decode with
# WebCodecs. An access unit delimiter starts every frame so the page can split
# the stream, and repeated SPS/PPS let it start at any keyframe. ffmpeg runs in
# the background while the shell waits for our stdin to close: when the local
# ssh goes, the channel closes and the shell kills ffmpeg, even one that has
# stopped writing (and so would never get SIGPIPE).
STREAM_DEVICE = "/dev/video99"
STREAM_HEIGHTS = (720, 1080)
STREAM_FPS = (30, 60)
STREAM_STALL = 10 # seconds without video before the stream is dropped
_stream_lock = threading.Lock()
_stream_proc = None
def stream_command(query):
q = parse_qs(query)
try:
height = int((q.get("h") or ["720"])[0])
fps = int((q.get("fps") or ["30"])[0])
except ValueError:
raise Failure("h and fps must be integers", 400)
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
rate = 3 if height == 720 else 6 # Mbit/s
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
f"-g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
def launch(body):
appid = str(body.get("appid", ""))
if not APPID.match(appid):
@@ -332,13 +358,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):
@@ -346,7 +403,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}"}
@@ -355,25 +416,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)
@@ -402,7 +463,8 @@ def android(body):
runtime=body.get("runtime") or "instance",
label=body.get("label"), source=body.get("source"))
name = r.get("label") or pkg
return {"message": f"Saved your report for {name}", "report": r}
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)
@@ -423,20 +485,19 @@ FONT_RANGE = (0.5, 2.0)
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
_adb_lock = threading.Lock()
_live_tunnels = set() # ssh processes to kill if the server stops mid-request
_live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the server stops mid-request
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")
@@ -453,7 +514,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.
@@ -551,7 +612,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:
@@ -705,6 +766,50 @@ POST = {"/api/android/display": android_display, "/api/android": android,"/api/l
# ---- 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
@@ -751,13 +856,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/android/displays":
self.send_json(android_displays())
elif path == "/api/android/reports":
self.send_json({"reports": frame_catalog.recent_reports()})
self.send_json({"reports": frame_catalog.recent_reports(),
"shared": frame_catalog.compat_db.shared()})
elif path == "/api/android/catalog":
self.send_json({"apps": frame_catalog.catalog()})
elif path == "/api/status":
@@ -770,6 +879,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(list_shots())
elif path == "/api/shots/image":
self.send_bytes(*shot_image(url.query))
elif path == "/api/stream":
self.stream_video(url.query)
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
elif path == "/api/screenshot":
@@ -779,6 +890,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": "not found"}, 404)
except Failure as e:
self.send_json({"error": str(e)}, e.status)
except frame_android.FrameError as e:
self.send_json({"error": str(e)}, 502)
except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
@@ -805,9 +918,67 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": str(e)}, e.status)
except (ValueError, TypeError) as e:
self.send_json({"error": f"bad request: {e}"}, 400)
except frame_android.FrameError as e:
self.send_json({"error": str(e)}, 502)
except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
def stream_video(self, query):
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
global _stream_proc
remote = stream_command(query)
ensure_master()
# stderr goes to a file: nothing reads it while streaming, and a full
# pipe would stall ffmpeg. It's only read if the stream fails to start.
errors = tempfile.TemporaryFile()
proc = subprocess.Popen([*SSH, FRAME, remote], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=errors)
try:
_live_tunnels.add(proc)
# One viewer at a time: a new stream (another tab, a reload) ends the last one.
with _stream_lock:
old, _stream_proc = _stream_proc, proc
if old and old.poll() is None:
old.terminate()
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.
first = _next_chunk(chunks, 20)
if not first:
proc.kill()
proc.wait()
errors.seek(0)
err = strip_ansi(errors.read().decode(errors="replace")).strip()
raise Failure(err or "The headset view sent no video for 20 s")
chunk = first
try:
self.send_response(200)
self.send_header("Content-Type", "video/h264")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.end_headers()
self.close_connection = True # the body ends when the connection does
while chunk:
self.wfile.write(chunk)
self.wfile.flush()
# A stalled headset view ends the stream rather than
# holding this thread (and the page) forever.
chunk = _next_chunk(chunks, STREAM_STALL)
except OSError:
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
for f in (proc.stdin, proc.stdout, errors):
f.close()
_live_tunnels.discard(proc)
def upload(self):
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
@@ -850,7 +1021,7 @@ 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)
@@ -858,20 +1029,34 @@ class Handler(BaseHTTPRequestHandler):
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))
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)
# 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 of requests cut off mid-way
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
if proc.poll() is None:
proc.terminate()