mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 09:00:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7ee97cd3 | ||
|
|
34ce457332 | ||
|
|
8a90e3e34f | ||
|
|
03729ce950 | ||
|
|
37153f69ae | ||
|
|
52d01bd815 | ||
|
|
e210407f31 | ||
|
|
fc2fa65f0d | ||
|
|
2a4a709ced | ||
|
|
cfb7465c22 | ||
|
|
07de29d58a | ||
|
|
f6e77cd98c |
No files matched your search
@@ -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
|
||||||
@@ -31,4 +31,26 @@ jobs:
|
|||||||
- name: Server tests
|
- name: Server tests
|
||||||
run: python -m unittest discover -s tests -v
|
run: python -m unittest discover -s tests -v
|
||||||
- name: App syntax
|
- name: App syntax
|
||||||
run: node --check app/main.js && node --check app/build/make-icon.js
|
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-python.js
|
||||||
|
|
||||||
|
# The server runs on each desktop OS the app ships for. Windows uses the same
|
||||||
|
# Python version the app bundles (app/build/fetch-python.js).
|
||||||
|
server-tests:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
python: "3.12"
|
||||||
|
- os: macos-latest
|
||||||
|
python: "3.12"
|
||||||
|
- os: ubuntu-latest
|
||||||
|
python: "3.13"
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
- name: Server tests
|
||||||
|
run: python -m unittest discover -s tests -v
|
||||||
@@ -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
|
||||||
@@ -1,253 +1,189 @@
|
|||||||
# Steam Frame ↔ Mac
|
<div align="center">
|
||||||
|
|
||||||
**Frame Control** is a Mac app for managing a Valve Steam Frame (standalone VR
|
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
|
||||||
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
|
**Manage your Valve Steam Frame from your computer.**<br>
|
||||||
(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app
|
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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).
|
<br>
|
||||||
- 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:
|
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
|
||||||
|
|
||||||
1. Download the DMG from the
|
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||||
[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.
|
|
||||||
|
|
||||||
**Feedback:** please open a
|
</div>
|
||||||
[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
|
<table>
|
||||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
<tr>
|
||||||
thing you type on the headset is a password you choose.
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
On the Frame:
|
**👓 Headset view**<br>
|
||||||
|
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||||
|
|
||||||
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
|
</td>
|
||||||
2. Scroll down to the **Developer** section and click **Set User Password**.
|
<td width="50%" valign="top">
|
||||||
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**<br>
|
||||||
|
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:
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
```sh
|
**🎮 Steam games**<br>
|
||||||
git clone https://github.com/saphid/steam-frame.git && cd steam-frame
|
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||||
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
|
|
||||||
ssh frame # passwordless from now on
|
|
||||||
```
|
|
||||||
|
|
||||||
`connect.sh` does four things:
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
|
**🤖 Android apps**<br>
|
||||||
- creates a dedicated key (`~/.ssh/id_ed25519_frame`)
|
About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
|
||||||
- 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
|
</td>
|
||||||
logins.
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
|
**📁 Files and clipboard**<br>
|
||||||
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
|
Drag files onto the window to send them. Send text or your clipboard straight to the headset's desktop.
|
||||||
(both **confirmed on Steam Frame**, Valve official).
|
|
||||||
|
|
||||||
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
|
</td>
|
||||||
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
|
<td valign="top">
|
||||||
characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
|
|
||||||
Frame's Linux desktop. The script it serves installs your Mac's public key and
|
|
||||||
enables `sshd`. See [docs/ssh.md](docs/ssh.md#fallback-bootstrap-one-liner).
|
|
||||||
|
|
||||||
## Recommended options
|
**📸 Screenshots**<br>
|
||||||
|
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||||
|
|
||||||
| Goal | Recommended | Confidence |
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**🧩 Flatpaks and display**<br>
|
||||||
|
Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**⚡ One-click tools**<br>
|
||||||
|
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Nothing is installed on the Frame for any of this: the app uses what SteamOS
|
||||||
|
already ships. [How each feature works](docs/frame-control.md).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
| | Download | Needs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Python 3 (`xcode-select --install`) |
|
||||||
| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
|
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra: Python is bundled, and SSH is built into Windows |
|
||||||
| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
|
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `python3` and `ssh` (most desktops have both) |
|
||||||
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
|
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | same |
|
||||||
| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
|
|
||||||
|
|
||||||
Details: [docs/ssh.md](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
|
Optional: `adb` for Android apps
|
||||||
[docs/file-transfer.md](docs/file-transfer.md),
|
([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` ·
|
||||||
[docs/open-questions.md](docs/open-questions.md). For how the Frame's software
|
Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`).
|
||||||
fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.md).
|
|
||||||
|
|
||||||
## Windows anywhere in the room
|
<details>
|
||||||
|
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||||
|
|
||||||
The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
|
There's no paid Apple developer account behind it, so macOS says the app is
|
||||||
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
|
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||||
for any Linux app tagged with an app id of its own:
|
quarantine once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/panel-on-frame.sh konsole
|
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
|
The first time, macOS also asks to allow local network access (for SSH) and
|
||||||
controls to place each panel. See [docs/panels.md](docs/panels.md).
|
control of Terminal (for the password prompts).
|
||||||
|
</details>
|
||||||
|
|
||||||
## Frame Control (Mac app)
|
<details>
|
||||||
|
<summary><b>Windows: SmartScreen warning</b></summary>
|
||||||
|
|
||||||
As of 2026-09-25 no other Mac app manages the Frame end to end.
|
The installer isn't code-signed, so Windows SmartScreen may say it protected
|
||||||
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
|
your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
|
||||||
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
|
installer: unzip it anywhere and run `Frame Control.exe`.
|
||||||
Windows-only. Steam Link views the headset. **Frame Control** is a Mac app over
|
</details>
|
||||||
the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
|
|
||||||
the same UI in a browser without packaging:
|
<details>
|
||||||
|
<summary><b>Linux: running the AppImage</b></summary>
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/frame-ui.sh # opens http://127.0.0.1:47810 in its own window
|
chmod +x Frame-Control-linux-*.AppImage && ./Frame-Control-linux-*.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||

|
If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
|
||||||
|
or run it with `--appimage-extract-and-run`. Sending the clipboard needs
|
||||||
|
`wl-clipboard` (Wayland) or `xclip` (X11).
|
||||||
|
</details>
|
||||||
|
|
||||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
## Set up the headset
|
||||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
|
||||||
a camera into one lens, or both eyes, 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.
|
|
||||||
|
|
||||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
You type one password on the headset, once. Everything else happens on your
|
||||||
with a non-local `Host` header, and any `/api/` request without a custom
|
computer.
|
||||||
header, so other websites can't drive it or read captures. It keeps a single multiplexed SSH connection open, so
|
|
||||||
status and each capture take about 0.3s. Headset captures are deleted from the
|
|
||||||
Frame as soon as they're copied, because they show everything on screen,
|
|
||||||
including anything private. The look follows the Steam client: its palette,
|
|
||||||
Motiva Sans (loaded from Valve's CDN), portrait library capsules and green
|
|
||||||
Play buttons. **Verified on the Frame 2026-09-25:** status and charging details,
|
|
||||||
both capture modes (headset view while in use, and a blank frame in standby,
|
|
||||||
which the UI labels), clipboard, volume, file push, and input validation. **Not yet exercised from the UI:** Launch, Flatpak
|
|
||||||
install/remove, APK drop, and the power buttons. Each of these calls a
|
|
||||||
command or script that was verified separately.
|
|
||||||
|
|
||||||
### Mac app
|
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
|
||||||
|
in the Developer section, **Set User Password**. Pick something short:
|
||||||
|
you'll type it once more on your computer and then never again.
|
||||||
|
2. **On your computer:** open Frame Control. It offers to **Set Up
|
||||||
|
Connection**, which finds the headset, creates an SSH key, and asks for that
|
||||||
|
password once in a terminal window. If it can't find the Frame, type the
|
||||||
|
IP address from the Frame's Quick Settings.
|
||||||
|
3. That's it. The app now reaches the headset whenever it's awake and on the
|
||||||
|
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||||
|
|
||||||
`app/` wraps the same UI as a standalone Mac app (Electron). The app bundles
|
**What it changes:** only what you click. Installs go to your user account on
|
||||||
`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`.
|
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
|
||||||
It starts `ui/server.py` on a free loopback port and shows it in its own window.
|
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
|
||||||
The server stops when you quit the app. A prebuilt DMG for Apple Silicon is
|
entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
|
||||||
attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
|
|
||||||
|
|
||||||
```sh
|
## Feedback
|
||||||
cd app
|
|
||||||
npm install
|
|
||||||
npm run dist # → app/dist/Frame Control-<version>-arm64.dmg (and a .zip)
|
|
||||||
npm start # run from the checkout without packaging
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the DMG and drag **Frame Control** to Applications. You need `python3` on
|
This is a first public test, so reports are really useful, especially from
|
||||||
the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your
|
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
|
||||||
login shell, so Homebrew's `rsync` and `adb` work when you launch it from
|
with:
|
||||||
Finder. Each time it starts while there's no `frame` SSH alias, the app offers
|
|
||||||
to run `connect.sh` in Terminal. **Frame → Set Up Connection…** does the same
|
|
||||||
at any time. The Frame menu also shows the server log at
|
|
||||||
`~/Library/Logs/Frame Control/server.log`. Installing APKs needs `adb`
|
|
||||||
(`brew install android-platform-tools`). The F-Droid ratings are bundled with the app.
|
|
||||||
|
|
||||||
The build is ad-hoc signed and not notarized. A copy you build yourself opens
|
- what you tried and what happened
|
||||||
normally. A copy downloaded from GitHub Releases is quarantined; clear it with
|
- your computer's OS and your SteamOS build (Steam Settings → System)
|
||||||
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
|
- the server log: **Frame → Show Server Log** in the app
|
||||||
time you use them, macOS asks to allow local network access (for SSH) and
|
|
||||||
control of Terminal (for SSH and power actions). **Verified 2026-09-25:**
|
|
||||||
installed from the DMG, launched from Finder, connected to the Frame, and
|
|
||||||
showed live status and the library.
|
|
||||||
|
|
||||||
## Scripts
|
## Going further
|
||||||
|
|
||||||
| Script | Runs on | Purpose |
|
This repo also holds the scripts behind the app and field notes on how the
|
||||||
|---|---|---|
|
Frame's software fits together, all checked against a real headset and labelled
|
||||||
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
|
**verified** or **inferred**.
|
||||||
| `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` |
|
|
||||||
|
|
||||||
## Security notes
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||||
|
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||||
|
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||||
|
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||||
|
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||||
|
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||||
|
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Security notes</b></summary>
|
||||||
|
|
||||||
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
|
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
|
||||||
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
||||||
@@ -256,7 +192,7 @@ showed live status and the library.
|
|||||||
networks only, and turn Developer Mode off when you don't need it.
|
networks only, and turn Developer Mode off when you don't need it.
|
||||||
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
|
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
|
||||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
||||||
database key lives in the macOS Keychain and is never written to the repo.
|
database key (maintainer-only) is never written to the repo.
|
||||||
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||||
you've switched to key auth, a short password still protects `sudo` and
|
you've switched to key auth, a short password still protects `sudo` and
|
||||||
RDP, so pick one that isn't trivially guessable.
|
RDP, so pick one that isn't trivially guessable.
|
||||||
@@ -264,19 +200,18 @@ showed live status and the library.
|
|||||||
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
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
|
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||||
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
|
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
|
||||||
|
</details>
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m unittest discover -s tests # server guards, validation, Steam helpers; no headset needed
|
python3 -m unittest discover -s tests # server tests; no headset needed
|
||||||
cd app && npm install && npm run dist # build the DMG
|
cd app && npm install && npm start # run the app from the checkout
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Actions runs the tests on Python 3.9, which is the oldest `python3` the app
|
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
|
||||||
may find (Xcode Command Line Tools), plus syntax checks for every script and the
|
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
|
||||||
Electron main process (`.github/workflows/checks.yml`). Anything that touches the
|
into the release. See [building](docs/frame-control.md#building).
|
||||||
headset is verified by hand against a real Frame, and the docs label it
|
|
||||||
**verified** or **inferred**.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
build/python-win/
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Downloads the official Windows embeddable Python into build/python-win, which
|
||||||
|
// the Windows build bundles as resources/python (so Windows users need no Python).
|
||||||
|
// Pinned by version and SHA-256. Run: node build/fetch-python.js
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const fs = require("fs");
|
||||||
|
const https = require("https");
|
||||||
|
const path = require("path");
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
|
||||||
|
const VERSION = "3.12.10";
|
||||||
|
const SHA256 = "4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3";
|
||||||
|
const URL = `https://www.python.org/ftp/python/${VERSION}/python-${VERSION}-embed-amd64.zip`;
|
||||||
|
const OUT = path.join(__dirname, "python-win");
|
||||||
|
|
||||||
|
function get(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume();
|
||||||
|
return resolve(get(res.headers.location));
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
|
||||||
|
const chunks = [];
|
||||||
|
res.on("data", (c) => chunks.push(c));
|
||||||
|
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
}).on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const stamp = path.join(OUT, ".version");
|
||||||
|
if (fs.existsSync(path.join(OUT, "python.exe")) && fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === VERSION) {
|
||||||
|
console.log(`Python ${VERSION} already in ${OUT}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zip = await get(URL);
|
||||||
|
const sum = crypto.createHash("sha256").update(zip).digest("hex");
|
||||||
|
if (sum !== SHA256) throw new Error(`checksum mismatch for ${URL}: ${sum}`);
|
||||||
|
fs.rmSync(OUT, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
const file = path.join(OUT, "python.zip");
|
||||||
|
fs.writeFileSync(file, zip);
|
||||||
|
// bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
|
||||||
|
try { execFileSync("tar", ["-xf", file, "-C", OUT]); }
|
||||||
|
catch { execFileSync("unzip", ["-q", "-o", file, "-d", OUT]); }
|
||||||
|
fs.rmSync(file);
|
||||||
|
fs.writeFileSync(path.join(OUT, ".version"), VERSION);
|
||||||
|
console.log(`Python ${VERSION} -> ${OUT}`);
|
||||||
|
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
+92
-44
@@ -1,6 +1,6 @@
|
|||||||
// Frame Control as a Mac app: starts ui/server.py on a free loopback port and
|
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||||
// shows it in a native window. The server does all the work over the `frame`
|
// a free loopback port and shows it in a native window. The server does all the
|
||||||
// SSH alias; this file only hosts it.
|
// work over the `frame` SSH alias; this file only hosts it.
|
||||||
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
|
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
@@ -12,11 +12,15 @@ const path = require("path");
|
|||||||
|
|
||||||
const run = promisify(execFile);
|
const run = promisify(execFile);
|
||||||
|
|
||||||
// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout.
|
const IS_MAC = process.platform === "darwin";
|
||||||
|
const IS_WIN = process.platform === "win32";
|
||||||
|
|
||||||
|
// Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout.
|
||||||
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
|
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
|
||||||
const SERVER = path.join(ROOT, "ui", "server.py");
|
const SERVER = path.join(ROOT, "ui", "server.py");
|
||||||
const SCRIPTS = path.join(ROOT, "scripts");
|
const SCRIPTS = path.join(ROOT, "scripts");
|
||||||
const LOG_DIR = path.join(os.homedir(), "Library", "Logs", "Frame Control");
|
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
|
||||||
|
: path.join(app.getPath("userData"), "logs");
|
||||||
const LOG = path.join(LOG_DIR, "server.log");
|
const LOG = path.join(LOG_DIR, "server.log");
|
||||||
const BG = "#0d1117";
|
const BG = "#0d1117";
|
||||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||||
@@ -25,15 +29,18 @@ let server = null;
|
|||||||
let url = null;
|
let url = null;
|
||||||
let win = null;
|
let win = null;
|
||||||
let quitting = false;
|
let quitting = false;
|
||||||
|
let python = null;
|
||||||
|
|
||||||
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
|
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
|
||||||
// Homebrew's python3, rsync and adb. Take PATH from the login shell instead.
|
// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare).
|
||||||
|
// Take PATH from the login shell instead. Windows has no login shell to ask.
|
||||||
// Runs asynchronously so a slow shell profile can't freeze the window.
|
// Runs asynchronously so a slow shell profile can't freeze the window.
|
||||||
let cachedPath = null;
|
let cachedPath = null;
|
||||||
async function loginPath() {
|
async function loginPath() {
|
||||||
|
if (IS_WIN) return process.env.PATH || "";
|
||||||
if (cachedPath) return cachedPath;
|
if (cachedPath) return cachedPath;
|
||||||
const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh";
|
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
|
||||||
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
|
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
|
||||||
let fromShell = "";
|
let fromShell = "";
|
||||||
try {
|
try {
|
||||||
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
||||||
@@ -46,19 +53,40 @@ async function loginPath() {
|
|||||||
return joined;
|
return joined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
|
||||||
async function findPython(env) {
|
async function findPython(env) {
|
||||||
for (const dir of env.PATH.split(":")) {
|
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
|
||||||
const p = path.join(dir, "python3");
|
const candidates = [];
|
||||||
|
if (IS_WIN) candidates.push(path.join(ROOT, "python", "python.exe"));
|
||||||
|
for (const dir of env.PATH.split(path.delimiter)) {
|
||||||
|
// The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
|
||||||
|
if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
|
||||||
|
for (const name of names) candidates.push(path.join(dir, name));
|
||||||
|
}
|
||||||
|
for (const p of candidates) {
|
||||||
try {
|
try {
|
||||||
fs.accessSync(p, fs.constants.X_OK);
|
fs.accessSync(p, fs.constants.X_OK);
|
||||||
// /usr/bin/python3 is a stub until the Command Line Tools are installed.
|
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
|
||||||
await run(p, ["-c", "import http.server"], { timeout: 10000, env });
|
await run(p, ["-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
|
||||||
|
{ timeout: 10000, env, windowsHide: true });
|
||||||
return p;
|
return p;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasSsh(env) {
|
||||||
|
try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PYTHON_HELP = IS_MAC
|
||||||
|
? "Install the Xcode Command Line Tools (xcode-select --install) or Homebrew's python, then reopen the app."
|
||||||
|
: IS_WIN ? "The bundled Python is missing; reinstall Frame Control."
|
||||||
|
: "Install Python 3.8 or later from your distribution (e.g. sudo apt install python3), then reopen the app.";
|
||||||
|
const SSH_HELP = IS_WIN
|
||||||
|
? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client."
|
||||||
|
: "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
|
||||||
|
|
||||||
function freePort() {
|
function freePort() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const s = net.createServer();
|
const s = net.createServer();
|
||||||
@@ -80,17 +108,20 @@ function ping(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
|
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
|
||||||
const python = await findPython(env);
|
PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" };
|
||||||
if (!python) {
|
python = await findPython(env);
|
||||||
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
|
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
|
||||||
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
|
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
|
||||||
}
|
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
const log = fs.openSync(LOG, "a");
|
const log = fs.openSync(LOG, "a");
|
||||||
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
|
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
|
||||||
const child = spawn(python, [SERVER, "--port", String(port)], { env, stdio: ["ignore", log, log] });
|
// stdin stays open while the app runs; the server exits cleanly when it closes.
|
||||||
|
// -X utf8: the bundled Windows Python ignores PYTHON* variables (isolated mode).
|
||||||
|
const child = spawn(python, ["-X", "utf8", SERVER, "--port", String(port), "--exit-on-eof"],
|
||||||
|
{ env, stdio: ["pipe", log, log], windowsHide: true });
|
||||||
|
child.stdin.on("error", () => {});
|
||||||
fs.closeSync(log);
|
fs.closeSync(log);
|
||||||
server = child;
|
server = child;
|
||||||
let exited = null;
|
let exited = null;
|
||||||
@@ -112,13 +143,20 @@ async function startServer() {
|
|||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
if (server === child) server = null;
|
if (server === child) server = null;
|
||||||
child.kill("SIGTERM");
|
endServer(child);
|
||||||
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
|
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||||
|
// way on Windows); SIGTERM does the same elsewhere.
|
||||||
|
function endServer(child) {
|
||||||
|
try { child.stdin.end(); } catch {}
|
||||||
|
if (!IS_WIN) child.kill("SIGTERM");
|
||||||
|
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
function stopServer() {
|
function stopServer() {
|
||||||
// server.py handles SIGTERM by closing its shared SSH connection.
|
if (server) endServer(server);
|
||||||
if (server) server.kill("SIGTERM");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorPage(message) {
|
function errorPage(message) {
|
||||||
@@ -139,12 +177,12 @@ async function restartServer() {
|
|||||||
const old = server;
|
const old = server;
|
||||||
server = null;
|
server = null;
|
||||||
url = null;
|
url = null;
|
||||||
if (old) old.kill("SIGTERM");
|
if (old) endServer(old);
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The page's sticky header becomes the title bar, clear of the traffic lights.
|
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||||
const CHROME_CSS = `
|
const CHROME_CSS = IS_MAC && `
|
||||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||||
`;
|
`;
|
||||||
@@ -183,8 +221,8 @@ async function firstRunCheck() {
|
|||||||
type: "info",
|
type: "info",
|
||||||
message: "Connect to your Steam Frame",
|
message: "Connect to your Steam Frame",
|
||||||
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
|
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
|
||||||
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup script: it finds the "
|
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the "
|
||||||
+ "headset, creates a key, and asks for that password once in Terminal.",
|
+ "headset, creates a key, and asks for that password once in a terminal window.",
|
||||||
buttons: ["Set Up Connection…", "Later"],
|
buttons: ["Set Up Connection…", "Later"],
|
||||||
defaultId: 0, cancelId: 1,
|
defaultId: 0, cancelId: 1,
|
||||||
});
|
});
|
||||||
@@ -195,11 +233,12 @@ function createWindow() {
|
|||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||||
title: "Frame Control", backgroundColor: BG, show: false,
|
title: "Frame Control", backgroundColor: BG, show: false,
|
||||||
titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
|
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||||
|
: { icon: path.join(__dirname, "build", "icon.png") }),
|
||||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||||
});
|
});
|
||||||
win.once("ready-to-show", () => win.show());
|
win.once("ready-to-show", () => win.show());
|
||||||
win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||||
// External links open in the default browser; the app never navigates away.
|
// External links open in the default browser; the app never navigates away.
|
||||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||||
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
||||||
@@ -212,36 +251,45 @@ function createWindow() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
|
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
|
||||||
function runInTerminal(command) {
|
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
|
||||||
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
// Developer Mode password there.
|
||||||
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
|
async function runInTerminal(argv) {
|
||||||
"-e", "activate", "-e", "end tell"], (err) => {
|
try {
|
||||||
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
|
const env = { ...process.env, PATH: await loginPath() };
|
||||||
});
|
const py = python || await findPython(env);
|
||||||
|
if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`);
|
||||||
|
await run(py, [path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv],
|
||||||
|
{ env, timeout: 15000, windowsHide: true });
|
||||||
|
} catch (err) {
|
||||||
|
dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
async function setUpConnection() {
|
||||||
|
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||||
function setUpConnection() {
|
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||||
runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(path.join(SCRIPTS, "connect.sh"))}`);
|
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||||
|
const setup = [py || "python3", path.join(ROOT, "ui", "frame_connect.py")];
|
||||||
|
// A new console inherits our environment on Windows; Linux terminals may not.
|
||||||
|
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMenu() {
|
function buildMenu() {
|
||||||
const template = [
|
const template = [
|
||||||
{ role: "appMenu" },
|
...(IS_MAC ? [{ role: "appMenu" }] : []),
|
||||||
{ role: "fileMenu" },
|
{ role: "fileMenu" },
|
||||||
{ role: "editMenu" },
|
{ role: "editMenu" },
|
||||||
{
|
{
|
||||||
label: "Frame",
|
label: "Frame",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Set Up Connection…", click: setUpConnection },
|
{ label: "Set Up Connection…", click: setUpConnection },
|
||||||
{ label: "Open SSH in Terminal", click: () => runInTerminal(`ssh ${sh(FRAME)}`) },
|
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||||
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
|
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
|
||||||
{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) },
|
...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -253,7 +301,7 @@ function buildMenu() {
|
|||||||
{ type: "separator" }, { role: "togglefullscreen" },
|
{ type: "separator" }, { role: "togglefullscreen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ role: "windowMenu" },
|
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||||
{
|
{
|
||||||
role: "help",
|
role: "help",
|
||||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
|
|||||||
+59
-5
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Mac app for managing a Valve Steam Frame over SSH",
|
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||||
"dist": "electron-builder --mac --arm64 --publish never",
|
"dist": "electron-builder --mac --arm64 --publish never",
|
||||||
"dist:dir": "electron-builder --mac --arm64 --dir"
|
"dist:dir": "electron-builder --mac --arm64 --dir",
|
||||||
|
"dist:linux": "electron-builder --linux --x64 --arm64 --publish never",
|
||||||
|
"dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
@@ -25,7 +27,8 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"package.json"
|
"package.json",
|
||||||
|
"build/icon.png"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
@@ -73,7 +76,8 @@
|
|||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
||||||
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
|
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
|
||||||
}
|
},
|
||||||
|
"artifactName": "Frame-Control-mac-${arch}.${ext}"
|
||||||
},
|
},
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"title": "Frame Control ${version}"
|
"title": "Frame Control ${version}"
|
||||||
@@ -82,6 +86,56 @@
|
|||||||
"runAsNode": false,
|
"runAsNode": false,
|
||||||
"enableNodeOptionsEnvironmentVariable": false,
|
"enableNodeOptionsEnvironmentVariable": false,
|
||||||
"enableNodeCliInspectArguments": false
|
"enableNodeCliInspectArguments": false
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"category": "Utility",
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"executableName": "frame-control",
|
||||||
|
"synopsis": "Manage a Valve Steam Frame over SSH",
|
||||||
|
"artifactName": "Frame-Control-linux-${arch}.${ext}",
|
||||||
|
"desktop": {
|
||||||
|
"entry": {
|
||||||
|
"StartupWMClass": "frame-control"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deb": {
|
||||||
|
"depends": [
|
||||||
|
"python3",
|
||||||
|
"openssh-client"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "build/python-win",
|
||||||
|
"to": "python",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"artifactName": "Frame-Control-Setup-${arch}.${ext}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/saphid/steam-frame",
|
||||||
|
"author": {
|
||||||
|
"name": "saphid",
|
||||||
|
"email": "4596216+saphid@users.noreply.github.com"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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`).
|
||||||
@@ -44,7 +44,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
|||||||
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
|
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
|
||||||
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
|
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
|
||||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app (verified 2026-09-26, seccomp sandbox off). What it looks like in the headset is still untested. Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+103
@@ -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` |
|
||||||
|
|
||||||
+53
-19
@@ -46,12 +46,21 @@ Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
|
|||||||
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
|
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
|
||||||
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
|
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
|
||||||
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
|
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
|
||||||
90 GB of disk. It shallow-fetches the CL ref, runs `gclient sync --no-history`,
|
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
|
||||||
installs the sysroot, builds `chrome` with `symbol_level=0` and proprietary
|
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
|
||||||
codecs, and packs `chromium-xr-arm64.tar.xz`. Progress is logged to
|
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
|
||||||
`~/chromium-xr/stage`. The build aborts if `/` drops below 12 GB free.
|
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
|
||||||
|
Progress is logged to `~/chromium-xr/stage`. The build aborts if `/` drops
|
||||||
|
below 12 GB free.
|
||||||
|
|
||||||
First run: a 12-core, 31 GB x64 Linux box, started 2026-09-25.
|
First run, 2026-09-25, on a 12-core, 31 GB x64 Linux box: 9 h 33 min for
|
||||||
|
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
|
||||||
|
takes under a minute, plus about 4 minutes to repack.
|
||||||
|
|
||||||
|
**The extra fix.** The CL's XR seccomp policy refuses `getsockopt`. SteamVR's
|
||||||
|
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
|
||||||
|
so the XR process died with a seccomp crash (arm64 syscall 209). The script
|
||||||
|
allows that one option.
|
||||||
|
|
||||||
## Running it on the Frame
|
## Running it on the Frame
|
||||||
|
|
||||||
@@ -59,24 +68,49 @@ First run: a 12-core, 31 GB x64 Linux box, started 2026-09-25.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
|
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
|
||||||
scripts/chromium-xr.sh launch [URL] # headset desktop, --enable-features=OpenXR
|
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
|
||||||
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
|
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
|
||||||
```
|
```
|
||||||
|
|
||||||
It runs natively, not as a Flatpak, so the XR sandbox and SteamVR's IPC work
|
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
|
||||||
as the CL expects. It uses its own profile (`~/.config/chromium-xr`) and
|
gamescope's X display, the same way as [`panel-on-frame.sh`](panels.md), so
|
||||||
DevTools on loopback port 9223, so it doesn't collide with the Flatpak's 9222.
|
the Plasma desktop doesn't need to be open. It uses its own profile
|
||||||
|
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
|
||||||
|
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
|
||||||
|
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
|
||||||
|
visiting the site*.
|
||||||
|
|
||||||
**Verified 2026-09-25:**
|
**Seccomp is off.** `launch` passes `--disable-seccomp-filter-sandbox`. With
|
||||||
|
the XR seccomp policy on, SteamVR's client reads `/proc/self/status` through
|
||||||
|
Chrome's file broker and gets the broker's pid. SteamVR then binds the app to
|
||||||
|
the wrong process ("Unable to init path manager: VRInitError_Init_Internal")
|
||||||
|
and `xrCreateInstance` fails. The broker can't answer `/proc/self` for another
|
||||||
|
process, so fixing this needs a change in Chromium's broker client or in the
|
||||||
|
CL. The namespace sandbox stays on, but seccomp is off for every process, so
|
||||||
|
use this profile for VR sites rather than everyday browsing.
|
||||||
|
|
||||||
- Vulkan is there: Turnip (Mesa) on Adreno 750, API 1.4.359.
|
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
|
||||||
- Unprivileged user namespaces work (`unshare -Ur true`), so Chromium's
|
this build):
|
||||||
namespace sandbox shouldn't need the setuid `chrome_sandbox`.
|
|
||||||
|
|
||||||
**Unverified (inferred):**
|
- `isSessionSupported('immersive-vr')` is `true`. The WebXR samples page
|
||||||
|
shows "VR support detected".
|
||||||
|
- `requestSession('immersive-vr')` succeeds after the prompt. With a WebGL
|
||||||
|
layer, the first XR frame has a viewer pose with 2 views and a
|
||||||
|
2880 × 1440 framebuffer (1440 × 1440 per eye).
|
||||||
|
- SteamVR moves the app from `VRApplication_OpenXRInstance` to
|
||||||
|
`VRApplication_OpenXRScene` and gives it scene focus. `xrEndFrame` submits
|
||||||
|
both projection views, and the compositor receives the 2880 × 1440 scene.
|
||||||
|
- The OpenXR runtime uses Vulkan (`XR_KHR_vulkan_enable2`). Chromium's own GPU
|
||||||
|
process uses ANGLE on GL, running on zink over Turnip Vulkan (Adreno 750);
|
||||||
|
Chromium's Vulkan backend is off. That doesn't stop the session.
|
||||||
|
- Unprivileged user namespaces work (`unshare -Ur true`), so the namespace
|
||||||
|
sandbox runs without the setuid `chrome_sandbox`.
|
||||||
|
|
||||||
- Chromium's GPU process may still fall back from Vulkan to GL on Turnip.
|
**Not verified yet:** nobody was wearing the headset during the test, so
|
||||||
- An immersive session started from a window on the nested desktop may not
|
SteamVR kept it in standby. The session stayed at
|
||||||
hand over cleanly to the SteamVR compositor.
|
`XR_SESSION_STATE_SYNCHRONIZED` (the page saw `visibilityState: "hidden"`)
|
||||||
- If the sandbox fails to start, `--no-sandbox` is the fallback for a first
|
and only the first frame ran. Still open:
|
||||||
test.
|
|
||||||
|
- Whether the image shows up correctly in the headset, and at what frame rate.
|
||||||
|
- Whether VR180 or 360 video players (DeoVR, DL8 embeds) play in 3D.
|
||||||
|
- Controller and hand input in the session.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
# 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:
|
# 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
|
# 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"'
|
# 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
|
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
||||||
guard
|
guard
|
||||||
cd src
|
cd src
|
||||||
|
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
|
||||||
|
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
|
||||||
|
# process (verified on the Frame 2026-09-26). Allow only that option.
|
||||||
|
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
|
||||||
|
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
index 435e13d396..297453f582 100644
|
||||||
|
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
@@ -11,6 +11,7 @@
|
||||||
|
#include "sandbox/linux/system_headers/linux_syscalls.h"
|
||||||
|
#include "sandbox/policy/linux/sandbox_linux.h"
|
||||||
|
|
||||||
|
+using sandbox::bpf_dsl::AllOf;
|
||||||
|
using sandbox::bpf_dsl::Allow;
|
||||||
|
using sandbox::bpf_dsl::Arg;
|
||||||
|
using sandbox::bpf_dsl::Error;
|
||||||
|
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
|
||||||
|
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||||
|
switch (system_call_number) {
|
||||||
|
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
|
||||||
|
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
|
||||||
|
- // stay disallowed; add a narrow level/optname restriction if ever needed.
|
||||||
|
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
|
||||||
|
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
|
||||||
|
#if defined(__NR_getpeername)
|
||||||
|
case __NR_getpeername:
|
||||||
|
#endif
|
||||||
|
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||||
|
case __NR_get_robust_list:
|
||||||
|
#endif
|
||||||
|
return Allow();
|
||||||
|
+#if defined(__NR_getsockopt)
|
||||||
|
+ case __NR_getsockopt: {
|
||||||
|
+ // SteamVR's IPC client checks who is on the other end of its socket
|
||||||
|
+ // with SO_PEERCRED. Nothing else is readable.
|
||||||
|
+ const Arg<int> level(1);
|
||||||
|
+ const Arg<int> optname(2);
|
||||||
|
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
|
||||||
|
+ .Else(Error(EPERM));
|
||||||
|
+ }
|
||||||
|
+#endif
|
||||||
|
#if defined(__NR_kill)
|
||||||
|
case __NR_kill: {
|
||||||
|
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
|
||||||
|
P
|
||||||
|
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
|
||||||
|
printf '%s\n' "$PEERCRED_PATCH" | git apply
|
||||||
|
stage "applied SO_PEERCRED patch"
|
||||||
|
fi
|
||||||
mkdir -p out/XR
|
mkdir -p out/XR
|
||||||
cat > out/XR/args.gn <<'A'
|
cat > out/XR/args.gn <<'A'
|
||||||
target_os = "linux"
|
target_os = "linux"
|
||||||
|
|||||||
+14
-3
@@ -9,7 +9,7 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
|
# 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
|
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
|
||||||
set -euo pipefail
|
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'
|
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
|
||||||
;;
|
;;
|
||||||
launch)
|
launch)
|
||||||
# run-on-frame starts in $HOME on the Frame, so the profile path is relative.
|
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
|
||||||
exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \
|
# 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 \
|
--user-data-dir=.config/chromium-xr \
|
||||||
--enable-features=OpenXR \
|
--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" \
|
--remote-debugging-port="$DEVTOOLS_PORT" \
|
||||||
"${2:-https://immersive-web.github.io/webxr-samples/}"
|
"${2:-https://immersive-web.github.io/webxr-samples/}"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ class ServerGuards(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class StatusProbe(unittest.TestCase):
|
class StatusProbe(unittest.TestCase):
|
||||||
|
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
|
||||||
|
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
|
||||||
def test_runs_off_device_and_prints_one_json_object(self):
|
def test_runs_off_device_and_prints_one_json_object(self):
|
||||||
# The probe runs on the Frame; elsewhere every field must degrade to null/empty.
|
# The probe runs on the Frame; elsewhere every field must degrade to null/empty.
|
||||||
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_status.py")],
|
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_status.py")],
|
||||||
|
|||||||
+27
-16
@@ -8,7 +8,9 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
|||||||
|
|
||||||
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
||||||
"""
|
"""
|
||||||
import glob, json, os, re, shlex, subprocess, sys, threading, time, zipfile, zlib
|
import glob, json, os, re, shlex, shutil, subprocess, sys, threading, time, zipfile, zlib
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||||
@@ -27,7 +29,9 @@ class FrameError(RuntimeError):
|
|||||||
|
|
||||||
def ssh(cmd, input=None, timeout=120):
|
def ssh(cmd, input=None, timeout=120):
|
||||||
try:
|
try:
|
||||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True,
|
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
||||||
|
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||||
|
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'timed out talking to {FRAME}')
|
raise FrameError(f'timed out talking to {FRAME}')
|
||||||
@@ -53,19 +57,17 @@ def game_id(shortcut_appid):
|
|||||||
|
|
||||||
|
|
||||||
def aapt2():
|
def aapt2():
|
||||||
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
|
exe = 'aapt2.exe' if frame_host.WINDOWS else 'aapt2'
|
||||||
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
|
found = sorted(f for d in frame_host.android_sdk_dirs() for f in glob.glob(os.path.join(d, 'build-tools', '*', exe)))
|
||||||
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
|
return found[-1] if found else shutil.which('aapt2')
|
||||||
return found[-1] if found else None
|
|
||||||
|
|
||||||
|
|
||||||
def apk_info(path):
|
def apk_info(path):
|
||||||
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
|
||||||
tool = aapt2()
|
tool = aapt2()
|
||||||
if not tool:
|
if not tool:
|
||||||
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
|
raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
|
||||||
'sdkmanager "build-tools;36.0.0"')
|
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, stdin=subprocess.DEVNULL, text=True).stdout
|
||||||
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
|
|
||||||
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
||||||
if not m:
|
if not m:
|
||||||
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
|
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
|
||||||
@@ -105,14 +107,23 @@ def check_installable(info):
|
|||||||
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
||||||
|
|
||||||
|
|
||||||
def _rsync(src, dest, *extra, timeout=600):
|
def _copy(src, dest, executable=False, timeout=600):
|
||||||
|
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
|
||||||
|
name = os.path.basename(src)
|
||||||
|
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
|
||||||
|
if rsync:
|
||||||
|
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
|
||||||
|
'-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}']
|
||||||
|
else:
|
||||||
|
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||||
try:
|
try:
|
||||||
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
|
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||||
check=True, capture_output=True, text=True, timeout=timeout)
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame timed out')
|
raise FrameError(f'copying {name} to the Frame timed out')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
raise FrameError(f'copying {os.path.basename(src)} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
|
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
|
||||||
|
if executable and not rsync:
|
||||||
|
ssh(f'chmod u+x {shlex.quote(dest)}')
|
||||||
|
|
||||||
|
|
||||||
def _shortcut_ids():
|
def _shortcut_ids():
|
||||||
@@ -146,8 +157,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
|||||||
ok = False
|
ok = False
|
||||||
try:
|
try:
|
||||||
ssh(f'mkdir -p {d}')
|
ssh(f'mkdir -p {d}')
|
||||||
_rsync(apk_path, f'{d}/app.apk.part')
|
_copy(apk_path, f'{d}/app.apk.part')
|
||||||
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
|
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||||
icon = ''
|
icon = ''
|
||||||
if info['icon_png']:
|
if info['icon_png']:
|
||||||
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
||||||
|
|||||||
+4
-1
@@ -10,9 +10,12 @@ sys.path.insert(0, CATALOG)
|
|||||||
import build as catalog_build # noqa: E402
|
import build as catalog_build # noqa: E402
|
||||||
import reports # noqa: E402
|
import reports # noqa: E402
|
||||||
import frame_android # noqa: E402
|
import frame_android # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
import frame_compat_db as compat_db # noqa: E402
|
import frame_compat_db as compat_db # noqa: E402
|
||||||
|
|
||||||
CACHE = (os.path.expanduser('~/Library/Caches/Frame Control/apk') if '.app/Contents/Resources' in CATALOG
|
# Inside the installed app the catalogue folder is read-only, so downloads go to
|
||||||
|
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
|
||||||
|
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
|
||||||
else os.path.join(CATALOG, 'data', 'cache'))
|
else os.path.join(CATALOG, 'data', 'cache'))
|
||||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|||||||
+15
-10
@@ -1,20 +1,23 @@
|
|||||||
"""Frame Control's compatibility database: a private Lakebed capsule
|
"""Frame Control's compatibility database: a private Lakebed capsule
|
||||||
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
||||||
write, using a key kept in the macOS Keychain (service frame-control-compat-db,
|
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
|
||||||
account app-key).
|
frame-control-compat-db, account app-key). Without one (anyone but the
|
||||||
|
maintainer), reports stay local.
|
||||||
|
|
||||||
New reports go to a local outbox first and are sent from there, so nothing is
|
New reports go to a local outbox first and are sent from there, so nothing is
|
||||||
lost offline. A mirror of every report is kept for offline reads. Both live in
|
lost offline. A mirror of every report is kept for offline reads. Both live in
|
||||||
~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
|
frame_host.data_dir('compat-db'). Python stdlib only.
|
||||||
|
|
||||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
||||||
(import restores a backup; reports already in the database are skipped.)
|
(import restores a backup; reports already in the database are skipped.)
|
||||||
"""
|
"""
|
||||||
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
||||||
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
|
STATE = str(frame_host.data_dir('compat-db'))
|
||||||
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
||||||
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
||||||
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
||||||
@@ -32,17 +35,19 @@ def key():
|
|||||||
k = os.environ.get('FRAME_CONTROL_KEY')
|
k = os.environ.get('FRAME_CONTROL_KEY')
|
||||||
if k:
|
if k:
|
||||||
return k
|
return k
|
||||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
p = None
|
||||||
capture_output=True, text=True)
|
if frame_host.MAC:
|
||||||
if p.returncode != 0 or not p.stdout.strip():
|
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||||
raise DBError('No compatibility-database key in the Keychain '
|
capture_output=True, text=True)
|
||||||
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
if p is None or p.returncode != 0 or not p.stdout.strip():
|
||||||
|
raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
|
||||||
|
f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
||||||
return p.stdout.strip()
|
return p.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def shared():
|
def shared():
|
||||||
"""Whether reports reach the shared database. Without the key (anyone but the
|
"""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:
|
try:
|
||||||
key()
|
key()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -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:]))
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""What differs between the computers Frame Control runs on: macOS, Linux, Windows.
|
||||||
|
|
||||||
|
Everything here runs on your computer, not the Frame. Python stdlib only.
|
||||||
|
|
||||||
|
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||||
|
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MAC = sys.platform == "darwin"
|
||||||
|
WINDOWS = os.name == "nt"
|
||||||
|
LINUX = not MAC and not WINDOWS
|
||||||
|
NAME = "macOS" if MAC else "Windows" if WINDOWS else "Linux"
|
||||||
|
FILE_MANAGER = "Finder" if MAC else "File Explorer" if WINDOWS else "your file manager"
|
||||||
|
|
||||||
|
# Windows' OpenSSH client can't share one connection between commands
|
||||||
|
# (no ControlMaster), so there each command opens its own.
|
||||||
|
MUX = not WINDOWS
|
||||||
|
|
||||||
|
# Popen() keyword arguments that detach a child from our console and signals.
|
||||||
|
DETACHED = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if WINDOWS
|
||||||
|
else {"start_new_session": True})
|
||||||
|
|
||||||
|
|
||||||
|
class HostError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def data_dir(*parts):
|
||||||
|
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME."""
|
||||||
|
if MAC:
|
||||||
|
base = Path.home() / "Library" / "Application Support" / "Frame Control"
|
||||||
|
elif WINDOWS:
|
||||||
|
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
|
||||||
|
else:
|
||||||
|
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "frame-control"
|
||||||
|
return base.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir(*parts):
|
||||||
|
if MAC:
|
||||||
|
base = Path.home() / "Library" / "Caches" / "Frame Control"
|
||||||
|
elif WINDOWS:
|
||||||
|
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "Frame Control" / "Cache"
|
||||||
|
else:
|
||||||
|
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "frame-control"
|
||||||
|
return base.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
def control_path():
|
||||||
|
"""ssh ControlPath for the shared connection, or None where it isn't supported.
|
||||||
|
|
||||||
|
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||||
|
"""
|
||||||
|
return f"/tmp/frame-ui-{os.getuid()}-%C" if MUX else None
|
||||||
|
|
||||||
|
|
||||||
|
def which(name, *extra):
|
||||||
|
"""First executable among PATH and the extra candidate paths."""
|
||||||
|
for cand in (shutil.which(name), *extra):
|
||||||
|
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
|
||||||
|
return cand
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def install_hint(tool):
|
||||||
|
"""How to get a missing command-line tool on this computer."""
|
||||||
|
hints = {
|
||||||
|
"adb": {"mac": "brew install android-platform-tools",
|
||||||
|
"win": "winget install Google.PlatformTools",
|
||||||
|
"linux": "install your distribution's adb package (e.g. sudo apt install adb)"},
|
||||||
|
"aapt2": {"mac": 'brew install --cask android-commandlinetools, then sdkmanager "build-tools;36.0.0"',
|
||||||
|
"win": 'install Android Studio\'s command-line tools, then sdkmanager "build-tools;36.0.0"',
|
||||||
|
"linux": 'install Android\'s command-line tools, then sdkmanager "build-tools;36.0.0"'},
|
||||||
|
}
|
||||||
|
return hints[tool]["mac" if MAC else "win" if WINDOWS else "linux"]
|
||||||
|
|
||||||
|
|
||||||
|
def android_sdk_dirs():
|
||||||
|
"""Where the Android SDK usually lives, for adb and aapt2."""
|
||||||
|
dirs = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")]
|
||||||
|
if MAC:
|
||||||
|
dirs += ["~/Library/Android/sdk", "/opt/homebrew/share/android-commandlinetools",
|
||||||
|
"~/.homebrew/share/android-commandlinetools"]
|
||||||
|
elif WINDOWS:
|
||||||
|
dirs += [os.path.join(os.environ.get("LOCALAPPDATA", ""), "Android", "Sdk")]
|
||||||
|
else:
|
||||||
|
dirs += ["~/Android/Sdk", "/usr/lib/android-sdk"]
|
||||||
|
return [os.path.expanduser(d) for d in dirs if d]
|
||||||
|
|
||||||
|
|
||||||
|
def adb():
|
||||||
|
exe = "adb.exe" if WINDOWS else "adb"
|
||||||
|
extra = [os.path.join(d, "platform-tools", exe) for d in android_sdk_dirs()]
|
||||||
|
if MAC:
|
||||||
|
extra += ["/opt/homebrew/bin/adb", str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"]
|
||||||
|
env = os.environ.get("ADB")
|
||||||
|
found = (env if env and os.access(env, os.X_OK) else None) or which("adb", *extra)
|
||||||
|
if not found:
|
||||||
|
raise HostError(f"adb isn't installed on this computer: {install_hint('adb')}")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def open_path(path):
|
||||||
|
"""Show a folder or file in the file manager."""
|
||||||
|
path = str(path)
|
||||||
|
if WINDOWS:
|
||||||
|
os.startfile(path) # noqa: pylint only on Windows
|
||||||
|
return
|
||||||
|
opener = "open" if MAC else which("xdg-open")
|
||||||
|
if not opener:
|
||||||
|
raise HostError("xdg-open isn't installed, so the folder can't be opened")
|
||||||
|
subprocess.Popen([opener, path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, **DETACHED)
|
||||||
|
|
||||||
|
|
||||||
|
open_url = open_path # the same openers hand URLs to the default browser
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(argv):
|
||||||
|
subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, **DETACHED)
|
||||||
|
|
||||||
|
|
||||||
|
def open_terminal(argv, title="Frame Control"):
|
||||||
|
"""Run argv in a new terminal window, for anything that asks for a password.
|
||||||
|
|
||||||
|
The window stays open after the command ends, so its output can be read.
|
||||||
|
"""
|
||||||
|
argv = [str(a) for a in argv]
|
||||||
|
if MAC:
|
||||||
|
command = shlex.join(argv).replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
||||||
|
"-e", f'do script "{command}"', "-e", "activate", "-e", "end tell"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
# Usually macOS Automation consent for Terminal was denied.
|
||||||
|
raise HostError(f"Couldn't open Terminal: {r.stderr.strip()}")
|
||||||
|
return "Terminal"
|
||||||
|
if WINDOWS:
|
||||||
|
# `start` gives the command its own console window; cmd /k keeps it open.
|
||||||
|
# One hand-built command line: quoting it twice through list2cmdline would
|
||||||
|
# produce backslash-escaped quotes, which cmd doesn't understand.
|
||||||
|
# Every argument is quoted, so cmd treats & | < > ^ in them literally. cmd has
|
||||||
|
# no escape for a quote inside quotes (and expands %VAR% regardless), so refuse those.
|
||||||
|
if any(c in a for a in argv for c in '"%\r\n'):
|
||||||
|
raise HostError("Can't pass quotes or % to a Windows terminal")
|
||||||
|
inner = " ".join(f'"{a}"' for a in argv)
|
||||||
|
subprocess.Popen(f'cmd.exe /c start "{title}" cmd.exe /k "{inner}"', **DETACHED)
|
||||||
|
return "a terminal window"
|
||||||
|
script = f'{shlex.join(argv)}; echo; read -r -p "Press Enter to close. " _'
|
||||||
|
# flags=None: the terminal takes the whole command as one string after -e.
|
||||||
|
for name, flags in (("x-terminal-emulator", ["-e"]), ("gnome-terminal", ["--"]), ("ptyxis", ["--"]),
|
||||||
|
("kgx", ["--"]), ("konsole", ["-e"]), ("xfce4-terminal", ["-x"]),
|
||||||
|
("tilix", None), ("lxterminal", None), ("kitty", []), ("alacritty", ["-e"]),
|
||||||
|
("wezterm", ["start", "--"]), ("foot", []), ("xterm", ["-e"])):
|
||||||
|
exe = which(name)
|
||||||
|
if not exe:
|
||||||
|
continue
|
||||||
|
if flags is None:
|
||||||
|
_spawn([exe, "-e", "bash -c " + shlex.quote(script)])
|
||||||
|
elif name == "x-terminal-emulator" and "lxterminal" in os.path.realpath(exe):
|
||||||
|
_spawn([exe, "-e", "bash -c " + shlex.quote(script)]) # Debian alternative -> lxterminal
|
||||||
|
else:
|
||||||
|
_spawn([exe, *flags, "bash", "-c", script])
|
||||||
|
return name
|
||||||
|
raise HostError("No terminal program found (tried gnome-terminal, konsole, xterm and others)")
|
||||||
|
|
||||||
|
|
||||||
|
def clipboard_text():
|
||||||
|
"""The text on this computer's clipboard."""
|
||||||
|
if MAC:
|
||||||
|
cmds = [["pbpaste"]]
|
||||||
|
elif WINDOWS:
|
||||||
|
cmds = [["powershell.exe", "-NoProfile", "-Command",
|
||||||
|
"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard -Raw"]]
|
||||||
|
else:
|
||||||
|
cmds = [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"],
|
||||||
|
["xsel", "--clipboard", "--output"]]
|
||||||
|
for cmd in cmds:
|
||||||
|
if not shutil.which(cmd[0]):
|
||||||
|
continue
|
||||||
|
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
|
||||||
|
if r.returncode == 0:
|
||||||
|
text = r.stdout.decode("utf-8", errors="replace")
|
||||||
|
return text[:-2] if WINDOWS and text.endswith("\r\n") else text
|
||||||
|
if LINUX:
|
||||||
|
raise HostError("Can't read the clipboard: install wl-clipboard (Wayland) or xclip (X11)")
|
||||||
|
raise HostError("Can't read the clipboard")
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_hostname(alias):
|
||||||
|
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return alias
|
||||||
|
for line in out.splitlines():
|
||||||
|
if line.startswith("hostname "):
|
||||||
|
return line.split(None, 1)[1].strip()
|
||||||
|
return alias
|
||||||
|
|
||||||
|
|
||||||
|
# Apps the UI can hand off to, per platform: (installed-check, launch argv) pairs,
|
||||||
|
# and where to get the app when none is installed.
|
||||||
|
def open_steam_link():
|
||||||
|
if MAC:
|
||||||
|
if subprocess.run(["open", "-a", "Steam Link"], capture_output=True).returncode == 0:
|
||||||
|
return "Opened Steam Link"
|
||||||
|
elif WINDOWS:
|
||||||
|
for base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles")):
|
||||||
|
exe = base and os.path.join(base, "Steam Link", "SteamLink.exe")
|
||||||
|
if exe and os.path.isfile(exe):
|
||||||
|
_spawn([exe])
|
||||||
|
return "Opened Steam Link"
|
||||||
|
else:
|
||||||
|
if which("steamlink"):
|
||||||
|
_spawn([which("steamlink")])
|
||||||
|
return "Opened Steam Link"
|
||||||
|
if which("flatpak") and subprocess.run(["flatpak", "info", "com.valvesoftware.SteamLink"],
|
||||||
|
capture_output=True).returncode == 0:
|
||||||
|
_spawn(["flatpak", "run", "com.valvesoftware.SteamLink"])
|
||||||
|
return "Opened Steam Link"
|
||||||
|
open_url("https://store.steampowered.com/remoteplay")
|
||||||
|
return "Steam Link isn't installed; opened its download page"
|
||||||
|
|
||||||
|
|
||||||
|
def open_rdp(alias):
|
||||||
|
"""Remote desktop to the Frame's xrdp (user steamos)."""
|
||||||
|
host = ssh_hostname(alias)
|
||||||
|
if MAC:
|
||||||
|
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||||
|
return "Opened Windows App"
|
||||||
|
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||||
|
return "Windows App isn't installed; opened its App Store page"
|
||||||
|
if WINDOWS:
|
||||||
|
_spawn(["mstsc.exe", f"/v:{host}"])
|
||||||
|
return f"Opened Remote Desktop to {host}"
|
||||||
|
if which("remmina"):
|
||||||
|
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
||||||
|
return f"Opened Remmina to {host}"
|
||||||
|
for name in ("xfreerdp3", "xfreerdp"):
|
||||||
|
if which(name):
|
||||||
|
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
||||||
|
return f"Opened FreeRDP to {host}"
|
||||||
|
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if len(argv) >= 3 and argv[0] == "terminal" and argv[1] == "--":
|
||||||
|
try:
|
||||||
|
print(f"Opened {open_terminal(argv[2:])}")
|
||||||
|
except HostError as e:
|
||||||
|
sys.exit(str(e))
|
||||||
|
return
|
||||||
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
"""Mac side: search the Steam store and look up each result's Steam Frame rating.
|
"""Computer side: search the Steam store and look up each result's Steam Frame rating.
|
||||||
|
|
||||||
Uses the store's public endpoints (no key, no login):
|
Uses the store's public endpoints (no key, no login):
|
||||||
api/storesearch name search, price in the IP's currency
|
api/storesearch name search, price in the IP's currency
|
||||||
|
|||||||
+20
-10
@@ -384,8 +384,8 @@
|
|||||||
<section id="shots">
|
<section id="shots">
|
||||||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||||||
<button class="small" id="shotsRefresh">Refresh</button>
|
<button class="small" id="shotsRefresh">Refresh</button>
|
||||||
<button class="small" id="shotsFolder" title="Open ~/Pictures/SteamFrame">Show in Finder</button>
|
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
|
||||||
<button class="action small" id="shotsSaveNew" disabled>Save new to Mac</button>
|
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||||||
@@ -465,7 +465,7 @@
|
|||||||
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<button class="action small" id="clipSend">Send text</button>
|
<button class="action small" id="clipSend">Send text</button>
|
||||||
<button class="small" id="clipMac">Send Mac clipboard</button>
|
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
||||||
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Sleep, Restart and Shut down open Terminal for the Developer Mode password.</div>
|
<div class="hint">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||||||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||||||
@@ -613,6 +613,15 @@ $("bottombar").onclick = () => {
|
|||||||
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
|
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
|
||||||
|
const HOST = { computer: "computer" };
|
||||||
|
function applyHostWording(h) {
|
||||||
|
Object.assign(HOST, h);
|
||||||
|
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||||
|
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
|
||||||
|
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||||||
|
}
|
||||||
|
|
||||||
async function api(path, body) {
|
async function api(path, body) {
|
||||||
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
||||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
||||||
@@ -1060,7 +1069,7 @@ $("clipSend").onclick = () => {
|
|||||||
if (!text) return toast("Nothing to send", true);
|
if (!text) return toast("Nothing to send", true);
|
||||||
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
||||||
};
|
};
|
||||||
$("clipMac").onclick = () => act("Send Mac clipboard", () => api("/api/clipboard", { fromMac: true }), $("clipMac"));
|
$("clipMac").onclick = () => act($("clipMac").textContent, () => api("/api/clipboard", { fromComputer: true }), $("clipMac"));
|
||||||
|
|
||||||
// ---- file drop ----
|
// ---- file drop ----
|
||||||
const drop = $("drop");
|
const drop = $("drop");
|
||||||
@@ -1091,7 +1100,7 @@ function upload(file, mode) {
|
|||||||
}
|
}
|
||||||
async function sendFiles(files) {
|
async function sendFiles(files) {
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; use scripts/push.sh`, true); continue; }
|
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; zip the folder first`, true); continue; }
|
||||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||||
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||||
}
|
}
|
||||||
@@ -1461,7 +1470,7 @@ async function loadReports() {
|
|||||||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||||
$("repHint").textContent = shared
|
$("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 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` : "";
|
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||||
const k = r.rating || r.result;
|
const k = r.rating || r.result;
|
||||||
@@ -1522,6 +1531,7 @@ $("repForm").onsubmit = async e => {
|
|||||||
finally { $("repSave").disabled = false; }
|
finally { $("repSave").disabled = false; }
|
||||||
};
|
};
|
||||||
loadReports();
|
loadReports();
|
||||||
|
api("/api/host").then(applyHostWording).catch(() => {});
|
||||||
|
|
||||||
// ---- Steam screenshots from the headset ----
|
// ---- Steam screenshots from the headset ----
|
||||||
const shots = { list: [], urls: [] };
|
const shots = { list: [], urls: [] };
|
||||||
@@ -1547,14 +1557,14 @@ async function loadShots() {
|
|||||||
} finally { $("shotsRefresh").disabled = false; }
|
} finally { $("shotsRefresh").disabled = false; }
|
||||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
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;
|
$("shotsSaveNew").disabled = !unsaved;
|
||||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
||||||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
||||||
<div class="row"><div class="grow">
|
<div class="row"><div class="grow">
|
||||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||||||
${s.saved ? `<span class="tag">On Mac</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
${s.saved ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||||
</div></div>`).join("")
|
</div></div>`).join("")
|
||||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||||
// Thumbnails one at a time over the shared SSH connection.
|
// Thumbnails one at a time over the shared SSH connection.
|
||||||
@@ -1606,7 +1616,7 @@ $("shotGrid").onclick = e => {
|
|||||||
};
|
};
|
||||||
$("shotsRefresh").onclick = loadShots;
|
$("shotsRefresh").onclick = loadShots;
|
||||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
$("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 ----
|
// ---- nav highlight follows scroll ----
|
||||||
const spy = new IntersectionObserver(entries => {
|
const spy = new IntersectionObserver(entries => {
|
||||||
|
|||||||
+174
-91
@@ -1,18 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
|
"""Frame Control: a small local web UI for managing the Steam Frame from a computer.
|
||||||
|
|
||||||
Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
|
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||||
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
|
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
||||||
|
up by scripts/connect.sh or ui/frame_connect.py.
|
||||||
|
|
||||||
Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
|
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||||
Env: FRAME_ALIAS (default frame)
|
Env: FRAME_ALIAS (default frame)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import select
|
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
@@ -26,19 +28,25 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
import frame_android
|
# Windows' embedded Python (bundled with the app) doesn't put the script's own
|
||||||
import frame_catalog
|
# folder on sys.path, so add it for the sibling modules below.
|
||||||
import frame_store
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import frame_android # noqa: E402
|
||||||
|
import frame_catalog # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
import frame_store # noqa: E402
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
SCRIPTS = HERE.parent / "scripts"
|
|
||||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||||
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||||
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
|
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||||
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
|
# supports it (not on Windows: there every command connects on its own).
|
||||||
|
CONTROL = frame_host.control_path()
|
||||||
|
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||||
# Commands use the master when it's up and connect directly when it isn't.
|
# Commands use the master when it's up and connect directly when it isn't.
|
||||||
SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
|
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||||
|
|
||||||
# Android helpers share the multiplexed connection when it's up.
|
# Android helpers share the multiplexed connection when it's up.
|
||||||
frame_android.SSH_OPTS = SSH[1:]
|
frame_android.SSH_OPTS = SSH[1:]
|
||||||
@@ -82,9 +90,12 @@ def ensure_master():
|
|||||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||||
"""
|
"""
|
||||||
global _master
|
global _master
|
||||||
|
if not CONTROL:
|
||||||
|
return
|
||||||
|
|
||||||
def up():
|
def up():
|
||||||
try:
|
try:
|
||||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
|
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
|
||||||
timeout=5).returncode == 0
|
timeout=5).returncode == 0
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False
|
return False
|
||||||
@@ -97,7 +108,7 @@ def ensure_master():
|
|||||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
if up() or _master.poll() is not None:
|
if up() or _master.poll() is not None:
|
||||||
return
|
return
|
||||||
@@ -107,7 +118,10 @@ def ensure_master():
|
|||||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||||
try:
|
try:
|
||||||
ensure_master()
|
ensure_master()
|
||||||
r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True,
|
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||||
|
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||||
|
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||||
|
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||||
text=text, errors="replace" if text else None, timeout=timeout)
|
text=text, errors="replace" if text else None, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Timed out talking to {FRAME}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
@@ -119,40 +133,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
def script(name, *args, stdin=None, timeout=900):
|
|
||||||
"""Run one of ../scripts and return its combined output."""
|
|
||||||
try:
|
|
||||||
r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
|
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
||||||
env={**os.environ, "FRAME_ALIAS": FRAME})
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise Failure(f"{name} timed out")
|
|
||||||
out = strip_ansi(r.stdout).strip()
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise Failure(out or f"{name} exited {r.returncode}")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def strip_ansi(s):
|
def strip_ansi(s):
|
||||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||||
|
|
||||||
|
|
||||||
def terminal(command):
|
def terminal(argv):
|
||||||
"""Open Terminal.app running `command` (for anything needing a password)."""
|
"""Open a terminal window running argv (for anything needing a password)."""
|
||||||
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
|
try:
|
||||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
return frame_host.open_terminal(argv)
|
||||||
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
|
except frame_host.HostError as e:
|
||||||
capture_output=True, text=True)
|
raise Failure(str(e), 500)
|
||||||
if r.returncode != 0:
|
|
||||||
# Usually macOS Automation consent for Terminal was denied.
|
|
||||||
raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
def open_app(name, fallback_url):
|
|
||||||
if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
|
|
||||||
return f"Opened {name}"
|
|
||||||
subprocess.run(["open", fallback_url])
|
|
||||||
return f"{name} isn't installed; opened its download page"
|
|
||||||
|
|
||||||
|
|
||||||
# ---- actions ---------------------------------------------------------------
|
# ---- actions ---------------------------------------------------------------
|
||||||
@@ -259,7 +249,7 @@ def save_shots(body):
|
|||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure("Copying screenshots timed out")
|
raise Failure("Copying screenshots timed out")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -368,13 +358,44 @@ def set_volume(body):
|
|||||||
return {"message": "Volume updated"}
|
return {"message": "Volume updated"}
|
||||||
|
|
||||||
|
|
||||||
|
# Runs on the Frame, clipboard text on stdin. Verified 2026-09-25 (SteamOS 0.3.0
|
||||||
|
# vr, build 20260922): the headset desktop is a nested Plasma Wayland session
|
||||||
|
# inside gamescope with its own D-Bus bus, and wl-copy/xclip are not installed.
|
||||||
|
# Klipper (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so
|
||||||
|
# borrow plasmashell's bus address. Same as scripts/paste-to-frame.sh.
|
||||||
|
PASTE = r"""set -u
|
||||||
|
text=$(cat; printf x); text=${text%x}
|
||||||
|
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
echo "plasmashell is not running: open the desktop in the headset first." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
|
||||||
|
if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
|
||||||
|
org.kde.klipper.klipper.setClipboardContents "$text" >/dev/null; then
|
||||||
|
echo "copied via Klipper (${#text} chars)"
|
||||||
|
else
|
||||||
|
echo "Klipper call failed (bus: ${bus:-none})" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
# base64 keeps the script intact through every local shell's quoting rules.
|
||||||
|
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
def clipboard(body):
|
def clipboard(body):
|
||||||
if body.get("fromMac"):
|
if body.get("fromMac") or body.get("fromComputer"):
|
||||||
return {"message": script("paste-to-frame.sh", timeout=30)}
|
try:
|
||||||
text = body.get("text")
|
text = frame_host.clipboard_text()
|
||||||
if not isinstance(text, str) or not text:
|
except frame_host.HostError as e:
|
||||||
raise Failure("nothing to send", 400)
|
raise Failure(str(e), 500)
|
||||||
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
|
if not text:
|
||||||
|
raise Failure("The clipboard is empty (or holds something other than text)", 400)
|
||||||
|
else:
|
||||||
|
text = body.get("text")
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
raise Failure("nothing to send", 400)
|
||||||
|
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
|
||||||
|
|
||||||
|
|
||||||
def flatpak(body):
|
def flatpak(body):
|
||||||
@@ -382,7 +403,11 @@ def flatpak(body):
|
|||||||
if not FLATPAK_ID.match(app):
|
if not FLATPAK_ID.match(app):
|
||||||
raise Failure("bad Flatpak app ID", 400)
|
raise Failure("bad Flatpak app ID", 400)
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return {"message": script("install-apps.sh", app)}
|
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||||
|
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||||
|
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||||
|
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
|
||||||
|
return {"message": f"Installed {app}"}
|
||||||
if action == "uninstall":
|
if action == "uninstall":
|
||||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||||
@@ -391,25 +416,25 @@ def flatpak(body):
|
|||||||
|
|
||||||
def open_thing(body):
|
def open_thing(body):
|
||||||
what = body.get("what")
|
what = body.get("what")
|
||||||
alias = shlex.quote(FRAME)
|
try:
|
||||||
if what == "terminal":
|
if what == "terminal":
|
||||||
terminal(f"ssh {alias}")
|
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||||
return {"message": "Opened an SSH session in Terminal"}
|
if what in ("reboot", "poweroff", "suspend"):
|
||||||
if what in ("reboot", "poweroff", "suspend"):
|
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
||||||
terminal(f"ssh -t {alias} sudo systemctl {what}")
|
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||||
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
|
if what == "steamlink":
|
||||||
if what == "steamlink":
|
return {"message": frame_host.open_steam_link()}
|
||||||
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
|
if what == "rdp":
|
||||||
if what == "rdp":
|
return {"message": frame_host.open_rdp(FRAME)}
|
||||||
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
|
if what == "sftp":
|
||||||
if what == "sftp":
|
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
||||||
terminal(f"sftp {alias}")
|
if what == "shots":
|
||||||
return {"message": "Opened an SFTP session in Terminal"}
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
if what == "shots":
|
frame_host.open_path(SHOTS_DIR)
|
||||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||||
subprocess.run(["open", str(SHOTS_DIR)])
|
except frame_host.HostError as e:
|
||||||
return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
|
raise Failure(str(e), 500)
|
||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
|
|
||||||
|
|
||||||
@@ -438,7 +463,7 @@ def android(body):
|
|||||||
runtime=body.get("runtime") or "instance",
|
runtime=body.get("runtime") or "instance",
|
||||||
label=body.get("label"), source=body.get("source"))
|
label=body.get("label"), source=body.get("source"))
|
||||||
name = r.get("label") or pkg
|
name = r.get("label") or pkg
|
||||||
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}
|
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(e))
|
raise Failure(str(e))
|
||||||
@@ -464,16 +489,15 @@ _live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the
|
|||||||
|
|
||||||
|
|
||||||
def adb_path():
|
def adb_path():
|
||||||
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
|
try:
|
||||||
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
|
return frame_host.adb()
|
||||||
if cand and os.access(cand, os.X_OK):
|
except frame_host.HostError as e:
|
||||||
return cand
|
raise Failure(str(e), 500)
|
||||||
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
|
|
||||||
|
|
||||||
|
|
||||||
def adb(adb_bin, *args, timeout=20):
|
def adb(adb_bin, *args, timeout=20):
|
||||||
try:
|
try:
|
||||||
r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
|
r = subprocess.run([adb_bin, *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
errors="replace", timeout=timeout)
|
errors="replace", timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
||||||
@@ -490,7 +514,7 @@ def free_local_port():
|
|||||||
|
|
||||||
|
|
||||||
class AdbTunnel:
|
class AdbTunnel:
|
||||||
"""SSH forwards from Mac loopback to Frame ADB ports, plus adb connections.
|
"""SSH forwards from local loopback to Frame ADB ports, plus adb connections.
|
||||||
|
|
||||||
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
|
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
|
||||||
disconnects adb and kills the ssh process, whatever happened inside.
|
disconnects adb and kills the ssh process, whatever happened inside.
|
||||||
@@ -588,7 +612,7 @@ class AdbTunnel:
|
|||||||
self._stop_ssh()
|
self._stop_ssh()
|
||||||
for p in self.local:
|
for p in self.local:
|
||||||
try:
|
try:
|
||||||
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10)
|
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -742,6 +766,50 @@ POST = {"/api/android/display": android_display, "/api/android": android,"/api/l
|
|||||||
|
|
||||||
# ---- HTTP ------------------------------------------------------------------
|
# ---- HTTP ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pipe_reader(pipe):
|
||||||
|
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
|
||||||
|
chunks = queue.Queue() # unbounded: the pump never blocks, so it ends at EOF
|
||||||
|
|
||||||
|
def pump():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = pipe.read1(1 << 16) if hasattr(pipe, "read1") else os.read(pipe.fileno(), 1 << 16)
|
||||||
|
chunks.put(chunk)
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
except (OSError, ValueError):
|
||||||
|
chunks.put(b"")
|
||||||
|
|
||||||
|
threading.Thread(target=pump, daemon=True).start()
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _next_chunk(chunks, timeout):
|
||||||
|
"""The next chunk, or b"" at end of stream or after `timeout` seconds of silence."""
|
||||||
|
try:
|
||||||
|
return chunks.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def push_file(path, dest="Downloads/"):
|
||||||
|
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
|
||||||
|
name = Path(path).name
|
||||||
|
try:
|
||||||
|
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
|
||||||
|
if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
|
||||||
|
cmd = ["rsync", "-a", "-e", shlex.join(SSH), str(path), f"{FRAME}:{shlex.quote(dest)}"]
|
||||||
|
else:
|
||||||
|
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||||
|
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise Failure(f"Copying {name} timed out")
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise Failure(strip_ansi(r.stderr or r.stdout).strip() or f"copy exited {r.returncode}")
|
||||||
|
return f"Sent {name} to ~/{dest}"
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "FrameControl/1"
|
server_version = "FrameControl/1"
|
||||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||||
@@ -788,6 +856,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||||
|
elif path == "/api/host":
|
||||||
|
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||||
|
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||||
elif path == "/api/android":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"apps": frame_android.list_apps()})
|
self.send_json({"apps": frame_android.list_apps()})
|
||||||
@@ -869,11 +940,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
old, _stream_proc = _stream_proc, proc
|
old, _stream_proc = _stream_proc, proc
|
||||||
if old and old.poll() is None:
|
if old and old.poll() is None:
|
||||||
old.terminate()
|
old.terminate()
|
||||||
fd = proc.stdout.fileno()
|
chunks = _pipe_reader(proc.stdout)
|
||||||
# Nothing is sent until the first bytes arrive, so a failure to
|
# Nothing is sent until the first bytes arrive, so a failure to
|
||||||
# start still comes back as a JSON error.
|
# start still comes back as a JSON error.
|
||||||
ready, _, _ = select.select([fd], [], [], 20)
|
first = _next_chunk(chunks, 20)
|
||||||
first = os.read(fd, 1 << 16) if ready else b""
|
|
||||||
if not first:
|
if not first:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
proc.wait()
|
proc.wait()
|
||||||
@@ -894,8 +964,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.flush()
|
self.wfile.flush()
|
||||||
# A stalled headset view ends the stream rather than
|
# A stalled headset view ends the stream rather than
|
||||||
# holding this thread (and the page) forever.
|
# holding this thread (and the page) forever.
|
||||||
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
|
chunk = _next_chunk(chunks, STREAM_STALL)
|
||||||
chunk = os.read(fd, 1 << 16) if ready else b""
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
||||||
finally:
|
finally:
|
||||||
@@ -952,7 +1021,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(e), 400)
|
raise Failure(str(e), 400)
|
||||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
||||||
return {"message": script("push.sh", str(dest))}
|
return {"message": push_file(dest)}
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
@@ -960,17 +1029,31 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||||
|
ap.add_argument("--exit-on-eof", action="store_true",
|
||||||
|
help="stop cleanly when stdin closes (the app closes it on quit; "
|
||||||
|
"Windows has no SIGTERM to catch)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
if not frame_host.WINDOWS:
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||||
|
if args.exit_on_eof:
|
||||||
|
def watch_stdin():
|
||||||
|
sys.stdin.buffer.read()
|
||||||
|
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||||
|
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||||
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||||
try:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
# The app both closes stdin and sends SIGTERM on quit; a second signal
|
||||||
|
# mid-cleanup would abort it and leave the SSH master running.
|
||||||
|
if not frame_host.WINDOWS:
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||||
# The master was started with -N, so it stays up until told to exit.
|
# The master was started with -N, so it stays up until told to exit.
|
||||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
if CONTROL:
|
||||||
|
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
||||||
if _master and _master.poll() is None:
|
if _master and _master.poll() is None:
|
||||||
_master.terminate()
|
_master.terminate()
|
||||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||||
|
|||||||
Reference in new issue
Block a user