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. +Frame Control icon -It's an unofficial hobby project, not affiliated with Valve. +# Frame Control -Status: written 2026-09-25 and checked against a real Frame the same day -(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app -and most scripts are **verified** on the device. The scripts table below marks -each one, and [docs/open-questions.md](docs/open-questions.md#verified-on-device-2026-09-25) -lists what's still unchecked. +**Manage your Valve Steam Frame from your computer.**
+See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH. -## Trying it out +[![Latest release](https://img.shields.io/github/v/release/saphid/steam-frame?label=release&color=1a9fff)](https://github.com/saphid/steam-frame/releases/latest) +[![Platforms](https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-2a475e?label=runs%20on)](#install) +[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE) -You need: +[**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: +Frame Control showing the headset view, battery and status, and the Steam library -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. + +
+ +**⚡ One-click tools**
+SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down. + +
+ +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 ``` -![Frame Control](docs/img/frame-control.png) +If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`), +or run it with `--appimage-extract-and-run`. Sending the clipboard needs +`wl-clipboard` (Wayland) or `xclip` (X11). +
-- **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.