Compare commits

..
18 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
saphidandClaude Opus 5.5 f3ae71ab05 Frame Control 0.1.1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 23:11:23 +10:00
saphidandClaude Opus 5.5 eabf4cd1f9 Add Steam screenshots, Tailscale remote access, VR-video fixes
- Screenshots: list the Frame's Steam screenshots, open them in the
  viewer, and save new ones to ~/Pictures/SteamFrame. Ids are validated
  before any shell, and copies land atomically.
- Tailscale: scripts/tailscale-on-frame.sh installs a userspace tailscaled
  as a lingering systemd --user service with no sudo, SHA-256 checked, safe
  to re-run, with --uninstall. docs/tailscale.md covers setup and warns that
  in userspace mode every Frame port, including loopback-only DevTools and
  ADB, is reachable from the tailnet.
- push-vr-video.sh: filenames starting with "-" are safe, symlinks are
  followed, and a real Videos\VR directory triggers a warning.
- Tests cover the screenshot routes (19 total).

Docs keep placeholder addresses for the headset and tailnet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 23:11:14 +10:00
36 changed files with 2483 additions and 434 deletions

No files matched your search

+3 -2
View File
@@ -1,11 +1,11 @@
--- ---
name: steam-frame 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 # 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 (user `steamos`). The headset has to be awake for anything that touches its
desktop or panels. desktop or panels.
@@ -22,6 +22,7 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` | | See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` | | Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` | | Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` | | Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` | | Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` | | Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
+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 - name: Server tests
run: python -m unittest discover -s tests -v run: python -m unittest discover -s tests -v
- name: App syntax - 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.
+161 -178
View File
@@ -1,211 +1,189 @@
# Steam Frame ↔ Mac <div align="center">
This repo holds notes and Mac-side helpers for controlling a Valve Steam Frame <img src="docs/img/icon.png" width="112" alt="Frame Control icon">
(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.
Status: written 2026-09-25 and checked against a real Frame the same day # Frame Control
(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.
**Quick start:** set up SSH once (next section), then install **Manage your Valve Steam Frame from your computer.**<br>
[Frame Control](#frame-control-mac-app) from the DMG. 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 [**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
**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: <br>
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing). <img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
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: <sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
```sh </div>
cd ~/projects/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) ## Features
- 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 <table>
logins. <tr>
<td width="50%" valign="top">
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup), **👓 Headset view**<br>
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
(both **confirmed on Steam Frame**, Valve official).
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the </td>
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30 <td width="50%" valign="top">
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).
## 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) | | **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`) |
| **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 | | **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 |
| **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 | | **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) |
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) | | **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 |
| 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](docs/ssh.md), [docs/streaming.md](docs/streaming.md), Optional: `adb` for Android apps
[docs/file-transfer.md](docs/file-transfer.md), ([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` ·
[docs/open-questions.md](docs/open-questions.md). For how the Frame's software Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`).
fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.md).
## 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 There's no paid Apple developer account behind it, so macOS says the app is
leave it. Each Steam app, though, gets its own SteamVR panel. That also works damaged or can't be checked. Drag it to Applications, then clear the download
for any Linux app tagged with an app id of its own: quarantine once:
```sh ```sh
./scripts/panel-on-frame.sh konsole xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
./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** The first time, macOS also asks to allow local network access (for SSH) and
controls to place each panel. See [docs/panels.md](docs/panels.md). 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. The installer isn't code-signed, so Windows SmartScreen may say it protected
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is installer: unzip it anywhere and run `Frame Control.exe`.
Windows-only. Steam Link views the headset. **Frame Control** is a Mac app over </details>
the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
the same UI in a browser without packaging: <details>
<summary><b>Linux: running the AppImage</b></summary>
```sh ```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, ## Set up the headset
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.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests You type one password on the headset, once. Everything else happens on your
with a non-local `Host` header, and any `/api/` request without a custom computer.
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.
### 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 **What it changes:** only what you click. Installs go to your user account on
`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`. the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
It starts `ui/server.py` on a free loopback port and shows it in its own window. needs `sudo` except the power buttons. On your computer it adds a `Host frame`
The server stops when you quit the app. A prebuilt DMG for Apple Silicon is entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
```sh ## Feedback
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
```
Open the DMG and drag **Frame Control** to Applications. You need `python3` on This is a first public test, so reports are really useful, especially from
the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
login shell, so Homebrew's `rsync` and `adb` work when you launch it from with:
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.
The build is ad-hoc signed and not notarized. A copy you build yourself opens - what you tried and what happened
normally. A copy downloaded from GitHub Releases is quarantined; clear it with - your computer's OS and your SteamOS build (Steam Settings → System)
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first - the server log: **Frame → Show Server Log** in the app
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.
## Scripts ## Going further
| Script | Runs on | Purpose | 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
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) | **verified** or **inferred**.
| `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` |
## 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. - 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, Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
@@ -214,23 +192,28 @@ showed live status and the library.
networks only, and turn Developer Mode off when you don't need it. 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 - Frame Control reaches ADB and the Steam client's DevTools port (Frame
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility 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 - `steamos` has `sudo`, protected by the same Developer Mode password. Once
you've switched to key auth, a short password still protects `sudo` and you've switched to key auth, a short password still protects `sudo` and
RDP, so pick one that isn't trivially guessable. RDP, so pick one that isn't trivially guessable.
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access, - Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
use Tailscale (Flatpak/package availability for the Frame hasn't been use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
checked). **every** Frame port is reachable from your tailnet, including Steam's DevTools
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
</details>
## Development ## Development
```sh ```sh
python3 -m unittest discover -s tests # server guards, validation, Steam helpers; no headset needed python3 -m unittest discover -s tests # server tests; no headset needed
cd app && npm install && npm run dist # build the DMG 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 The server is Python stdlib only; the app is Electron. GitHub Actions runs the
may find (Xcode Command Line Tools), plus syntax checks for every script and the tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
Electron main process (`.github/workflows/checks.yml`). Anything that touches the into the release. See [building](docs/frame-control.md#building).
headset is verified by hand against a real Frame, and the docs label it
**verified** or **inferred**. ## 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 ## Compatibility reports
Reports live in Frame Control's private database, a Lakebed capsule at Reports are saved on your Mac. The maintainer's copy of Frame Control also
`https://frame-compat.lakebed.app` that only the app can read or write (see syncs them to a private Lakebed database (see
[compat-db/README.md](../compat-db/README.md), including backups). **Test** [compat-db/README.md](../compat-db/README.md), including backups). **Test**
records whether an app stays up in its own instance (`result`); **Report** 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 (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/ node_modules/
dist/ 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 // Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// shows it in a native window. The server does all the work over the `frame` // a free loopback port and shows it in a native window. The server does all the
// SSH alias; this file only hosts it. // work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, dialog, shell } = require("electron"); const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
const { execFile, spawn } = require("child_process"); const { execFile, spawn } = require("child_process");
const { promisify } = require("util"); const { promisify } = require("util");
@@ -12,11 +12,15 @@ const path = require("path");
const run = promisify(execFile); 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 ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const SERVER = path.join(ROOT, "ui", "server.py"); const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts"); 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 LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117"; const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame"; const FRAME = process.env.FRAME_ALIAS || "frame";
@@ -25,15 +29,18 @@ let server = null;
let url = null; let url = null;
let win = null; let win = null;
let quitting = false; let quitting = false;
let python = null;
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses // 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. // Runs asynchronously so a slow shell profile can't freeze the window.
let cachedPath = null; let cachedPath = null;
async function loginPath() { async function loginPath() {
if (IS_WIN) return process.env.PATH || "";
if (cachedPath) return cachedPath; if (cachedPath) return cachedPath;
const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh"; const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")]; const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
let fromShell = ""; let fromShell = "";
try { try {
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'], const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
@@ -46,19 +53,40 @@ async function loginPath() {
return joined; return joined;
} }
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
async function findPython(env) { async function findPython(env) {
for (const dir of env.PATH.split(":")) { const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
const p = path.join(dir, "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 { try {
fs.accessSync(p, fs.constants.X_OK); fs.accessSync(p, fs.constants.X_OK);
// /usr/bin/python3 is a stub until the Command Line Tools are installed. // /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
await run(p, ["-c", "import http.server"], { timeout: 10000, env }); await run(p, ["-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
{ timeout: 10000, env, windowsHide: true });
return p; return p;
} catch {} } catch {}
} }
return null; 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() { function freePort() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const s = net.createServer(); const s = net.createServer();
@@ -80,17 +108,20 @@ function ping(target) {
} }
async function startServer() { async function startServer() {
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" }; const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
const python = await findPython(env); PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" };
if (!python) { python = await findPython(env);
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools " if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
+ "(xcode-select --install) or Homebrew's python, then reopen the app."); if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
}
const port = await freePort(); const port = await freePort();
fs.mkdirSync(LOG_DIR, { recursive: true }); fs.mkdirSync(LOG_DIR, { recursive: true });
const log = fs.openSync(LOG, "a"); const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`); 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); fs.closeSync(log);
server = child; server = child;
let exited = null; let exited = null;
@@ -112,13 +143,20 @@ async function startServer() {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
} }
if (server === child) server = null; if (server === child) server = null;
child.kill("SIGTERM"); endServer(child);
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`); 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() { function stopServer() {
// server.py handles SIGTERM by closing its shared SSH connection. if (server) endServer(server);
if (server) server.kill("SIGTERM");
} }
function errorPage(message) { function errorPage(message) {
@@ -139,12 +177,12 @@ async function restartServer() {
const old = server; const old = server;
server = null; server = null;
url = null; url = null;
if (old) old.kill("SIGTERM"); if (old) endServer(old);
await load(); await load();
} }
// The page's sticky header becomes the title bar, clear of the traffic lights. // On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = ` const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; } header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; } header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
`; `;
@@ -183,8 +221,8 @@ async function firstRunCheck() {
type: "info", type: "info",
message: "Connect to your Steam Frame", message: "Connect to your Steam Frame",
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → ` 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 " + "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 Terminal.", + "headset, creates a key, and asks for that password once in a terminal window.",
buttons: ["Set Up Connection…", "Later"], buttons: ["Set Up Connection…", "Later"],
defaultId: 0, cancelId: 1, defaultId: 0, cancelId: 1,
}); });
@@ -195,11 +233,12 @@ function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560, width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false, 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 }, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
}); });
win.once("ready-to-show", () => win.show()); 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. // External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => { win.webContents.setWindowOpenHandler(({ url: target }) => {
if (/^https?:\/\//.test(target)) shell.openExternal(target); if (/^https?:\/\//.test(target)) shell.openExternal(target);
@@ -212,36 +251,45 @@ function createWindow() {
load(); load();
} }
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password. // Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
function runInTerminal(command) { // ui/frame_host.py, which the server uses too: setup and power actions ask for the
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); // Developer Mode password there.
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`, async function runInTerminal(argv) {
"-e", "activate", "-e", "end tell"], (err) => { try {
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err)); 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, "'\\''")}'`; async function setUpConnection() {
const alias = `FRAME_ALIAS=${FRAME}`;
function setUpConnection() { if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(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() { function buildMenu() {
const template = [ const template = [
{ role: "appMenu" }, ...(IS_MAC ? [{ role: "appMenu" }] : []),
{ role: "fileMenu" }, { role: "fileMenu" },
{ role: "editMenu" }, { role: "editMenu" },
{ {
label: "Frame", label: "Frame",
submenu: [ submenu: [
{ label: "Set Up Connection…", click: setUpConnection }, { 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" }, { type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) }, { label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() }, { label: "Restart Server", click: () => win ? restartServer() : createWindow() },
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) }, { 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" }, { type: "separator" }, { role: "togglefullscreen" },
], ],
}, },
{ role: "windowMenu" }, ...(IS_MAC ? [{ role: "windowMenu" }] : []),
{ {
role: "help", role: "help",
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }], 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", "name": "frame-control",
"version": "0.1.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frame-control", "name": "frame-control",
"version": "0.1.0", "version": "0.3.0",
"license": "UNLICENSED", "license": "MIT",
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
"electron-builder": "^26.15.3" "electron-builder": "^26.15.3"
+60 -6
View File
@@ -1,16 +1,18 @@
{ {
"name": "frame-control", "name": "frame-control",
"productName": "Frame Control", "productName": "Frame Control",
"version": "0.1.0", "version": "0.3.0",
"description": "Mac app for managing a Valve Steam Frame over SSH", "description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
"license": "UNLICENSED", "license": "MIT",
"scripts": { "scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .", "start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js", "icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "electron-builder --mac --arm64 --publish never", "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": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
@@ -25,7 +27,8 @@
}, },
"files": [ "files": [
"main.js", "main.js",
"package.json" "package.json",
"build/icon.png"
], ],
"extraResources": [ "extraResources": [
{ {
@@ -73,7 +76,8 @@
"extendInfo": { "extendInfo": {
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.", "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." "NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
} },
"artifactName": "Frame-Control-mac-${arch}.${ext}"
}, },
"dmg": { "dmg": {
"title": "Frame Control ${version}" "title": "Frame Control ${version}"
@@ -82,6 +86,56 @@
"runAsNode": false, "runAsNode": false,
"enableNodeOptionsEnvironmentVariable": false, "enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": 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 # compat-db: Frame Control's compatibility database
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
reports for Android apps on the Steam Frame. Only Frame Control can read or reports for Android apps on the Steam Frame. For now only the maintainer's
write it. 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`, - 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 - Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are `{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
no Lakebed queries or mutations, so nothing else can reach the rows. 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 `scripts/compat-db-backup.sh` exports every report through the app key and
keeps dated copies in keeps dated copies in
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60). `~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
When the data has changed, it also uploads them to Google Drive When the data has changed, it also uploads them with `gog` to the Google
(**the backup folder**, folder Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
`<drive-folder-id>`) with `gog`. The LaunchAgent `EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
`frame-compat-backup` runs it daily at 03:40; the log is
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than `~/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`, 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 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` Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
(for example `lepton-stable`) also cover Steam games only (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)). ([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 So Frame Control keeps its own. Your reports are saved on your Mac; the
(`https://frame-compat.lakebed.app`) that only Frame Control can read or write. maintainer's copy also syncs them to a private Lakebed database.
**Test** records whether the app stays up in its own instance, and **Report** **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. (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 [compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md). [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: 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` 1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
keeps `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running. launchd for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
restarts it if it drops. Log: `~/Library/Logs/frame-t3-tunnel.log`. it drops.
2. In the app on the Frame, the environment host is `192.168.1.1:3873`. 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 The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
`d0c468e3`) with `expo prebuild` and `gradlew assembleRelease
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the -PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
`android-commandlinetools` SDK. It's signed with the debug key. `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`).
+6 -2
View File
@@ -30,12 +30,13 @@ 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 | | 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 | | `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` | | 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 | | 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 | | `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) | | The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks | | SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging | | The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb`, `wayvnc`. | Script design | | Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` | | Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things | | `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` | | Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
@@ -43,8 +44,10 @@ 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) | | 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) | | 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` | | 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. 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) | | **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` |
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons | | Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
## Debug recipes ## Debug recipes
@@ -70,5 +73,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
- Files and clipboard: [file-transfer.md](file-transfer.md) - Files and clipboard: [file-transfer.md](file-transfer.md)
- Android apps: [apks.md](apks.md) - Android apps: [apks.md](apks.md)
- Installing and buying Steam games: [steam-games.md](steam-games.md) - Installing and buying Steam games: [steam-games.md](steam-games.md)
- Remote access from anywhere: [tailscale.md](tailscale.md)
- Floating windows in space: [panels.md](panels.md) - Floating windows in space: [panels.md](panels.md)
- What's still unverified: [open-questions.md](open-questions.md) - What's still unverified: [open-questions.md](open-questions.md)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+6 -4
View File
@@ -25,7 +25,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
(`vrserver`, `vrcompositor`) and `xrdp` are running. (`vrserver`, `vrcompositor`) and `xrdp` are running.
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present. - **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc` `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc`
are **not**. `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips are **not** (Tailscale can be added in `~`; see [tailscale.md](tailscale.md)). `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
text correctly. text correctly.
- Flathub is already configured as a **system** remote; Chromium is the only - Flathub is already configured as a **system** remote; Chromium is the only
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB. installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
@@ -40,7 +40,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md). created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
Still open: 4, 6, 7, 11 (in-headset connect), 12–21. Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21.
## Check on the headset (in order) ## Check on the headset (in order)
@@ -82,8 +82,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–21.
to type locally. to type locally.
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC) 15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC)
reach the Linux side? Does USB power from the Mac cope? reach the Linux side? Does USB power from the Mac cope?
16. **Tailscale**: can it be installed persistently (Flatpak? a 16. ~~**Tailscale**~~: answered 2026-09-25. A userspace `tailscaled` in `~`
userspace `tailscaled` in `~`?) for access off the home LAN? runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
Still open: reaching the Frame from outside the home network, and the service
starting after a reboot.
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the 17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
panels from `panel-on-frame.sh` show up, take input, and offer **Float in panels from `panel-on-frame.sh` show up, take input, and offer **Float in
World** / **Move** / **Size**? Do floating positions survive closing and World** / **Move** / **Size**? Do floating positions survive closing and
+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` |
+91
View File
@@ -0,0 +1,91 @@
# Tailscale: use the Frame from anywhere
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
so does everything built on it: Frame Control, the scripts and the Mac app.
When the Mac and the Frame are on the same network, Tailscale connects them
directly, so there's no relay in the way (`tailscale ping frame` → `via
192.168.1.50:41641`, 20 ms).
```sh
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
scripts/tailscale-on-frame.sh --uninstall
```
## How it's installed
There's no Tailscale Flatpak, and the rootfs is read-only. So the script
installs Tailscale's static arm64 build in the `steamos` user's home and runs
`tailscaled --tun=userspace-networking` as a systemd **user** service. It
doesn't need sudo, and SteamOS updates don't touch it.
| Path | What |
|---|---|
| `~/.local/share/tailscale/<version>/` | `tailscale`, `tailscaled` (SHA-256 checked against pkgs.tailscale.com) |
| `~/.local/share/tailscale/current` | Symlink to the active version |
| `~/.local/share/tailscale/state/` | Node key and state |
| `~/.local/bin/tailscale` | CLI wrapper that points at the daemon's socket (`$XDG_RUNTIME_DIR/tailscale/tailscaled.sock`) |
| `~/.config/systemd/user/tailscaled.service` | The service |
Lingering (`loginctl enable-linger`) is on, so the service starts at boot
without anyone logging in. polkit allowed that without sudo. Re-running the
script is safe. It restarts `tailscaled` only if the version or unit changed,
and then does so detached after 3 s, because the SSH session may itself run
over Tailscale.
To update, run the script again; it installs the latest stable version. To
manage the node, use `ssh frame '~/.local/bin/tailscale status'` (or `set`,
`down`, `up`).
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926, Tailscale
1.102.4):**
- First install and login approval. This ran an earlier revision of the script,
which restarted the daemon unconditionally. The node is `frame`,
with a 100.x.y.z tailnet address.
- SSH works over Tailscale: the Frame serves the same ED25519 host key as it
does on `frame.local`.
- Frame Control's status and Get games work through the alias.
- The current script: a re-run with nothing changed doesn't restart anything,
and a re-run with a changed unit restarts `tailscaled` 3 s after the SSH
session ends and then reads `Running`. The timer needs
`AccuracySec=100ms`; the default of 1 min made it fire up to a minute late.
The first-install guard was checked on its own.
**Not verified:**
- A clean first install and login with the current script end to end. It would
mean removing the node from the tailnet.
- Reaching the Frame from outside the home network. Only the direct LAN path
was tested.
- The service coming up after a reboot. That's **inferred** from linger plus
`WantedBy=default.target`; the Frame hasn't been rebooted since.
## Exposure: every port is on the tailnet
In userspace mode, `tailscaled` passes inbound tailnet connections to the
Frame's **loopback**. Any device on the tailnet can therefore reach **every**
listening port, including ones meant to be local-only. Checked from the Mac on
2026-09-25:
| Port | Service | Normally |
|---|---|---|
| 22 | sshd | LAN |
| 8080 | Steam client DevTools (full control of the Steam client and account session) | loopback only |
| 27062 | SteamVR `vrserver` | loopback only |
| 5555 | Lepton ADB (unauthenticated shell into Android) | LAN |
| 3389 | xrdp | LAN |
The user accepted this on 2026-09-25, since the tailnet only holds their own
devices. Other options:
- `tailscale set --shields-up` blocks **all** inbound connections. That
includes SSH and Tailscale SSH (`--ssh`), both checked.
- A tailnet policy that tags the Frame (`tag:frame`) and allows only
`tag:frame:22` keeps the other ports private. This is an admin-console
change.
- Kernel-mode Tailscale (a root install, e.g. systemd-sysext) wouldn't expose
loopback-only ports, but it needs sudo and may not survive SteamOS updates.
If the Mac's Tailscale is off, the alias won't resolve. Use
`scripts/connect.sh frame.local` to go back to the LAN name.
+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 # 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 # dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
# 60), and uploads to Google Drive (the backup folder) when # 60), and uploads to the Google Drive folder DRIVE_FOLDER_ID when the data
# the data changed since the last upload. Run daily by the LaunchAgent # changed since the last upload. Maintainer-only: it needs the database key.
# frame-compat-backup (see docs/apks.md). # Run it daily from a LaunchAgent (see compat-db/README.md).
# #
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink] # Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER # Env: DRIVE_FOLDER_ID, GOG_WRAPPER
@@ -14,8 +14,8 @@ set -euo pipefail
ROOT="${0:A:h}/.." ROOT="${0:A:h}/.."
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups" DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-<drive-folder-id>} DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-}
GOG_WRAPPER=${GOG_WRAPPER:-$HOME/bin/gog-with-keyring.sh} GOG_WRAPPER=${GOG_WRAPPER:-$(command -v gog || true)}
upload=1 force=0 accept_shrink=0 upload=1 force=0 accept_shrink=0
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
@@ -60,9 +60,10 @@ if (( upload )); then
print "==> Unchanged since the last Drive upload; skipped" print "==> Unchanged since the last Drive upload; skipped"
exit 0 exit 0
fi 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" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
"$GOG_WRAPPER" drive upload "$out.sha256" --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 -r -- "$digest" > "$last"
print "==> Uploaded to Google Drive (the backup folder)" print "==> Uploaded to Google Drive"
fi fi
+12 -7
View File
@@ -25,7 +25,7 @@ PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/
launch=0 list=0 launch=0 list=0
while (( $# )); do while (( $# )); do
case "$1" in case "$1" in
-h|--help) sed -n '2,18p' "$0"; exit 0 ;; -h|--help) sed -n '2,17p' "$0"; exit 0 ;;
--launch) launch=1; shift ;; --launch) launch=1; shift ;;
--list) list=1; shift ;; --list) list=1; shift ;;
--) shift; break ;; --) shift; break ;;
@@ -33,21 +33,25 @@ while (( $# )); do
*) break ;; *) break ;;
esac esac
done done
(( $# || launch || list )) || { sed -n '2,18p' "$0"; exit 2; } (( $# || launch || list )) || { sed -n '2,17p' "$0" >&2; exit 2; }
for f in "$@"; do for f in "$@"; do
[[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; } [[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
done done
# Create the folder and link it into DeoVR's prefix (the prefix exists once # Create the folder and link it into DeoVR's prefix (the prefix exists once
# DeoVR has run). Never replace a real directory that's already there. # DeoVR has run). Refresh a stale link, but never replace a real directory.
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR if (( $# || launch )); then
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
p=~/$PREFIX_VIDEOS p=~/$PREFIX_VIDEOS
if [ -d \"\$p\" ] && [ ! -e \"\$p/VR\" ]; then ln -s ~/$REMOTE_DIR \"\$p/VR\"; fi if [ -d \"\$p\" ] && { [ -L \"\$p/VR\" ] || [ ! -e \"\$p/VR\" ]; }; then ln -sfn ~/$REMOTE_DIR \"\$p/VR\"
elif [ -d \"\$p/VR\" ]; then echo \"warning: \$p/VR is a real folder, so uploads won't show under DeoVR's Videos; browse Z:\\\\home\\\\steamos\\\\Videos\\\\VR instead\" >&2; fi
[ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2" [ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2"
fi
if (( $# )); then if (( $# )); then
rsync -a --partial --progress "$@" "$FRAME_ALIAS:$REMOTE_DIR/" # -L: send what a symlink points at; a Mac-side link would dangle on the Frame
rsync -aL --partial --progress -- "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
fi fi
if (( list )); then if (( list )); then
@@ -55,6 +59,7 @@ if (( list )); then
fi fi
if (( launch )); then if (( launch )); then
ssh "$FRAME_ALIAS" "steam steam://rungameid/$DEOVR_APPID >/dev/null 2>&1 &" ssh "$FRAME_ALIAS" "command -v steam >/dev/null || { echo 'steam not found on the Frame' >&2; exit 1; }
steam steam://rungameid/$DEOVR_APPID </dev/null >/dev/null 2>&1 &"
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR." print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
fi fi
+81 -28
View File
@@ -10,6 +10,11 @@
# ~/.config/systemd/user/tailscaled.service # ~/.config/systemd/user/tailscaled.service
# `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root. # `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root.
# #
# Exposure: in userspace mode tailscaled forwards inbound tailnet connections
# to the Frame's loopback, so EVERY port is reachable from the tailnet,
# including localhost-only ones (Steam's DevTools on 8080, SteamVR, ADB).
# `tailscale set --shields-up` blocks all inbound (SSH too). See docs/tailscale.md.
#
# Usage: scripts/tailscale-on-frame.sh [--version X.Y.Z] [--hostname NAME] # Usage: scripts/tailscale-on-frame.sh [--version X.Y.Z] [--hostname NAME]
# scripts/tailscale-on-frame.sh --uninstall # scripts/tailscale-on-frame.sh --uninstall
# The first run prints a login URL (and opens it on the Mac) to add the Frame # The first run prints a login URL (and opens it on the Mac) to add the Frame
@@ -20,10 +25,10 @@ FRAME=${FRAME_ALIAS:-frame}
version="" hostname="frame" uninstall=0 version="" hostname="frame" uninstall=0
while (( $# )); do while (( $# )); do
case "$1" in case "$1" in
--version) version=$2; shift ;; --version) version=${2:?--version needs a value}; shift ;;
--hostname) hostname=$2; shift ;; --hostname) hostname=${2:?--hostname needs a value}; shift ;;
--uninstall) uninstall=1 ;; --uninstall) uninstall=1 ;;
-h|--help) sed -n '2,17p' "$0"; exit 0 ;; -h|--help) sed -n "2,21p" "$0"; exit 0 ;;
*) print -u2 "unknown argument: $1"; exit 2 ;; *) print -u2 "unknown argument: $1"; exit 2 ;;
esac esac
shift shift
@@ -33,13 +38,21 @@ done
if (( uninstall )); then if (( uninstall )); then
ssh "$FRAME" 'set -e ssh "$FRAME" 'set -e
systemctl --user disable --now tailscaled.service 2>/dev/null || true systemctl --user disable --now tailscaled.service 2>/dev/null || true
rm -f ~/.config/systemd/user/tailscaled.service ~/.local/bin/tailscale ~/.local/bin/tailscaled rm -f ~/.config/systemd/user/tailscaled.service ~/.local/bin/tailscale
systemctl --user daemon-reload systemctl --user daemon-reload
echo "Removed the service and wrappers. Binaries and node state are still in ~/.local/share/tailscale;" echo "Removed the service and the CLI wrapper. Binaries and node state are still in"
echo "delete that folder and remove the machine in the Tailscale admin console to finish."' echo "~/.local/share/tailscale; delete that folder and remove the machine in the"
echo "Tailscale admin console to finish. Linger stays on (loginctl disable-linger to undo)."'
exit 0 exit 0
fi fi
# BackendState of the Frame's tailscaled (Running, NeedsLogin, Stopped, …), or
# Unreachable when the probe itself fails (SSH down, daemon restarting).
ts_state() {
ssh -o ConnectTimeout=10 "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null |
python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])"' 2>/dev/null || print Unreachable
}
if [[ -z $version ]]; then if [[ -z $version ]]; then
version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" | version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["TarballsVersion"])') python3 -c 'import json,sys; print(json.load(sys.stdin)["TarballsVersion"])')
@@ -47,11 +60,13 @@ fi
[[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { print -u2 "bad version: $version"; exit 2; } [[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { print -u2 "bad version: $version"; exit 2; }
print "==> Installing Tailscale $version on $FRAME (userspace networking)" print "==> Installing Tailscale $version on $FRAME (userspace networking)"
ssh "$FRAME" "VERSION=$version HOSTNAME_TS=$hostname sh -s" <<'REMOTE' remote_out=$(ssh "$FRAME" "VERSION=$version sh -s" <<'REMOTE'
set -eu set -eu
base="$HOME/.local/share/tailscale" base="$HOME/.local/share/tailscale"
dir="$base/$VERSION" dir="$base/$VERSION"
tgz="tailscale_${VERSION}_arm64.tgz" tgz="tailscale_${VERSION}_arm64.tgz"
unit="$HOME/.config/systemd/user/tailscaled.service"
sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock"
mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user" mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user"
if [ ! -x "$dir/tailscaled" ]; then if [ ! -x "$dir/tailscaled" ]; then
@@ -59,12 +74,15 @@ if [ ! -x "$dir/tailscaled" ]; then
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz" curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz"
want=$(curl -fsSL "https://pkgs.tailscale.com/stable/$tgz.sha256" | cut -d' ' -f1) want=$(curl -fsSL "https://pkgs.tailscale.com/stable/$tgz.sha256" | cut -d' ' -f1)
[ -n "$want" ] || { echo "couldn't fetch $tgz.sha256" >&2; exit 1; }
got=$(sha256sum "$tmp/$tgz" | cut -d' ' -f1) got=$(sha256sum "$tmp/$tgz" | cut -d' ' -f1)
[ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; } [ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; }
tar -xzf "$tmp/$tgz" -C "$tmp" tar -xzf "$tmp/$tgz" -C "$tmp"
mkdir -p "$dir" mkdir -p "$dir"
mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/" mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/"
fi fi
# Neither exists on a first install; don't let that trip set -e.
before=$({ readlink "$base/current"; cat "$unit"; } 2>/dev/null || true)
ln -sfn "$dir" "$base/current" ln -sfn "$dir" "$base/current"
# The CLI looks for the daemon at /var/run/tailscale by default; point it at ours. # The CLI looks for the daemon at /var/run/tailscale by default; point it at ours.
@@ -74,7 +92,7 @@ exec "$HOME/.local/share/tailscale/current/tailscale" --socket="${XDG_RUNTIME_DI
EOF EOF
chmod +x "$HOME/.local/bin/tailscale" chmod +x "$HOME/.local/bin/tailscale"
cat > "$HOME/.config/systemd/user/tailscaled.service" <<'EOF' cat > "$unit" <<'EOF'
[Unit] [Unit]
Description=Tailscale (userspace networking, no root) Description=Tailscale (userspace networking, no root)
After=network-online.target After=network-online.target
@@ -88,39 +106,74 @@ RestartSec=5
[Install] [Install]
WantedBy=default.target WantedBy=default.target
EOF EOF
# Linger starts user services at boot, before anyone logs in; polkit allows it without sudo.
loginctl enable-linger 2>/dev/null || echo "note: couldn't enable linger; tailscaled starts when the session does" >&2
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable tailscaled.service >/dev/null 2>&1 systemctl --user enable tailscaled.service >/dev/null 2>&1
systemctl --user restart tailscaled.service after=$(readlink "$base/current"; cat "$unit")
restart=0
if systemctl --user is-active --quiet tailscaled.service; then
[ "$before" = "$after" ] || restart=1
else
systemctl --user start tailscaled.service
fi
for i in $(seq 1 50); do for i in $(seq 1 50); do
[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock" ] && break [ -S "$sock" ] && break
sleep 0.2 sleep 0.2
done done
"$HOME/.local/bin/tailscale" version | head -n 1 [ -S "$sock" ] || { echo "tailscaled didn't open $sock; see: journalctl --user -u tailscaled" >&2; exit 1; }
v=$("$HOME/.local/bin/tailscale" version)
printf 'Tailscale %s\n' "$(printf '%s\n' "$v" | head -n 1)"
if [ "$restart" = 1 ]; then
# This SSH session may itself run over Tailscale, so restart detached, after
# it has ended; the Mac waits and reconnects.
systemd-run --user --quiet --on-active=3 --timer-property=AccuracySec=100ms --unit=tailscaled-restart --collect \
systemctl --user restart tailscaled.service >/dev/null
echo "RESTART_SCHEDULED"
fi
REMOTE REMOTE
)
print -r -- "${remote_out//RESTART_SCHEDULED/Restarting tailscaled for the new version or unit…}"
# `up` blocks until the login is approved, so run it in the background on the # Wait out a scheduled restart, then read a definite state.
# Frame and fetch the URL from its log. [[ $remote_out == *RESTART_SCHEDULED* ]] && sleep 6
state=$(ssh "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])" 2>/dev/null || echo Unknown') state=""
if [[ $state != Running ]]; then for i in {1..30}; do
ssh "$FRAME" "nohup ~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1 &" state=$(ts_state)
url="" [[ $state == (Running|NeedsLogin|NeedsMachineAuth|Stopped|NoState) ]] && break
for i in {1..40}; do sleep 2
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log | head -n 1' || true) done
[[ -n $url ]] && break
sleep 0.5 case $state in
done Running) ;;
if [[ -n $url ]]; then NeedsLogin|Stopped|NoState)
# `up` blocks until the login is approved, so run it as its own transient
# unit (it outlives this SSH session) and fetch the URL from its log.
ssh "$FRAME" "rm -f /tmp/tailscale-up.log; systemd-run --user --quiet --collect --unit=tailscale-up-\$\$ \
sh -c '~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1' >/dev/null"
url=""
for i in {1..40}; do
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log 2>/dev/null | head -n 1' || true)
[[ -n $url ]] && break
sleep 0.5
done
[[ -n $url ]] || { print -u2 "No login URL after 20 s; see /tmp/tailscale-up.log on the Frame."; exit 1; }
print "==> Approve the Frame in your tailnet: $url" print "==> Approve the Frame in your tailnet: $url"
open "$url" 2>/dev/null || true open "$url" 2>/dev/null || true
print " Waiting for approval (up to 10 minutes)…" print " Waiting for approval (up to 10 minutes)…"
for i in {1..300}; do for i in {1..300}; do
state=$(ssh "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])"' || true) state=$(ts_state)
[[ $state == Running ]] && break [[ $state == Running ]] && break
sleep 2 sleep 2
done done
else [[ $state == Running ]] || { print -u2 "Not approved yet (state: $state). Re-run to get a new URL."; exit 1; }
print -u2 "No login URL yet; see /tmp/tailscale-up.log on the Frame." ;;
fi NeedsMachineAuth) print -u2 "Logged in; approve the device in the Tailscale admin console, then re-run."; exit 1 ;;
fi *) print -u2 "Couldn't read tailscaled's state (last: $state). Check: ssh $FRAME 'journalctl --user -u tailscaled'"; exit 1 ;;
esac
ssh "$FRAME" '~/.local/bin/tailscale status --self --peers=false; printf "Tailscale IP: "; ~/.local/bin/tailscale ip -4' ssh "$FRAME" '~/.local/bin/tailscale status --self --peers=false; printf "Tailscale IP: "; ~/.local/bin/tailscale ip -4'
name=$(ssh "$FRAME" '~/.local/bin/tailscale status --json' | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')
print "==> To use the Frame from anywhere, point the alias at Tailscale:"
print " ssh-keyscan -t ed25519 $name >> ~/.ssh/known_hosts # after checking it matches"
print " scripts/connect.sh $name"
+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()
+21
View File
@@ -15,6 +15,7 @@ import tempfile
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from urllib.parse import quote
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -81,6 +82,9 @@ class ServerGuards(unittest.TestCase):
# <img src> and plain form posts from other sites can't set it. # <img src> and plain form posts from other sites can't set it.
self.assertEqual(self.request("GET", "/api/status")[0], 403) 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/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) self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
def test_captures_are_not_cacheable(self): def test_captures_are_not_cacheable(self):
@@ -98,11 +102,26 @@ class ServerGuards(unittest.TestCase):
("/api/volume", {"level": 1.5}), ("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}), ("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}), ("/api/open", {"what": "anything-else"}),
("/api/shots/save", {"ids": []}),
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
("/api/shots/save", {"ids": [1]}),
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
] ]
for path, body in cases: for path, body in cases:
status, payload = self.post(path, body) status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}") self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
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): def test_bad_bodies(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) 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"}) conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
@@ -117,6 +136,8 @@ class ServerGuards(unittest.TestCase):
class StatusProbe(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): 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. # 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")], 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} 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__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame') FRAME = os.environ.get('FRAME_ALIAS', 'frame')
@@ -27,7 +29,9 @@ class FrameError(RuntimeError):
def ssh(cmd, input=None, timeout=120): def ssh(cmd, input=None, timeout=120):
try: 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) timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}') raise FrameError(f'timed out talking to {FRAME}')
@@ -53,19 +57,17 @@ def game_id(shortcut_appid):
def aapt2(): def aapt2():
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2')) exe = 'aapt2.exe' if frame_host.WINDOWS else 'aapt2'
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2') found = sorted(f for d in frame_host.android_sdk_dirs() for f in glob.glob(os.path.join(d, 'build-tools', '*', exe)))
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2'))) return found[-1] if found else shutil.which('aapt2')
return found[-1] if found else None
def apk_info(path): def apk_info(path):
"""Package, label, version, native ABIs and the best PNG icon inside the APK.""" """Package, label, version, native ABIs and the best PNG icon inside the APK."""
tool = aapt2() tool = aapt2()
if not tool: if not tool:
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then ' raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
'sdkmanager "build-tools;36.0.0"') out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, stdin=subprocess.DEVNULL, text=True).stdout
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out) m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
if not m: if not m:
raise FrameError(f'not a readable APK: {os.path.basename(path)}') 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 _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: try:
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'], subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
check=True, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired: 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: 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(): def _shortcut_ids():
@@ -146,8 +157,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
ok = False ok = False
try: try:
ssh(f'mkdir -p {d}') ssh(f'mkdir -p {d}')
_rsync(apk_path, f'{d}/app.apk.part') _copy(apk_path, f'{d}/app.apk.part')
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120) _copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
icon = '' icon = ''
if info['icon_png']: if info['icon_png']:
ssh(f'cat > {d}/icon.png', input=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 build as catalog_build # noqa: E402
import reports # noqa: E402 import reports # noqa: E402
import frame_android # noqa: E402 import frame_android # noqa: E402
import frame_host # noqa: E402
import frame_compat_db as compat_db # 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')) else os.path.join(CATALOG, 'data', 'cache'))
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/') APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
_lock = threading.Lock() _lock = threading.Lock()
+29 -11
View File
@@ -1,20 +1,23 @@
"""Frame Control's compatibility database: a private Lakebed capsule """Frame Control's compatibility database: a private Lakebed capsule
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or (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, write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
account app-key). 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 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 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} 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 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 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') URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
KEYCHAIN = ('frame-control-compat-db', 'app-key') 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') OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
MIRROR = os.path.join(STATE, 'compat-mirror.json') MIRROR = os.path.join(STATE, 'compat-mirror.json')
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime', FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
@@ -32,14 +35,26 @@ def key():
k = os.environ.get('FRAME_CONTROL_KEY') k = os.environ.get('FRAME_CONTROL_KEY')
if k: if k:
return k return k
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'], p = None
capture_output=True, text=True) if frame_host.MAC:
if p.returncode != 0 or not p.stdout.strip(): p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
raise DBError('No compatibility-database key in the Keychain ' capture_output=True, text=True)
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})') 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() 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): class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never follow redirects: urllib would copy the key header to the new host.""" """Never follow redirects: urllib would copy the key header to the new host."""
def redirect_request(self, *args, **kwargs): def redirect_request(self, *args, **kwargs):
@@ -169,6 +184,8 @@ def load():
now = time.time() now = time.time()
if _mem['reports'] is None or now - _mem['at'] > TTL: if _mem['reports'] is None or now - _mem['at'] > TTL:
try: try:
if not shared():
raise DBError('no key')
try: try:
flush() flush()
except Exception: except Exception:
@@ -196,8 +213,9 @@ def add(report):
with _lock, open(OUTBOX, 'a') as f: with _lock, open(OUTBOX, 'a') as f:
f.write(json.dumps(r, ensure_ascii=False) + '\n') f.write(json.dumps(r, ensure_ascii=False) + '\n')
try: try:
flush() if shared():
_mem['at'] = 0 # refetch on next load flush()
_mem['at'] = 0 # refetch on next load
except Exception: except Exception:
pass # stays queued; load() shows it and a later call sends it pass # stays queued; load() shows it and a later call sends it
return r 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): Uses the store's public endpoints (no key, no login):
api/storesearch name search, price in the IP's currency api/storesearch name search, price in the IP's currency
+258 -22
View File
@@ -152,6 +152,17 @@
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; } background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; } .vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
/* ---- Steam screenshots from the headset ---- */
.shot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.shot-card { display: flex; flex-direction: column; gap: 6px; }
.shot-card .thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: rgba(0,0,0,.3);
display: block; cursor: zoom-in; box-shadow: 0 6px 16px rgba(0,0,0,.45); }
.shot-card .thumb:hover { box-shadow: 0 6px 16px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.25); }
.shot-card .row { flex-wrap: nowrap; }
.shot-card .grow { flex: 1; min-width: 0; }
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shot-card .s { color: var(--muted); font-size: 12px; }
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */ /* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; } .shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
.capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat; .capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
@@ -279,6 +290,7 @@
</a> </a>
<nav id="nav"> <nav id="nav">
<a href="#view" class="on">View</a> <a href="#view" class="on">View</a>
<a href="#shots">Shots</a>
<a href="#library">Library</a> <a href="#library">Library</a>
<a href="#getgames">Games</a> <a href="#getgames">Games</a>
<a href="#android">Android</a> <a href="#android">Android</a>
@@ -309,7 +321,7 @@
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<button class="action" id="shotBtn">Capture</button> <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> <button id="saveBtn" disabled>Save</button>
</div> </div>
<div class="viewer" id="viewer"> <div class="viewer" id="viewer">
@@ -369,6 +381,16 @@
</section> </section>
</div> </div>
<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 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>
</section>
<section id="library"> <section id="library">
<div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div> <div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div>
<div class="shelf" id="games"><div class="sub">Loading…</div></div> <div class="shelf" id="games"><div class="sub">Loading…</div></div>
@@ -412,8 +434,8 @@
<div class="panel"> <div class="panel">
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div> <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="list" id="repList"><div class="sub">Loading…</div></div>
<div class="hint">Reports go to Frame Control's private compatibility database and change the <div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid.</div> reported, including ones not on F-Droid.</div>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
@@ -443,7 +465,7 @@
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea> <textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px"> <div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button> <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>
<div class="hint">Clipboard needs the desktop panel open in the headset.</div> <div class="hint">Clipboard needs the desktop panel open in the headset.</div>
</section> </section>
@@ -500,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="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> <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>
<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 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://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> <div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
@@ -553,12 +575,12 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]]; ["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps"; const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
const HINTS = { 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.", 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" }; const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null; let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
let lastImg = null, lastSource = null; let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); } function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; } function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
@@ -591,6 +613,15 @@ $("bottombar").onclick = () => {
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴"; $("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) { async function api(path, body) {
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : { const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) }; method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
@@ -729,10 +760,12 @@ function render(s) {
// ---- view ---- // ---- view ----
function setView(v) { function setView(v) {
const changed = v !== view;
view = v; view = v;
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.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]; $("viewHint").textContent = HINTS[v];
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
} }
function setEye(e) { function setEye(e) {
eye = e; eye = e;
@@ -792,7 +825,7 @@ document.addEventListener("keydown", e => {
function draw() { function draw() {
const img = lastImg, c = $("canvas"); const img = lastImg, c = $("canvas");
// Side-by-side stereo captures: crop to the left half for "one eye". // Side-by-side stereo captures: crop to the left half for "one eye".
const sbs = lastSource !== "gamescope" && img.naturalWidth >= img.naturalHeight * 1.5; const sbs = lastSource === "steamvr" && img.naturalWidth >= img.naturalHeight * 1.5;
const crop = sbs && eye === "left"; const crop = sbs && eye === "left";
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight; const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
if (c.width !== sw || c.height !== sh) { if (c.width !== sw || c.height !== sh) {
@@ -819,6 +852,8 @@ function isBlank(ctx, w, h) {
return max - min < 6; return max - min < 6;
} }
async function capture() { 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 if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
let url = null; let url = null;
try { try {
@@ -829,7 +864,8 @@ async function capture() {
url = URL.createObjectURL(await r.blob()); url = URL.createObjectURL(await r.blob());
const img = new Image(); const img = new Image();
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; }); await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
lastImg = img; lastSource = source; if (gen !== viewGen) return true;
lastImg = img; lastSource = source; lastShot = null;
draw(); draw();
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString(); $("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
$("saveBtn").disabled = false; $("saveBtn").disabled = false;
@@ -859,18 +895,129 @@ function toggleLive(on) {
liveFailures = 0; liveFailures = 0;
$("liveBtn").classList.toggle("on", live); $("liveBtn").classList.toggle("on", live);
$("liveBadge").hidden = !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(); $("shotBtn").onclick = () => capture();
$("liveBtn").onclick = () => toggleLive(!live); $("liveBtn").onclick = () => toggleLive(!live);
$("saveBtn").onclick = () => $("canvas").toBlob(b => { $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : $("canvas").toBlob(b => {
if (!b) return toast("Couldn't encode the image", true); if (!b) return toast("Couldn't encode the image", true);
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
}, "image/png");
function download(blob, name) {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(b); a.href = URL.createObjectURL(blob);
a.download = `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`; a.download = name;
a.click(); a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}, "image/png"); }
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view)); document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye)); document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
@@ -922,7 +1069,7 @@ $("clipSend").onclick = () => {
if (!text) return toast("Nothing to send", true); if (!text) return toast("Nothing to send", true);
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend")); 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 ---- // ---- file drop ----
const drop = $("drop"); const drop = $("drop");
@@ -953,7 +1100,7 @@ function upload(file, mode) {
} }
async function sendFiles(files) { async function sendFiles(files) {
for (const f of 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"); const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push")); await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
} }
@@ -1318,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)" }; crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
const RCLASS = { works: "works", runs: "works", issues: "maybe" }; const RCLASS = { works: "works", runs: "works", issues: "maybe" };
async function loadReports() { async function loadReports() {
let reps; let reps, shared;
try { reps = (await api("/api/android/reports")).reports; } try { ({ reports: reps, shared } = await api("/api/android/reports")); }
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; } 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` : ""; $("repCount").textContent = reps.length ? `${reps.length} newest` : "";
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => { $("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
const k = r.rating || r.result; const k = r.rating || r.result;
@@ -1381,16 +1531,102 @@ $("repForm").onsubmit = async e => {
finally { $("repSave").disabled = false; } finally { $("repSave").disabled = false; }
}; };
loadReports(); loadReports();
api("/api/host").then(applyHostWording).catch(() => {});
// ---- Steam screenshots from the headset ----
const shots = { list: [], urls: [] };
const STEAMVR_APPID = "250820";
function shotApp(appid) {
if (appid === STEAMVR_APPID) return "SteamVR";
const g = state?.games?.find(x => x.appid === appid);
return g ? g.name : `App ${appid}`;
}
async function shotBlob(id, thumb) {
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
{ headers: {"X-Frame-UI": "1"} });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
return r.blob();
}
async function loadShots() {
$("shotsRefresh").disabled = true;
try {
shots.list = (await api("/api/shots")).shots;
} catch (e) {
$("shotGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`;
return;
} 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 ${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 ${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.
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
const s = shots.list[+img.dataset.shot];
try {
const url = URL.createObjectURL(await shotBlob(s.id, true));
shots.urls.push(url);
img.src = url;
} catch (e) { img.alt = "Preview failed"; }
if (!img.isConnected) return; // the list was reloaded meanwhile
}
}
async function openShot(s) {
if (live) toggleLive(false);
const gen = ++viewGen;
$("viewer").classList.add("busy");
let url = null;
try {
const blob = await shotBlob(s.id, false);
url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
if (gen !== viewGen) return;
lastImg = img; lastSource = "shot"; lastShot = { blob, file: s.file };
draw();
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
$("saveBtn").disabled = false;
$("view").scrollIntoView({ behavior: "smooth" });
} catch (e) {
toast("Couldn't open the screenshot: " + e.message, true);
} finally {
if (url) URL.revokeObjectURL(url);
$("viewer").classList.remove("busy");
}
}
async function saveShots(list, btn) {
if (!list.length) return;
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
if (res) loadShots();
}
$("shotGrid").onclick = e => {
const img = e.target.closest("img[data-shot]");
if (img) return openShot(shots.list[+img.dataset.shot]);
const b = e.target.closest("[data-shot-save]");
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
};
$("shotsRefresh").onclick = loadShots;
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
// ---- nav highlight follows scroll ---- // ---- nav highlight follows scroll ----
const spy = new IntersectionObserver(entries => { const spy = new IntersectionObserver(entries => {
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id)); if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id));
}, { rootMargin: "-80px 0px -55% 0px" }); }, { rootMargin: "-80px 0px -55% 0px" });
["view", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id))); ["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
setView("headset"); setView("headset");
refresh(); refresh().then(loadShots); // after status, so app names resolve
setInterval(() => { if (!document.hidden) refresh(); }, 30000); setInterval(() => { if (!document.hidden) refresh(); }, 30000);
</script> </script>
</body> </body>
+360 -84
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env python3 #!/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` Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts. 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) Env: FRAME_ALIAS (default frame)
""" """
import argparse import argparse
import base64
import http.client import http.client
import json import json
import os import os
import queue
import re import re
import shlex import shlex
import shutil import shutil
@@ -25,19 +28,25 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
import frame_android # Windows' embedded Python (bundled with the app) doesn't put the script's own
import frame_catalog # folder on sys.path, so add it for the sibling modules below.
import frame_store 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 HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent / "scripts"
FRAME = os.environ.get("FRAME_ALIAS", "frame") FRAME = os.environ.get("FRAME_ALIAS", "frame")
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit. sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C" # Reuse one SSH connection for the frequent status/screenshot calls, where ssh
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"] # 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. # 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. # Android helpers share the multiplexed connection when it's up.
frame_android.SSH_OPTS = SSH[1:] 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. No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
""" """
global _master global _master
if not CONTROL:
return
def up(): def up():
try: 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 timeout=5).returncode == 0
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return False return False
@@ -96,7 +108,7 @@ def ensure_master():
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5", _master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2", "-N", FRAME], "-o", "ServerAliveCountMax=2", "-N", FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True) stderr=subprocess.DEVNULL, **frame_host.DETACHED)
for _ in range(60): for _ in range(60):
if up() or _master.poll() is not None: if up() or _master.poll() is not None:
return return
@@ -106,7 +118,10 @@ def ensure_master():
def ssh(remote, *, stdin=None, timeout=30, text=True): def ssh(remote, *, stdin=None, timeout=30, text=True):
try: try:
ensure_master() 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) text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}") raise Failure(f"Timed out talking to {FRAME}")
@@ -118,40 +133,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
return r.stdout 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): def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s) return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
def terminal(command): def terminal(argv):
"""Open Terminal.app running `command` (for anything needing a password).""" """Open a terminal window running argv (for anything needing a password)."""
as_str = command.replace("\\", "\\\\").replace('"', '\\"') try:
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"', return frame_host.open_terminal(argv)
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"], except frame_host.HostError as e:
capture_output=True, text=True) raise Failure(str(e), 500)
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"
# ---- actions --------------------------------------------------------------- # ---- actions ---------------------------------------------------------------
@@ -189,6 +180,124 @@ def headset_view():
pass # frame_vrshot.py sweeps leftovers on the next capture pass # frame_vrshot.py sweeps leftovers on the next capture
# Screenshots taken in the headset with Steam's shortcut. Steam files each under the app
# it was taken in: userdata/<account>/760/remote/<appid>/screenshots/<file>,
# with a smaller copy in screenshots/thumbnails/. A shot's id is
# "<account>/<appid>/<file>", checked here before it goes near a shell.
SHOT_ROOT = ".local/share/Steam/userdata"
SHOT_ID = re.compile(r"(\d{1,12})/(\d{1,20})/(\d{14}_\d{1,4}\.(?:jpg|png))")
SHOTS_DIR = Path.home() / "Pictures" / "SteamFrame"
LIST_SHOTS = f"""cd ~/{SHOT_ROOT} 2>/dev/null || exit 0
find . -mindepth 6 -maxdepth 6 -path './*/760/remote/*/screenshots/*' -type f \\
\\( -name '*.jpg' -o -name '*.png' \\) -printf '%P\\t%s\\t%T@\\n'"""
def shot_path(shot_id, thumb=False):
m = SHOT_ID.fullmatch(shot_id) if isinstance(shot_id, str) else None
if not m:
raise Failure("bad screenshot id", 400)
return f"{SHOT_ROOT}/{m[1]}/760/remote/{m[2]}/screenshots/{'thumbnails/' if thumb else ''}{m[3]}"
def list_shots():
shots = []
for line in ssh(LIST_SHOTS, timeout=20).splitlines():
rel, _, rest = line.partition("\t")
parts = rel.split("/") # account/760/remote/appid/screenshots/file
size, _, mtime = rest.partition("\t")
shot_id = f"{parts[0]}/{parts[3]}/{parts[-1]}" if len(parts) == 6 else ""
if not SHOT_ID.fullmatch(shot_id) or not size.isdigit():
continue
try:
when = float(mtime)
except ValueError:
continue
local = SHOTS_DIR / parts[-1]
shots.append({"id": shot_id, "appid": parts[3], "file": parts[-1], "size": int(size), "time": when,
"saved": local.exists() and local.stat().st_size == int(size)})
shots.sort(key=lambda s: s["time"], reverse=True)
return {"shots": shots, "folder": str(SHOTS_DIR)}
def shot_image(query):
q = parse_qs(query)
shot_id = (q.get("id") or [""])[0]
full = shot_path(shot_id)
if q.get("thumb") == ["1"]:
# Steam writes the thumbnail a moment after the shot; fall back to the full image.
thumb = shot_path(shot_id, thumb=True)
remote = f"if [ -s {thumb} ]; then cat {thumb}; else cat {full}; fi"
else:
remote = f"cat {full}"
ctype = "image/png" if shot_id.endswith(".png") else "image/jpeg"
return ssh(remote, timeout=30, text=False), ctype
def save_shots(body):
"""Copy screenshots to ~/Pictures/SteamFrame, skipping ones already there."""
ids = body.get("ids")
if not isinstance(ids, list) or not 0 < len(ids) <= 1000:
raise Failure("ids must be a list of 1-1000 screenshot ids", 400)
paths = [shot_path(i) for i in ids]
todo = [p for p in paths if not (SHOTS_DIR / p.rsplit("/", 1)[-1]).exists()]
if todo:
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
ensure_master()
# Copy into a hidden folder and move complete files in, so a cut-off
# copy never looks saved. -p keeps the time the shot was taken.
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try:
try:
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
for f in incoming.iterdir():
os.replace(f, SHOTS_DIR / f.name)
finally:
shutil.rmtree(incoming, ignore_errors=True)
n, skipped = len(todo), len(ids) - len(todo)
msg = f"Saved {n} screenshot{'s' * (n != 1)} to ~/Pictures/SteamFrame"
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): def launch(body):
appid = str(body.get("appid", "")) appid = str(body.get("appid", ""))
if not APPID.match(appid): if not APPID.match(appid):
@@ -249,13 +358,44 @@ def set_volume(body):
return {"message": "Volume updated"} 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): def clipboard(body):
if body.get("fromMac"): if body.get("fromMac") or body.get("fromComputer"):
return {"message": script("paste-to-frame.sh", timeout=30)} try:
text = body.get("text") text = frame_host.clipboard_text()
if not isinstance(text, str) or not text: except frame_host.HostError as e:
raise Failure("nothing to send", 400) raise Failure(str(e), 500)
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)} 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): def flatpak(body):
@@ -263,7 +403,11 @@ def flatpak(body):
if not FLATPAK_ID.match(app): if not FLATPAK_ID.match(app):
raise Failure("bad Flatpak app ID", 400) raise Failure("bad Flatpak app ID", 400)
if action == "install": 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": if action == "uninstall":
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300) out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
return {"message": strip_ansi(out).strip() or f"Removed {app}"} return {"message": strip_ansi(out).strip() or f"Removed {app}"}
@@ -272,21 +416,25 @@ def flatpak(body):
def open_thing(body): def open_thing(body):
what = body.get("what") what = body.get("what")
alias = shlex.quote(FRAME) try:
if what == "terminal": if what == "terminal":
terminal(f"ssh {alias}") return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
return {"message": "Opened an SSH session in Terminal"} if what in ("reboot", "poweroff", "suspend"):
if what in ("reboot", "poweroff", "suspend"): # logind answers "challenge" over SSH, so sudo (and the password) is needed.
# logind answers "challenge" over SSH, so sudo (and the password) is needed. where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
terminal(f"ssh -t {alias} sudo systemctl {what}") return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"} if what == "steamlink":
if what == "steamlink": return {"message": frame_host.open_steam_link()}
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")} if what == "rdp":
if what == "rdp": return {"message": frame_host.open_rdp(FRAME)}
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")} if what == "sftp":
if what == "sftp": return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
terminal(f"sftp {alias}") if what == "shots":
return {"message": "Opened an SFTP session in Terminal"} 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) raise Failure("unknown target", 400)
@@ -315,7 +463,8 @@ def android(body):
runtime=body.get("runtime") or "instance", runtime=body.get("runtime") or "instance",
label=body.get("label"), source=body.get("source")) label=body.get("label"), source=body.get("source"))
name = r.get("label") or pkg 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: except frame_android.FrameError as e:
raise Failure(str(e)) raise Failure(str(e))
raise Failure("unknown action", 400) raise Failure("unknown action", 400)
@@ -336,20 +485,19 @@ FONT_RANGE = (0.5, 2.0)
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"} 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. # One ADB session at a time: requests are rare, and it keeps adb's state simple.
_adb_lock = threading.Lock() _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(): def adb_path():
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb", try:
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"): return frame_host.adb()
if cand and os.access(cand, os.X_OK): except frame_host.HostError as e:
return cand raise Failure(str(e), 500)
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
def adb(adb_bin, *args, timeout=20): def adb(adb_bin, *args, timeout=20):
try: 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) errors="replace", timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise Failure(f"adb {' '.join(args[-2:])} timed out") raise Failure(f"adb {' '.join(args[-2:])} timed out")
@@ -366,7 +514,7 @@ def free_local_port():
class AdbTunnel: 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 `with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
disconnects adb and kills the ssh process, whatever happened inside. disconnects adb and kills the ssh process, whatever happened inside.
@@ -464,7 +612,7 @@ class AdbTunnel:
self._stop_ssh() self._stop_ssh()
for p in self.local: for p in self.local:
try: 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): except (subprocess.TimeoutExpired, OSError):
pass pass
finally: finally:
@@ -613,11 +761,55 @@ def android_display(body):
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing} "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots}
# ---- HTTP ------------------------------------------------------------------ # ---- 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): class Handler(BaseHTTPRequestHandler):
server_version = "FrameControl/1" server_version = "FrameControl/1"
timeout = 60 # per socket operation, so a stalled client can't hold a thread timeout = 60 # per socket operation, so a stalled client can't hold a thread
@@ -664,13 +856,17 @@ class Handler(BaseHTTPRequestHandler):
try: try:
if path in ("/", "/index.html"): if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8") 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": elif path == "/api/android":
ensure_master() ensure_master()
self.send_json({"apps": frame_android.list_apps()}) self.send_json({"apps": frame_android.list_apps()})
elif path == "/api/android/displays": elif path == "/api/android/displays":
self.send_json(android_displays()) self.send_json(android_displays())
elif path == "/api/android/reports": 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": elif path == "/api/android/catalog":
self.send_json({"apps": frame_catalog.catalog()}) self.send_json({"apps": frame_catalog.catalog()})
elif path == "/api/status": elif path == "/api/status":
@@ -679,6 +875,12 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(steam_frame("owned")) self.send_json(steam_frame("owned"))
elif path == "/api/steam/search": elif path == "/api/steam/search":
self.send_json(steam_search(url.query)) self.send_json(steam_search(url.query))
elif path == "/api/shots":
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"]: elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")]) self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
elif path == "/api/screenshot": elif path == "/api/screenshot":
@@ -688,6 +890,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": "not found"}, 404) self.send_json({"error": "not found"}, 404)
except Failure as e: except Failure as e:
self.send_json({"error": str(e)}, e.status) 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: except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500) self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
@@ -714,9 +918,67 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": str(e)}, e.status) self.send_json({"error": str(e)}, e.status)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
self.send_json({"error": f"bad request: {e}"}, 400) 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: except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500) 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): def upload(self):
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only).""" """Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
name = os.path.basename(unquote(self.headers.get("X-Filename", ""))) name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
@@ -759,7 +1021,7 @@ class Handler(BaseHTTPRequestHandler):
except frame_android.FrameError as e: except frame_android.FrameError as e:
raise Failure(str(e), 400) raise Failure(str(e), 400)
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m} 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: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
@@ -767,20 +1029,34 @@ class Handler(BaseHTTPRequestHandler):
def main(): def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810))) 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() args = ap.parse_args()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) 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) print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try: try:
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: 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. # 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: if _master and _master.poll() is None:
_master.terminate() _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: if proc.poll() is None:
proc.terminate() proc.terminate()