diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6a5ef04
--- /dev/null
+++ b/.gitattributes
@@ -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
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 574db9c..882eb6c 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -31,4 +31,26 @@ jobs:
- name: Server tests
run: python -m unittest discover -s tests -v
- name: App syntax
- run: node --check app/main.js && node --check app/build/make-icon.js
+ run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-python.js
+
+ # The server runs on each desktop OS the app ships for. Windows uses the same
+ # Python version the app bundles (app/build/fetch-python.js).
+ server-tests:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ python: "3.12"
+ - os: macos-latest
+ python: "3.12"
+ - os: ubuntu-latest
+ python: "3.13"
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python }}
+ - name: Server tests
+ run: python -m unittest discover -s tests -v
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..04cfeaf
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/README.md b/README.md
index 3eae27b..d532d50 100644
--- a/README.md
+++ b/README.md
@@ -1,253 +1,189 @@
-# Steam Frame ↔ Mac
+
-**Frame Control** is a Mac app for managing a Valve Steam Frame (standalone VR
-headset: SteamOS 3, Arch-based, arm64, Snapdragon 8 Gen 3) over SSH: live
-headset view, battery and status, your Steam library, Android (Lepton) apps,
-file and clipboard transfer. This repo also holds the scripts behind it and
-field notes on how the Frame's software works, all aimed at as little typing
-on the headset's virtual keyboard as possible.
+
-It's an unofficial hobby project, not affiliated with Valve.
+# Frame Control
-Status: written 2026-09-25 and checked against a real Frame the same day
-(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app
-and most scripts are **verified** on the device. The scripts table below marks
-each one, and [docs/open-questions.md](docs/open-questions.md#verified-on-device-2026-09-25)
-lists what's still unchecked.
+**Manage your Valve Steam Frame from your computer.**
+See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
-## Trying it out
+[](https://github.com/saphid/steam-frame/releases/latest)
+[](#install)
+[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
+[](LICENSE)
-You need:
+[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
-- A Steam Frame with **Developer Mode** on (next section; it's a toggle).
-- A Mac with Apple Silicon (M1 or later). Tested on macOS 26. There's no Intel
- build.
-- `python3` on the Mac (`xcode-select --install` provides it).
-- Optional: `adb` for Android apps (`brew install android-platform-tools`).
+
-Steps:
+
-1. Download the DMG from the
- [latest release](https://github.com/saphid/steam-frame/releases/latest),
- open it and drag **Frame Control** to Applications.
-2. The app isn't notarized (no paid Apple developer account), so macOS will
- say it's damaged or can't be checked. Clear the download quarantine once:
- ```sh
- xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
- ```
-3. Open it. With no `frame` SSH alias yet, it offers to run the connection
- setup in Terminal. That asks for the Developer Mode password once, then
- uses a key from then on.
+Unofficial hobby project, not affiliated with Valve. Free and open source.
-**Feedback:** please open a
-[GitHub issue](https://github.com/saphid/steam-frame/issues) with what you
-tried, your SteamOS build (Steam Settings → System) and the server log
-(**Frame → Show Server Log**, at `~/Library/Logs/Frame Control/server.log`).
-Features are marked **verified** or not below; the unverified ones are the
-most useful to hear about.
+
-**What it changes on your Frame:** only what you click. Installs go to your
-user account (`--user` Flatpaks, Lepton instances, Steam downloads), and
-nothing needs `sudo` except the power buttons. On the Mac it adds a `Host
-frame` entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
+---
-## Minimum typing on the headset
+## Features
-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:
+**👓 Headset view**
+Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
-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:
+**🔋 Battery and status**
+Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
-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
-```
+**🎮 Steam games**
+Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
-`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
+**🤖 Android apps**
+About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
-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).
+**📁 Files and clipboard**
+Drag files onto the window to send them. Send text or your clipboard straight to the headset's desktop.
-**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
-Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
-characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
-Frame's Linux desktop. The script it serves installs your Mac's public key and
-enables `sshd`. See [docs/ssh.md](docs/ssh.md#fallback-bootstrap-one-liner).
+
+
-## Recommended options
+**📸 Screenshots**
+Browse the shots you take in the headset and save them to your Pictures folder.
-| Goal | Recommended | Confidence |
+
+
+
+
+
+**🧩 Flatpaks and display**
+Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
+
+
+
+Nothing is installed on the Frame for any of this: the app uses what SteamOS
+already ships. [How each feature works](docs/frame-control.md).
+
+## Install
+
+| | Download | Needs |
|---|---|---|
-| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
-| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
-| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
-| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
-| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
+| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Python 3 (`xcode-select --install`) |
+| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra: Python is bundled, and SSH is built into Windows |
+| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `python3` and `ssh` (most desktops have both) |
+| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | same |
-Details: [docs/ssh.md](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
-[docs/file-transfer.md](docs/file-transfer.md),
-[docs/open-questions.md](docs/open-questions.md). For how the Frame's software
-fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.md).
+Optional: `adb` for Android apps
+([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` ·
+Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`).
-## Windows anywhere in the room
+
+macOS: the app isn't notarized
-The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
-leave it. Each Steam app, though, gets its own SteamVR panel. That also works
-for any Linux app tagged with an app id of its own:
+There's no paid Apple developer account behind it, so macOS says the app is
+damaged or can't be checked. Drag it to Applications, then clear the download
+quarantine once:
```sh
-./scripts/panel-on-frame.sh konsole
-./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
+xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
-Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
-controls to place each panel. See [docs/panels.md](docs/panels.md).
+The first time, macOS also asks to allow local network access (for SSH) and
+control of Terminal (for the password prompts).
+
-## Frame Control (Mac app)
+
+Windows: SmartScreen warning
-As of 2026-09-25 no other Mac app manages the Frame end to end.
-[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
-the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
-Windows-only. Steam Link views the headset. **Frame Control** is a Mac app over
-the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
-the same UI in a browser without packaging:
+The installer isn't code-signed, so Windows SmartScreen may say it protected
+your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
+installer: unzip it anywhere and run `Frame Control.exe`.
+
+
+
+Linux: running the AppImage
```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
```
-
+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).
+
-- **Headset view**: what the lenses show, as SteamVR composites it (the room,
- floating panels, dashboard and controllers). Shows the left eye, like pointing
- a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
- is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
- headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
- it with WebCodecs. Live video is one eye; Capture still gets both. The viewer fits the whole frame; zoom with − / + (or scroll, or
- double-click), drag to pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots` API through Python `ctypes`
- (`ui/frame_vrshot.py`). Nothing extra is installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel**
- captures gamescope's flat layer instead.
-- Battery with charging state: charge rate in watts, time to full or empty,
- charger type and wattage (for example USB-C PD 20 W), and battery temperature
-- Storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR, the desktop,
- Lepton and xrdp are running
-- Library shelf with Steam cover art and a Play button (`steam://rungameid`)
-- **Get games**: every game you own with its Steam Frame rating (Verified,
- Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
- with live progress. Search the Steam store with prices and Frame ratings; Buy
- opens the store page in your browser, or Store on Frame opens it in the
- headset. It drives the Frame's own Steam client through its DevTools port;
- see `docs/steam-games.md`
-- Volume and mute (`wpctl`)
-- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
- one with a click as its own Lepton instance (it keeps its data and shows in the
- Steam library), then launch, stop, test or remove it. **Report an APK** records whether any APK
- worked (F-Droid or not: pick a file, type a package, or use an installed app). Your
- reports are saved on your Mac and change the verdicts you see. They aren't
- uploaded anywhere: the shared database is maintainer-only for now (see
- `compat-db/README.md`)
-- **Android display**: pick a running Lepton instance (by the app in it) and set
- its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
- match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
- (0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all three
- back. Whether the settings survive the app relaunching is untested
-- Drag and drop files to `~/Downloads`; `.apk` files install as their own Android app
-- Send typed text, or the Mac clipboard, to the Frame clipboard
-- Install and remove Flatpaks (quick picks: Moonlight, Firefox, VLC, Remmina)
-- One-click SSH or SFTP in Terminal, Steam Link, and Windows App (RDP).
- Sleep, restart and shut down open Terminal because SteamOS asks for the
- sudo password over SSH.
+## Set up the headset
-The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
-with a non-local `Host` header, and any `/api/` request without a custom
-header, so other websites can't drive it or read captures. It keeps a single multiplexed SSH connection open, so
-status and each capture take about 0.3s. Headset captures are deleted from the
-Frame as soon as they're copied, because they show everything on screen,
-including anything private. The look follows the Steam client: its palette,
-Motiva Sans (loaded from Valve's CDN), portrait library capsules and green
-Play buttons. **Verified on the Frame 2026-09-25:** status and charging details,
-both capture modes (headset view while in use, and a blank frame in standby,
-which the UI labels), clipboard, volume, file push, and input validation. **Not yet exercised from the UI:** Launch, Flatpak
-install/remove, APK drop, and the power buttons. Each of these calls a
-command or script that was verified separately.
+You type one password on the headset, once. Everything else happens on your
+computer.
-### Mac app
+1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
+ in the Developer section, **Set User Password**. Pick something short:
+ you'll type it once more on your computer and then never again.
+2. **On your computer:** open Frame Control. It offers to **Set Up
+ Connection**, which finds the headset, creates an SSH key, and asks for that
+ password once in a terminal window. If it can't find the Frame, type the
+ IP address from the Frame's Quick Settings.
+3. That's it. The app now reaches the headset whenever it's awake and on the
+ same network. For anywhere else, see [Tailscale](docs/tailscale.md).
-`app/` wraps the same UI as a standalone Mac app (Electron). The app bundles
-`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`.
-It starts `ui/server.py` on a free loopback port and shows it in its own window.
-The server stops when you quit the app. A prebuilt DMG for Apple Silicon is
-attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
+**What it changes:** only what you click. Installs go to your user account on
+the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
+needs `sudo` except the power buttons. On your computer it adds a `Host frame`
+entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
-```sh
-cd app
-npm install
-npm run dist # → app/dist/Frame Control--arm64.dmg (and a .zip)
-npm start # run from the checkout without packaging
-```
+## Feedback
-Open the DMG and drag **Frame Control** to Applications. You need `python3` on
-the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your
-login shell, so Homebrew's `rsync` and `adb` work when you launch it from
-Finder. Each time it starts while there's no `frame` SSH alias, the app offers
-to run `connect.sh` in Terminal. **Frame → Set Up Connection…** does the same
-at any time. The Frame menu also shows the server log at
-`~/Library/Logs/Frame Control/server.log`. Installing APKs needs `adb`
-(`brew install android-platform-tools`). The F-Droid ratings are bundled with the app.
+This is a first public test, so reports are really useful, especially from
+Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
+with:
-The build is ad-hoc signed and not notarized. A copy you build yourself opens
-normally. A copy downloaded from GitHub Releases is quarantined; clear it with
-`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
-time you use them, macOS asks to allow local network access (for SSH) and
-control of Terminal (for SSH and power actions). **Verified 2026-09-25:**
-installed from the DMG, launched from Finder, connected to the Frame, and
-showed live status and the library.
+- what you tried and what happened
+- your computer's OS and your SteamOS build (Steam Settings → System)
+- the server log: **Frame → Show Server Log** in the app
-## Scripts
+## Going further
-| Script | Runs on | Purpose |
-|---|---|---|
-| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
-| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
-| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
-| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
-| `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](docs/apks.md)) |
-| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**: overlays created; in-headset placement not yet checked) |
-| `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) |
-| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
-| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
-| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
-| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](docs/vr-video.md) |
-| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
-| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
-| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
+This repo also holds the scripts behind the app and field notes on how the
+Frame's software fits together, all checked against a real headset and labelled
+**verified** or **inferred**.
-## Security notes
+| | |
+|---|---|
+| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
+| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
+| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
+| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
+| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
+| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
+| [Open questions](docs/open-questions.md) | What's still unchecked |
+
+
+Security notes
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
@@ -256,7 +192,7 @@ showed live status and the library.
networks only, and turn Developer Mode off when you don't need it.
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
- database key lives in the macOS Keychain and is never written to the repo.
+ database key (maintainer-only) is never written to the repo.
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
you've switched to key auth, a short password still protects `sudo` and
RDP, so pick one that isn't trivially guessable.
@@ -264,19 +200,18 @@ showed live status and the library.
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
**every** Frame port is reachable from your tailnet, including Steam's DevTools
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
+
## Development
```sh
-python3 -m unittest discover -s tests # server guards, validation, Steam helpers; no headset needed
-cd app && npm install && npm run dist # build the DMG
+python3 -m unittest discover -s tests # server tests; no headset needed
+cd app && npm install && npm start # run the app from the checkout
```
-GitHub Actions runs the tests on Python 3.9, which is the oldest `python3` the app
-may find (Xcode Command Line Tools), plus syntax checks for every script and the
-Electron main process (`.github/workflows/checks.yml`). Anything that touches the
-headset is verified by hand against a real Frame, and the docs label it
-**verified** or **inferred**.
+The server is Python stdlib only; the app is Electron. GitHub Actions runs the
+tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
+into the release. See [building](docs/frame-control.md#building).
## License
diff --git a/app/.gitignore b/app/.gitignore
index b947077..5417301 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,2 +1,3 @@
node_modules/
dist/
+build/python-win/
diff --git a/app/build/fetch-python.js b/app/build/fetch-python.js
new file mode 100644
index 0000000..d883b9a
--- /dev/null
+++ b/app/build/fetch-python.js
@@ -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); });
diff --git a/app/main.js b/app/main.js
index 3675180..4f5a522 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,6 +1,6 @@
-// Frame Control as a Mac app: starts ui/server.py on a free loopback port and
-// shows it in a native window. The server does all the work over the `frame`
-// SSH alias; this file only hosts it.
+// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
+// a free loopback port and shows it in a native window. The server does all the
+// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
@@ -12,11 +12,15 @@ const path = require("path");
const run = promisify(execFile);
-// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout.
+const IS_MAC = process.platform === "darwin";
+const IS_WIN = process.platform === "win32";
+
+// Packaged: /{ui,scripts,python}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts");
-const LOG_DIR = path.join(os.homedir(), "Library", "Logs", "Frame Control");
+const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
+ : path.join(app.getPath("userData"), "logs");
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
@@ -25,15 +29,18 @@ let server = null;
let url = null;
let win = null;
let quitting = false;
+let python = null;
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
-// Homebrew's python3, rsync and adb. Take PATH from the login shell instead.
+// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare).
+// Take PATH from the login shell instead. Windows has no login shell to ask.
// Runs asynchronously so a slow shell profile can't freeze the window.
let cachedPath = null;
async function loginPath() {
+ if (IS_WIN) return process.env.PATH || "";
if (cachedPath) return cachedPath;
- const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh";
- const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
+ const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
+ const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
let fromShell = "";
try {
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
@@ -46,19 +53,40 @@ async function loginPath() {
return joined;
}
+// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
async function findPython(env) {
- for (const dir of env.PATH.split(":")) {
- const p = path.join(dir, "python3");
+ const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
+ const candidates = [];
+ if (IS_WIN) candidates.push(path.join(ROOT, "python", "python.exe"));
+ for (const dir of env.PATH.split(path.delimiter)) {
+ // The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
+ if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
+ for (const name of names) candidates.push(path.join(dir, name));
+ }
+ for (const p of candidates) {
try {
fs.accessSync(p, fs.constants.X_OK);
- // /usr/bin/python3 is a stub until the Command Line Tools are installed.
- await run(p, ["-c", "import http.server"], { timeout: 10000, env });
+ // /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
+ await run(p, ["-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
+ { timeout: 10000, env, windowsHide: true });
return p;
} catch {}
}
return null;
}
+async function hasSsh(env) {
+ try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
+}
+
+const PYTHON_HELP = IS_MAC
+ ? "Install the Xcode Command Line Tools (xcode-select --install) or Homebrew's python, then reopen the app."
+ : IS_WIN ? "The bundled Python is missing; reinstall Frame Control."
+ : "Install Python 3.8 or later from your distribution (e.g. sudo apt install python3), then reopen the app.";
+const SSH_HELP = IS_WIN
+ ? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client."
+ : "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
+
function freePort() {
return new Promise((resolve, reject) => {
const s = net.createServer();
@@ -80,17 +108,20 @@ function ping(target) {
}
async function startServer() {
- const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
- const python = await findPython(env);
- if (!python) {
- throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
- + "(xcode-select --install) or Homebrew's python, then reopen the app.");
- }
+ const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
+ PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" };
+ python = await findPython(env);
+ if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
+ if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
const port = await freePort();
fs.mkdirSync(LOG_DIR, { recursive: true });
const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
- const child = spawn(python, [SERVER, "--port", String(port)], { env, stdio: ["ignore", log, log] });
+ // stdin stays open while the app runs; the server exits cleanly when it closes.
+ // -X utf8: the bundled Windows Python ignores PYTHON* variables (isolated mode).
+ const child = spawn(python, ["-X", "utf8", SERVER, "--port", String(port), "--exit-on-eof"],
+ { env, stdio: ["pipe", log, log], windowsHide: true });
+ child.stdin.on("error", () => {});
fs.closeSync(log);
server = child;
let exited = null;
@@ -112,13 +143,20 @@ async function startServer() {
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
- child.kill("SIGTERM");
+ endServer(child);
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
}
+// Closing stdin lets server.py close its SSH connections and exit (the only clean
+// way on Windows); SIGTERM does the same elsewhere.
+function endServer(child) {
+ try { child.stdin.end(); } catch {}
+ if (!IS_WIN) child.kill("SIGTERM");
+ setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
+}
+
function stopServer() {
- // server.py handles SIGTERM by closing its shared SSH connection.
- if (server) server.kill("SIGTERM");
+ if (server) endServer(server);
}
function errorPage(message) {
@@ -139,12 +177,12 @@ async function restartServer() {
const old = server;
server = null;
url = null;
- if (old) old.kill("SIGTERM");
+ if (old) endServer(old);
await load();
}
-// The page's sticky header becomes the title bar, clear of the traffic lights.
-const CHROME_CSS = `
+// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
+const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
`;
@@ -183,8 +221,8 @@ async function firstRunCheck() {
type: "info",
message: "Connect to your Steam Frame",
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
- + "Enable Developer Mode, then Developer → Set User Password. Then run the setup script: it finds the "
- + "headset, creates a key, and asks for that password once in Terminal.",
+ + "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the "
+ + "headset, creates a key, and asks for that password once in a terminal window.",
buttons: ["Set Up Connection…", "Later"],
defaultId: 0, cancelId: 1,
});
@@ -195,11 +233,12 @@ function createWindow() {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
- titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
+ ...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
+ : { icon: path.join(__dirname, "build", "icon.png") }),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
win.once("ready-to-show", () => win.show());
- win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
+ if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
// External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => {
if (/^https?:\/\//.test(target)) shell.openExternal(target);
@@ -212,36 +251,45 @@ function createWindow() {
load();
}
-// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
-function runInTerminal(command) {
- const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
- execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
- "-e", "activate", "-e", "end tell"], (err) => {
- if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
- });
+// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
+// ui/frame_host.py, which the server uses too: setup and power actions ask for the
+// Developer Mode password there.
+async function runInTerminal(argv) {
+ try {
+ const env = { ...process.env, PATH: await loginPath() };
+ const py = python || await findPython(env);
+ if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`);
+ await run(py, [path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv],
+ { env, timeout: 15000, windowsHide: true });
+ } catch (err) {
+ dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim());
+ }
}
-const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
-
-function setUpConnection() {
- runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(path.join(SCRIPTS, "connect.sh"))}`);
+async function setUpConnection() {
+ const alias = `FRAME_ALIAS=${FRAME}`;
+ if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
+ const py = python || await findPython({ ...process.env, PATH: await loginPath() });
+ const setup = [py || "python3", path.join(ROOT, "ui", "frame_connect.py")];
+ // A new console inherits our environment on Windows; Linux terminals may not.
+ runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
}
function buildMenu() {
const template = [
- { role: "appMenu" },
+ ...(IS_MAC ? [{ role: "appMenu" }] : []),
{ role: "fileMenu" },
{ role: "editMenu" },
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: setUpConnection },
- { label: "Open SSH in Terminal", click: () => runInTerminal(`ssh ${sh(FRAME)}`) },
+ { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
- { label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) },
+ ...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
],
},
{
@@ -253,7 +301,7 @@ function buildMenu() {
{ type: "separator" }, { role: "togglefullscreen" },
],
},
- { role: "windowMenu" },
+ ...(IS_MAC ? [{ role: "windowMenu" }] : []),
{
role: "help",
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
diff --git a/app/package-lock.json b/app/package-lock.json
index e934d74..117de10 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frame-control",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
diff --git a/app/package.json b/app/package.json
index 7a6f460..d7586b9 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,8 +1,8 @@
{
"name": "frame-control",
"productName": "Frame Control",
- "version": "0.2.0",
- "description": "Mac app for managing a Valve Steam Frame over SSH",
+ "version": "0.3.0",
+ "description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
"license": "MIT",
@@ -10,7 +10,9 @@
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "electron-builder --mac --arm64 --publish never",
- "dist:dir": "electron-builder --mac --arm64 --dir"
+ "dist:dir": "electron-builder --mac --arm64 --dir",
+ "dist:linux": "electron-builder --linux --x64 --arm64 --publish never",
+ "dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -25,7 +27,8 @@
},
"files": [
"main.js",
- "package.json"
+ "package.json",
+ "build/icon.png"
],
"extraResources": [
{
@@ -73,7 +76,8 @@
"extendInfo": {
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
- }
+ },
+ "artifactName": "Frame-Control-mac-${arch}.${ext}"
},
"dmg": {
"title": "Frame Control ${version}"
@@ -82,6 +86,56 @@
"runAsNode": false,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false
+ },
+ "linux": {
+ "target": [
+ "AppImage",
+ "deb"
+ ],
+ "category": "Utility",
+ "icon": "build/icon.png",
+ "executableName": "frame-control",
+ "synopsis": "Manage a Valve Steam Frame over SSH",
+ "artifactName": "Frame-Control-linux-${arch}.${ext}",
+ "desktop": {
+ "entry": {
+ "StartupWMClass": "frame-control"
+ }
+ }
+ },
+ "deb": {
+ "depends": [
+ "python3",
+ "openssh-client"
+ ]
+ },
+ "win": {
+ "target": [
+ "nsis",
+ "zip"
+ ],
+ "icon": "build/icon.png",
+ "artifactName": "Frame-Control-win-${arch}.${ext}",
+ "extraResources": [
+ {
+ "from": "build/python-win",
+ "to": "python",
+ "filter": [
+ "**/*"
+ ]
+ }
+ ]
+ },
+ "nsis": {
+ "oneClick": false,
+ "perMachine": false,
+ "allowToChangeInstallationDirectory": true,
+ "artifactName": "Frame-Control-Setup-${arch}.${ext}"
}
+ },
+ "homepage": "https://github.com/saphid/steam-frame",
+ "author": {
+ "name": "saphid",
+ "email": "4596216+saphid@users.noreply.github.com"
}
}
diff --git a/docs/frame-control.md b/docs/frame-control.md
new file mode 100644
index 0000000..e612947
--- /dev/null
+++ b/docs/frame-control.md
@@ -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`).
diff --git a/docs/img/icon.png b/docs/img/icon.png
new file mode 100644
index 0000000..b4e5a57
Binary files /dev/null and b/docs/img/icon.png differ
diff --git a/docs/scripts.md b/docs/scripts.md
new file mode 100644
index 0000000..a1a1e0f
--- /dev/null
+++ b/docs/scripts.md
@@ -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` |
+
diff --git a/scripts/build-chromium-xr.sh b/scripts/build-chromium-xr.sh
index e81cb81..9c477f4 100755
--- a/scripts/build-chromium-xr.sh
+++ b/scripts/build-chromium-xr.sh
@@ -1,6 +1,7 @@
#!/bin/bash
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
-# (8441736 + 8132979, bug 506004811) so WebXR immersive-vr works on the Frame.
+# (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"'
@@ -43,6 +44,55 @@ 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 level(1);
++ const Arg 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"
diff --git a/scripts/chromium-xr.sh b/scripts/chromium-xr.sh
index 8e320ff..2fb8e2d 100755
--- a/scripts/chromium-xr.sh
+++ b/scripts/chromium-xr.sh
@@ -9,7 +9,7 @@
#
# Usage:
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
-# scripts/chromium-xr.sh launch [URL] # opens in the headset desktop
+# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
set -euo pipefail
@@ -35,10 +35,21 @@ case "${1:-}" in
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
;;
launch)
- # run-on-frame starts in $HOME on the Frame, so the profile path is relative.
- exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \
+ # 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/}"
;;
diff --git a/tests/test_server.py b/tests/test_server.py
index 17bf9b0..5511184 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -136,6 +136,8 @@ class ServerGuards(unittest.TestCase):
class StatusProbe(unittest.TestCase):
+ # frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
+ @unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
def test_runs_off_device_and_prints_one_json_object(self):
# The probe runs on the Frame; elsewhere every field must degrade to null/empty.
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_status.py")],
diff --git a/ui/frame_android.py b/ui/frame_android.py
index 62f898b..2536129 100644
--- a/ui/frame_android.py
+++ b/ui/frame_android.py
@@ -8,7 +8,9 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
"""
-import glob, json, os, re, shlex, subprocess, sys, threading, time, zipfile, zlib
+import glob, json, os, re, shlex, shutil, subprocess, sys, threading, time, zipfile, zlib
+
+import frame_host
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
@@ -27,7 +29,9 @@ class FrameError(RuntimeError):
def ssh(cmd, input=None, timeout=120):
try:
- p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True,
+ # No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
+ feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
+ p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}')
@@ -53,19 +57,17 @@ def game_id(shortcut_appid):
def aapt2():
- found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
- + glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
- + glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
- return found[-1] if found else None
+ exe = 'aapt2.exe' if frame_host.WINDOWS else 'aapt2'
+ found = sorted(f for d in frame_host.android_sdk_dirs() for f in glob.glob(os.path.join(d, 'build-tools', '*', exe)))
+ return found[-1] if found else shutil.which('aapt2')
def apk_info(path):
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
tool = aapt2()
if not tool:
- raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
- 'sdkmanager "build-tools;36.0.0"')
- out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
+ raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
+ out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, stdin=subprocess.DEVNULL, text=True).stdout
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
if not m:
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
@@ -105,14 +107,23 @@ def check_installable(info):
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
-def _rsync(src, dest, *extra, timeout=600):
+def _copy(src, dest, executable=False, timeout=600):
+ """Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
+ name = os.path.basename(src)
+ rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
+ if rsync:
+ cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
+ '-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}']
+ else:
+ cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
- subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
- check=True, capture_output=True, text=True, timeout=timeout)
+ subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
- raise FrameError(f'copying {os.path.basename(src)} to the Frame timed out')
+ raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
- raise FrameError(f'copying {os.path.basename(src)} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
+ raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
+ if executable and not rsync:
+ ssh(f'chmod u+x {shlex.quote(dest)}')
def _shortcut_ids():
@@ -146,8 +157,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
ok = False
try:
ssh(f'mkdir -p {d}')
- _rsync(apk_path, f'{d}/app.apk.part')
- _rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
+ _copy(apk_path, f'{d}/app.apk.part')
+ _copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
icon = ''
if info['icon_png']:
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
diff --git a/ui/frame_catalog.py b/ui/frame_catalog.py
index 4638a61..28c0161 100644
--- a/ui/frame_catalog.py
+++ b/ui/frame_catalog.py
@@ -10,9 +10,12 @@ sys.path.insert(0, CATALOG)
import build as catalog_build # noqa: E402
import reports # noqa: E402
import frame_android # noqa: E402
+import frame_host # noqa: E402
import frame_compat_db as compat_db # noqa: E402
-CACHE = (os.path.expanduser('~/Library/Caches/Frame Control/apk') if '.app/Contents/Resources' in CATALOG
+# Inside the installed app the catalogue folder is read-only, so downloads go to
+# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
+CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
else os.path.join(CATALOG, 'data', 'cache'))
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
_lock = threading.Lock()
diff --git a/ui/frame_compat_db.py b/ui/frame_compat_db.py
index 3259e42..98c67c4 100644
--- a/ui/frame_compat_db.py
+++ b/ui/frame_compat_db.py
@@ -1,20 +1,23 @@
"""Frame Control's compatibility database: a private Lakebed capsule
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
-write, using a key kept in the macOS Keychain (service frame-control-compat-db,
-account app-key).
+write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
+frame-control-compat-db, account app-key). Without one (anyone but the
+maintainer), reports stay local.
New reports go to a local outbox first and are sent from there, so nothing is
lost offline. A mirror of every report is kept for offline reads. Both live in
-~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
+frame_host.data_dir('compat-db'). Python stdlib only.
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
(import restores a backup; reports already in the database are skipped.)
"""
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
+import frame_host
+
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
KEYCHAIN = ('frame-control-compat-db', 'app-key')
-STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
+STATE = str(frame_host.data_dir('compat-db'))
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
MIRROR = os.path.join(STATE, 'compat-mirror.json')
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
@@ -32,17 +35,19 @@ def key():
k = os.environ.get('FRAME_CONTROL_KEY')
if k:
return k
- p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
- capture_output=True, text=True)
- if p.returncode != 0 or not p.stdout.strip():
- raise DBError('No compatibility-database key in the Keychain '
- f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
+ p = None
+ if frame_host.MAC:
+ p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
+ capture_output=True, text=True)
+ if p is None or p.returncode != 0 or not p.stdout.strip():
+ raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
+ f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
return p.stdout.strip()
def shared():
"""Whether reports reach the shared database. Without the key (anyone but the
- maintainer), reports stay in this Mac's outbox and ratings come from the catalogue."""
+ maintainer), reports stay in this computer's outbox and ratings come from the catalogue."""
try:
key()
return True
diff --git a/ui/frame_connect.py b/ui/frame_connect.py
new file mode 100644
index 0000000..bdf93ec
--- /dev/null
+++ b/ui/frame_connect.py
@@ -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:]))
diff --git a/ui/frame_host.py b/ui/frame_host.py
new file mode 100644
index 0000000..3833e06
--- /dev/null
+++ b/ui/frame_host.py
@@ -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:])
diff --git a/ui/frame_store.py b/ui/frame_store.py
index 8373623..8b554ad 100644
--- a/ui/frame_store.py
+++ b/ui/frame_store.py
@@ -1,4 +1,4 @@
-"""Mac side: search the Steam store and look up each result's Steam Frame rating.
+"""Computer side: search the Steam store and look up each result's Steam Frame rating.
Uses the store's public endpoints (no key, no login):
api/storesearch name search, price in the IP's currency
diff --git a/ui/index.html b/ui/index.html
index eb5ec42..b242cf3 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -384,8 +384,8 @@
Screenshots
-
-
+
+
Loading…
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
@@ -465,7 +465,7 @@
-
+
Clipboard needs the desktop panel open in the headset.
@@ -522,7 +522,7 @@
-
Sleep, Restart and Shut down open Terminal for the Developer Mode password.
+
Sleep, Restart and Shut down open a terminal window for the Developer Mode password.
@@ -613,6 +613,15 @@ $("bottombar").onclick = () => {
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
};
+// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
+const HOST = { computer: "computer" };
+function applyHostWording(h) {
+ Object.assign(HOST, h);
+ $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
+ $("shotsSaveNew").textContent = `Save new to ${h.computer}`;
+ $("clipMac").textContent = `Send ${h.computer} clipboard`;
+}
+
async function api(path, body) {
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
@@ -1060,7 +1069,7 @@ $("clipSend").onclick = () => {
if (!text) return toast("Nothing to send", true);
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
};
-$("clipMac").onclick = () => act("Send Mac clipboard", () => api("/api/clipboard", { fromMac: true }), $("clipMac"));
+$("clipMac").onclick = () => act($("clipMac").textContent, () => api("/api/clipboard", { fromComputer: true }), $("clipMac"));
// ---- file drop ----
const drop = $("drop");
@@ -1091,7 +1100,7 @@ function upload(file, mode) {
}
async function sendFiles(files) {
for (const f of files) {
- if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; use scripts/push.sh`, true); continue; }
+ if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; zip the folder first`, true); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
}
@@ -1461,7 +1470,7 @@ async function loadReports() {
catch (e) { $("repList").innerHTML = `
${esc(e.message)}
`; return; }
$("repHint").textContent = shared
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
- : "Reports are saved on this Mac and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
+ : "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
const k = r.rating || r.result;
@@ -1522,6 +1531,7 @@ $("repForm").onsubmit = async e => {
finally { $("repSave").disabled = false; }
};
loadReports();
+api("/api/host").then(applyHostWording).catch(() => {});
// ---- Steam screenshots from the headset ----
const shots = { list: [], urls: [] };
@@ -1547,14 +1557,14 @@ async function loadShots() {
} finally { $("shotsRefresh").disabled = false; }
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
const unsaved = shots.list.filter(s => !s.saved).length;
- $("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this Mac` : "") : "";
+ $("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
$("shotsSaveNew").disabled = !unsaved;
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `
`;
// Thumbnails one at a time over the shared SSH connection.
@@ -1606,7 +1616,7 @@ $("shotGrid").onclick = e => {
};
$("shotsRefresh").onclick = loadShots;
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
-$("shotsFolder").onclick = e => act("Show in Finder", () => api("/api/open", { what: "shots" }), e.currentTarget);
+$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
// ---- nav highlight follows scroll ----
const spy = new IntersectionObserver(entries => {
diff --git a/ui/server.py b/ui/server.py
index 7246d15..5415c51 100755
--- a/ui/server.py
+++ b/ui/server.py
@@ -1,18 +1,20 @@
#!/usr/bin/env python3
-"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
+"""Frame Control: a small local web UI for managing the Steam Frame from a computer.
-Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
-SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
+Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
+Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
+up by scripts/connect.sh or ui/frame_connect.py.
-Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
+Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
Env: FRAME_ALIAS (default frame)
"""
import argparse
+import base64
import http.client
import json
import os
+import queue
import re
-import select
import shlex
import shutil
import signal
@@ -26,19 +28,25 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
-import frame_android
-import frame_catalog
-import frame_store
+# Windows' embedded Python (bundled with the app) doesn't put the script's own
+# folder on sys.path, so add it for the sibling modules below.
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import frame_android # noqa: E402
+import frame_catalog # noqa: E402
+import frame_host # noqa: E402
+import frame_store # noqa: E402
HERE = Path(__file__).resolve().parent
-SCRIPTS = HERE.parent / "scripts"
FRAME = os.environ.get("FRAME_ALIAS", "frame")
-# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
-# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
-CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
-MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
+if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
+ sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
+# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
+# supports it (not on Windows: there every command connects on its own).
+CONTROL = frame_host.control_path()
+MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
# Commands use the master when it's up and connect directly when it isn't.
-SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
+SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
# Android helpers share the multiplexed connection when it's up.
frame_android.SSH_OPTS = SSH[1:]
@@ -82,9 +90,12 @@ def ensure_master():
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
"""
global _master
+ if not CONTROL:
+ return
+
def up():
try:
- return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
+ return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
timeout=5).returncode == 0
except subprocess.TimeoutExpired:
return False
@@ -97,7 +108,7 @@ def ensure_master():
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2", "-N", FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL, start_new_session=True)
+ stderr=subprocess.DEVNULL, **frame_host.DETACHED)
for _ in range(60):
if up() or _master.poll() is not None:
return
@@ -107,7 +118,10 @@ def ensure_master():
def ssh(remote, *, stdin=None, timeout=30, text=True):
try:
ensure_master()
- r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True,
+ # Never let ssh inherit our stdin: under the app it's the pipe held open for
+ # --exit-on-eof, and Windows' ssh.exe waits on it forever.
+ feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
+ r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}")
@@ -119,40 +133,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
return r.stdout
-def script(name, *args, stdin=None, timeout=900):
- """Run one of ../scripts and return its combined output."""
- try:
- r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
- stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
- env={**os.environ, "FRAME_ALIAS": FRAME})
- except subprocess.TimeoutExpired:
- raise Failure(f"{name} timed out")
- out = strip_ansi(r.stdout).strip()
- if r.returncode != 0:
- raise Failure(out or f"{name} exited {r.returncode}")
- return out
-
-
def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
-def terminal(command):
- """Open Terminal.app running `command` (for anything needing a password)."""
- as_str = command.replace("\\", "\\\\").replace('"', '\\"')
- r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
- "-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
- capture_output=True, text=True)
- if r.returncode != 0:
- # Usually macOS Automation consent for Terminal was denied.
- raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
-
-
-def open_app(name, fallback_url):
- if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
- return f"Opened {name}"
- subprocess.run(["open", fallback_url])
- return f"{name} isn't installed; opened its download page"
+def terminal(argv):
+ """Open a terminal window running argv (for anything needing a password)."""
+ try:
+ return frame_host.open_terminal(argv)
+ except frame_host.HostError as e:
+ raise Failure(str(e), 500)
# ---- actions ---------------------------------------------------------------
@@ -259,7 +249,7 @@ def save_shots(body):
try:
try:
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
- capture_output=True, text=True, timeout=300)
+ capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
@@ -368,13 +358,44 @@ def set_volume(body):
return {"message": "Volume updated"}
+# Runs on the Frame, clipboard text on stdin. Verified 2026-09-25 (SteamOS 0.3.0
+# vr, build 20260922): the headset desktop is a nested Plasma Wayland session
+# inside gamescope with its own D-Bus bus, and wl-copy/xclip are not installed.
+# Klipper (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so
+# borrow plasmashell's bus address. Same as scripts/paste-to-frame.sh.
+PASTE = r"""set -u
+text=$(cat; printf x); text=${text%x}
+pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
+if [ -z "$pid" ]; then
+ echo "plasmashell is not running: open the desktop in the headset first." >&2
+ exit 2
+fi
+bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
+if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
+ org.kde.klipper.klipper.setClipboardContents "$text" >/dev/null; then
+ echo "copied via Klipper (${#text} chars)"
+else
+ echo "Klipper call failed (bus: ${bus:-none})" >&2
+ exit 2
+fi
+"""
+# base64 keeps the script intact through every local shell's quoting rules.
+PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
+
+
def clipboard(body):
- if body.get("fromMac"):
- return {"message": script("paste-to-frame.sh", timeout=30)}
- text = body.get("text")
- if not isinstance(text, str) or not text:
- raise Failure("nothing to send", 400)
- return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
+ if body.get("fromMac") or body.get("fromComputer"):
+ try:
+ text = frame_host.clipboard_text()
+ except frame_host.HostError as e:
+ raise Failure(str(e), 500)
+ if not text:
+ raise Failure("The clipboard is empty (or holds something other than text)", 400)
+ else:
+ text = body.get("text")
+ if not isinstance(text, str) or not text:
+ raise Failure("nothing to send", 400)
+ return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
def flatpak(body):
@@ -382,7 +403,11 @@ def flatpak(body):
if not FLATPAK_ID.match(app):
raise Failure("bad Flatpak app ID", 400)
if action == "install":
- return {"message": script("install-apps.sh", app)}
+ # Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
+ ssh("flatpak remote-add --user --if-not-exists flathub "
+ "https://dl.flathub.org/repo/flathub.flatpakrepo && "
+ f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
+ return {"message": f"Installed {app}"}
if action == "uninstall":
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
@@ -391,25 +416,25 @@ def flatpak(body):
def open_thing(body):
what = body.get("what")
- alias = shlex.quote(FRAME)
- if what == "terminal":
- terminal(f"ssh {alias}")
- return {"message": "Opened an SSH session in Terminal"}
- if what in ("reboot", "poweroff", "suspend"):
- # logind answers "challenge" over SSH, so sudo (and the password) is needed.
- terminal(f"ssh -t {alias} sudo systemctl {what}")
- return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
- if what == "steamlink":
- return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
- if what == "rdp":
- return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
- if what == "sftp":
- terminal(f"sftp {alias}")
- return {"message": "Opened an SFTP session in Terminal"}
- if what == "shots":
- SHOTS_DIR.mkdir(parents=True, exist_ok=True)
- subprocess.run(["open", str(SHOTS_DIR)])
- return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
+ try:
+ if what == "terminal":
+ return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
+ if what in ("reboot", "poweroff", "suspend"):
+ # logind answers "challenge" over SSH, so sudo (and the password) is needed.
+ where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
+ return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
+ if what == "steamlink":
+ return {"message": frame_host.open_steam_link()}
+ if what == "rdp":
+ return {"message": frame_host.open_rdp(FRAME)}
+ if what == "sftp":
+ return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
+ if what == "shots":
+ SHOTS_DIR.mkdir(parents=True, exist_ok=True)
+ frame_host.open_path(SHOTS_DIR)
+ return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
+ except frame_host.HostError as e:
+ raise Failure(str(e), 500)
raise Failure("unknown target", 400)
@@ -438,7 +463,7 @@ def android(body):
runtime=body.get("runtime") or "instance",
label=body.get("label"), source=body.get("source"))
name = r.get("label") or pkg
- where = "" if frame_catalog.compat_db.shared() else " on this Mac"
+ where = "" if frame_catalog.compat_db.shared() else " on this computer"
return {"message": f"Saved your report for {name}{where}", "report": r}
except frame_android.FrameError as e:
raise Failure(str(e))
@@ -464,16 +489,15 @@ _live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the
def adb_path():
- for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
- str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
- if cand and os.access(cand, os.X_OK):
- return cand
- raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
+ try:
+ return frame_host.adb()
+ except frame_host.HostError as e:
+ raise Failure(str(e), 500)
def adb(adb_bin, *args, timeout=20):
try:
- r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
+ r = subprocess.run([adb_bin, *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"adb {' '.join(args[-2:])} timed out")
@@ -490,7 +514,7 @@ def free_local_port():
class AdbTunnel:
- """SSH forwards from Mac loopback to Frame ADB ports, plus adb connections.
+ """SSH forwards from local loopback to Frame ADB ports, plus adb connections.
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
disconnects adb and kills the ssh process, whatever happened inside.
@@ -588,7 +612,7 @@ class AdbTunnel:
self._stop_ssh()
for p in self.local:
try:
- subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10)
+ subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
except (subprocess.TimeoutExpired, OSError):
pass
finally:
@@ -742,6 +766,50 @@ POST = {"/api/android/display": android_display, "/api/android": android,"/api/l
# ---- HTTP ------------------------------------------------------------------
+def _pipe_reader(pipe):
+ """Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
+ chunks = queue.Queue() # unbounded: the pump never blocks, so it ends at EOF
+
+ def pump():
+ try:
+ while True:
+ chunk = pipe.read1(1 << 16) if hasattr(pipe, "read1") else os.read(pipe.fileno(), 1 << 16)
+ chunks.put(chunk)
+ if not chunk:
+ return
+ except (OSError, ValueError):
+ chunks.put(b"")
+
+ threading.Thread(target=pump, daemon=True).start()
+ return chunks
+
+
+def _next_chunk(chunks, timeout):
+ """The next chunk, or b"" at end of stream or after `timeout` seconds of silence."""
+ try:
+ return chunks.get(timeout=timeout)
+ except queue.Empty:
+ return b""
+
+
+def push_file(path, dest="Downloads/"):
+ """Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
+ name = Path(path).name
+ try:
+ # Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
+ if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
+ cmd = ["rsync", "-a", "-e", shlex.join(SSH), str(path), f"{FRAME}:{shlex.quote(dest)}"]
+ else:
+ # Modern scp uses SFTP, so the remote path isn't parsed by a shell.
+ cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
+ r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
+ except subprocess.TimeoutExpired:
+ raise Failure(f"Copying {name} timed out")
+ if r.returncode != 0:
+ raise Failure(strip_ansi(r.stderr or r.stdout).strip() or f"copy exited {r.returncode}")
+ return f"Sent {name} to ~/{dest}"
+
+
class Handler(BaseHTTPRequestHandler):
server_version = "FrameControl/1"
timeout = 60 # per socket operation, so a stalled client can't hold a thread
@@ -788,6 +856,9 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
+ elif path == "/api/host":
+ self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
+ "computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/android":
ensure_master()
self.send_json({"apps": frame_android.list_apps()})
@@ -869,11 +940,10 @@ class Handler(BaseHTTPRequestHandler):
old, _stream_proc = _stream_proc, proc
if old and old.poll() is None:
old.terminate()
- fd = proc.stdout.fileno()
+ chunks = _pipe_reader(proc.stdout)
# Nothing is sent until the first bytes arrive, so a failure to
# start still comes back as a JSON error.
- ready, _, _ = select.select([fd], [], [], 20)
- first = os.read(fd, 1 << 16) if ready else b""
+ first = _next_chunk(chunks, 20)
if not first:
proc.kill()
proc.wait()
@@ -894,8 +964,7 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.flush()
# A stalled headset view ends the stream rather than
# holding this thread (and the page) forever.
- ready, _, _ = select.select([fd], [], [], STREAM_STALL)
- chunk = os.read(fd, 1 << 16) if ready else b""
+ chunk = _next_chunk(chunks, STREAM_STALL)
except OSError:
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
finally:
@@ -952,7 +1021,7 @@ class Handler(BaseHTTPRequestHandler):
except frame_android.FrameError as e:
raise Failure(str(e), 400)
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
- return {"message": script("push.sh", str(dest))}
+ return {"message": push_file(dest)}
finally:
shutil.rmtree(tmp, ignore_errors=True)
@@ -960,9 +1029,18 @@ class Handler(BaseHTTPRequestHandler):
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
+ ap.add_argument("--exit-on-eof", action="store_true",
+ help="stop cleanly when stdin closes (the app closes it on quit; "
+ "Windows has no SIGTERM to catch)")
args = ap.parse_args()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
- signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
+ if not frame_host.WINDOWS:
+ signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
+ if args.exit_on_eof:
+ def watch_stdin():
+ sys.stdin.buffer.read()
+ threading.Thread(target=httpd.shutdown, daemon=True).start()
+ threading.Thread(target=watch_stdin, daemon=True).start()
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try:
httpd.serve_forever()
@@ -970,7 +1048,8 @@ def main():
pass
finally:
# The master was started with -N, so it stays up until told to exit.
- subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
+ if CONTROL:
+ subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
if _master and _master.poll() is None:
_master.terminate()
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way