mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7ee97cd3 | ||
|
|
34ce457332 | ||
|
|
8a90e3e34f | ||
|
|
03729ce950 | ||
|
|
37153f69ae | ||
|
|
52d01bd815 | ||
|
|
e210407f31 | ||
|
|
fc2fa65f0d | ||
|
|
2a4a709ced | ||
|
|
cfb7465c22 | ||
|
|
07de29d58a | ||
|
|
f6e77cd98c | ||
|
|
6d73912f8c | ||
|
|
60571dbfac | ||
|
|
f324aac690 | ||
|
|
8b7c46a63a |
No files matched your search
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: steam-frame
|
name: steam-frame
|
||||||
description: Operate the user's Valve Steam Frame headset from the Mac through the ~/projects/steam-frame helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
|
description: Operate the user's Valve Steam Frame headset from the Mac through this repo's helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Steam Frame
|
# Steam Frame
|
||||||
|
|
||||||
The repo is `~/projects/steam-frame`. SSH works through the `frame` alias
|
SSH works through the `frame` alias
|
||||||
(user `steamos`). The headset has to be awake for anything that touches its
|
(user `steamos`). The headset has to be awake for anything that touches its
|
||||||
desktop or panels.
|
desktop or panels.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 saphid
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,212 +1,189 @@
|
|||||||
# Steam Frame ↔ Mac
|
<div align="center">
|
||||||
|
|
||||||
This repo holds notes and Mac-side helpers for controlling a Valve Steam Frame
|
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
|
||||||
(standalone VR headset: SteamOS 3, Arch-based, arm64, Snapdragon 8 Gen 3) from
|
|
||||||
this Mac, with as little typing on the headset's virtual keyboard as possible.
|
|
||||||
|
|
||||||
Status: written 2026-09-25 and checked against a real Frame the same day
|
# Frame Control
|
||||||
(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app
|
|
||||||
and most scripts are **verified** on the device. The scripts table below marks
|
|
||||||
each one, and [docs/open-questions.md](docs/open-questions.md#verified-on-device-2026-09-25)
|
|
||||||
lists what's still unchecked.
|
|
||||||
|
|
||||||
**Quick start:** set up SSH once (next section), then install
|
**Manage your Valve Steam Frame from your computer.**<br>
|
||||||
[Frame Control](#frame-control-mac-app) from the DMG.
|
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||||
|
|
||||||
## Minimum typing on the headset
|
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||||
|
[](#install)
|
||||||
|
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
|
||||||
thing you type on the headset is a password you choose.
|
|
||||||
|
|
||||||
On the Frame:
|
<br>
|
||||||
|
|
||||||
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
|
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
|
||||||
2. Scroll down to the **Developer** section and click **Set User Password**.
|
|
||||||
Type a password. **This is the only thing you type on the headset.** Pick
|
|
||||||
something short, because you'll type it once more on the Mac and then
|
|
||||||
never again.
|
|
||||||
3. (Optional, no typing) Note the IP address from **Quick Settings** or
|
|
||||||
**Steam Settings → Internet**, in case `frame.local` doesn't resolve.
|
|
||||||
4. (Optional) Check **Steam Settings → System → Hostname**. Leaving it as
|
|
||||||
`frame` means the scripts work without any extra setup.
|
|
||||||
|
|
||||||
On the Mac:
|
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||||
|
|
||||||
```sh
|
</div>
|
||||||
cd ~/projects/steam-frame
|
|
||||||
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
|
|
||||||
ssh frame # passwordless from now on
|
|
||||||
```
|
|
||||||
|
|
||||||
`connect.sh` does four things:
|
---
|
||||||
|
|
||||||
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
|
## Features
|
||||||
- creates a dedicated key (`~/.ssh/id_ed25519_frame`)
|
|
||||||
- adds a `Host frame` block to `~/.ssh/config`
|
|
||||||
- runs `ssh-copy-id`, which asks for the Developer Mode password once
|
|
||||||
|
|
||||||
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
|
<table>
|
||||||
logins.
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
|
**👓 Headset view**<br>
|
||||||
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
|
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||||
(both **confirmed on Steam Frame**, Valve official).
|
|
||||||
|
|
||||||
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
|
</td>
|
||||||
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
|
<td width="50%" valign="top">
|
||||||
characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
|
|
||||||
Frame's Linux desktop. The script it serves installs your Mac's public key and
|
|
||||||
enables `sshd`. See [docs/ssh.md](docs/ssh.md#fallback-bootstrap-one-liner).
|
|
||||||
|
|
||||||
## Recommended options
|
**🔋 Battery and status**<br>
|
||||||
|
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||||
|
|
||||||
| Goal | Recommended | Confidence |
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**🎮 Steam games**<br>
|
||||||
|
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**🤖 Android apps**<br>
|
||||||
|
About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**📁 Files and clipboard**<br>
|
||||||
|
Drag files onto the window to send them. Send text or your clipboard straight to the headset's desktop.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**📸 Screenshots**<br>
|
||||||
|
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**🧩 Flatpaks and display**<br>
|
||||||
|
Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
**⚡ One-click tools**<br>
|
||||||
|
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Nothing is installed on the Frame for any of this: the app uses what SteamOS
|
||||||
|
already ships. [How each feature works](docs/frame-control.md).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
| | Download | Needs |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Python 3 (`xcode-select --install`) |
|
||||||
| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
|
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra: Python is bundled, and SSH is built into Windows |
|
||||||
| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
|
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `python3` and `ssh` (most desktops have both) |
|
||||||
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
|
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | same |
|
||||||
| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
|
|
||||||
|
|
||||||
Details: [docs/ssh.md](docs/ssh.md), [docs/streaming.md](docs/streaming.md),
|
Optional: `adb` for Android apps
|
||||||
[docs/file-transfer.md](docs/file-transfer.md),
|
([macOS](https://formulae.brew.sh/formula/android-platform-tools) `brew install android-platform-tools` ·
|
||||||
[docs/open-questions.md](docs/open-questions.md). For how the Frame's software
|
Windows `winget install Google.PlatformTools` · Linux `sudo apt install adb`).
|
||||||
fits together, see [docs/how-the-frame-works.md](docs/how-the-frame-works.md).
|
|
||||||
|
|
||||||
## Windows anywhere in the room
|
<details>
|
||||||
|
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||||
|
|
||||||
The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
|
There's no paid Apple developer account behind it, so macOS says the app is
|
||||||
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
|
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||||
for any Linux app tagged with an app id of its own:
|
quarantine once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/panel-on-frame.sh konsole
|
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
|
The first time, macOS also asks to allow local network access (for SSH) and
|
||||||
controls to place each panel. See [docs/panels.md](docs/panels.md).
|
control of Terminal (for the password prompts).
|
||||||
|
</details>
|
||||||
|
|
||||||
## Frame Control (Mac app)
|
<details>
|
||||||
|
<summary><b>Windows: SmartScreen warning</b></summary>
|
||||||
|
|
||||||
As of 2026-09-25 no other Mac app manages the Frame end to end.
|
The installer isn't code-signed, so Windows SmartScreen may say it protected
|
||||||
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
|
your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
|
||||||
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
|
installer: unzip it anywhere and run `Frame Control.exe`.
|
||||||
Windows-only. Steam Link views the headset. **Frame Control** is a Mac app over
|
</details>
|
||||||
the scripts below. Install it from the DMG (see [Mac app](#mac-app)), or run
|
|
||||||
the same UI in a browser without packaging:
|
<details>
|
||||||
|
<summary><b>Linux: running the AppImage</b></summary>
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/frame-ui.sh # opens http://127.0.0.1:47810 in its own window
|
chmod +x Frame-Control-linux-*.AppImage && ./Frame-Control-linux-*.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||

|
If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
|
||||||
|
or run it with `--appimage-extract-and-run`. Sending the clipboard needs
|
||||||
|
`wl-clipboard` (Wayland) or `xclip` (X11).
|
||||||
|
</details>
|
||||||
|
|
||||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
## Set up the headset
|
||||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
|
||||||
a camera into one lens, or both eyes; single shot or about 2 fps live; saves
|
|
||||||
as PNG. The viewer fits the whole frame; zoom with − / + (or scroll, or
|
|
||||||
double-click), drag to pan, `0` to fit, `F` for full screen. It uses OpenVR's `IVRScreenshots` API through Python `ctypes`
|
|
||||||
(`ui/frame_vrshot.py`), so nothing is installed on the Frame. **Desktop panel**
|
|
||||||
captures gamescope's flat layer instead.
|
|
||||||
- Battery with charging state: charge rate in watts, time to full or empty,
|
|
||||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature
|
|
||||||
- Storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR, the desktop,
|
|
||||||
Lepton and xrdp are running
|
|
||||||
- Library shelf with Steam cover art and a Play button (`steam://rungameid`)
|
|
||||||
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
|
||||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
|
||||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
|
||||||
opens the store page in your browser, or Store on Frame opens it in the
|
|
||||||
headset. It drives the Frame's own Steam client through its DevTools port;
|
|
||||||
see `docs/steam-games.md`
|
|
||||||
- Volume and mute (`wpctl`)
|
|
||||||
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
|
|
||||||
one with a click as its own Lepton instance (it keeps its data and shows in the
|
|
||||||
Steam library), then launch, stop, test or remove it. **Report an APK** records whether any APK
|
|
||||||
worked (F-Droid or not: pick a file, type a package, or use an installed app). The
|
|
||||||
ratings go into our private compatibility database (a Lakebed capsule only the
|
|
||||||
app can use, backed up daily to Google Drive; see `compat-db/README.md`)
|
|
||||||
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
|
||||||
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
|
||||||
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
|
||||||
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all three
|
|
||||||
back. Whether the settings survive the app relaunching is untested
|
|
||||||
- Drag and drop files to `~/Downloads`; `.apk` files install as their own Android app
|
|
||||||
- Send typed text, or the Mac clipboard, to the Frame clipboard
|
|
||||||
- Install and remove Flatpaks (quick picks: Moonlight, Firefox, VLC, Remmina)
|
|
||||||
- One-click SSH or SFTP in Terminal, Steam Link, and Windows App (RDP).
|
|
||||||
Sleep, restart and shut down open Terminal because SteamOS asks for the
|
|
||||||
sudo password over SSH.
|
|
||||||
|
|
||||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
You type one password on the headset, once. Everything else happens on your
|
||||||
with a non-local `Host` header, and any `/api/` request without a custom
|
computer.
|
||||||
header, so other websites can't drive it or read captures. It keeps a single multiplexed SSH connection open, so
|
|
||||||
status and each capture take about 0.3s. Headset captures are deleted from the
|
|
||||||
Frame as soon as they're copied, because they show everything on screen,
|
|
||||||
including anything private. The look follows the Steam client: its palette,
|
|
||||||
Motiva Sans (loaded from Valve's CDN), portrait library capsules and green
|
|
||||||
Play buttons. **Verified on the Frame 2026-09-25:** status and charging details,
|
|
||||||
both capture modes (headset view while in use, and a blank frame in standby,
|
|
||||||
which the UI labels), clipboard, volume, file push, and input validation. **Not yet exercised from the UI:** Launch, Flatpak
|
|
||||||
install/remove, APK drop, and the power buttons. Each of these calls a
|
|
||||||
command or script that was verified separately.
|
|
||||||
|
|
||||||
### Mac app
|
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
|
||||||
|
in the Developer section, **Set User Password**. Pick something short:
|
||||||
|
you'll type it once more on your computer and then never again.
|
||||||
|
2. **On your computer:** open Frame Control. It offers to **Set Up
|
||||||
|
Connection**, which finds the headset, creates an SSH key, and asks for that
|
||||||
|
password once in a terminal window. If it can't find the Frame, type the
|
||||||
|
IP address from the Frame's Quick Settings.
|
||||||
|
3. That's it. The app now reaches the headset whenever it's awake and on the
|
||||||
|
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||||
|
|
||||||
`app/` wraps the same UI as a standalone Mac app (Electron). The app bundles
|
**What it changes:** only what you click. Installs go to your user account on
|
||||||
`ui/`, `scripts/`, `frame/android/` and the rated catalogue from `apk-catalog/`.
|
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
|
||||||
It starts `ui/server.py` on a free loopback port and shows it in its own window.
|
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
|
||||||
The server stops when you quit the app. A prebuilt DMG for Apple Silicon is
|
entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
|
||||||
attached to each [GitHub release](https://github.com/saphid/steam-frame/releases).
|
|
||||||
|
|
||||||
```sh
|
## Feedback
|
||||||
cd app
|
|
||||||
npm install
|
|
||||||
npm run dist # → app/dist/Frame Control-<version>-arm64.dmg (and a .zip)
|
|
||||||
npm start # run from the checkout without packaging
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the DMG and drag **Frame Control** to Applications. You need `python3` on
|
This is a first public test, so reports are really useful, especially from
|
||||||
the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your
|
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
|
||||||
login shell, so Homebrew's `rsync` and `adb` work when you launch it from
|
with:
|
||||||
Finder. Each time it starts while there's no `frame` SSH alias, the app offers
|
|
||||||
to run `connect.sh` in Terminal. **Frame → Set Up Connection…** does the same
|
|
||||||
at any time. The Frame menu also shows the server log at
|
|
||||||
`~/Library/Logs/Frame Control/server.log`. Installing APKs needs `adb`
|
|
||||||
(`brew install android-platform-tools`). The Android ratings database needs
|
|
||||||
its key in the Keychain (see `compat-db/README.md`); without it, the app uses
|
|
||||||
its offline copy.
|
|
||||||
|
|
||||||
The build is ad-hoc signed and not notarized. A copy you build yourself opens
|
- what you tried and what happened
|
||||||
normally. A copy downloaded from GitHub Releases is quarantined; clear it with
|
- your computer's OS and your SteamOS build (Steam Settings → System)
|
||||||
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
|
- the server log: **Frame → Show Server Log** in the app
|
||||||
time you use them, macOS asks to allow local network access (for SSH) and
|
|
||||||
control of Terminal (for SSH and power actions). **Verified 2026-09-25:**
|
|
||||||
installed from the DMG, launched from Finder, connected to the Frame, and
|
|
||||||
showed live status and the library.
|
|
||||||
|
|
||||||
## Scripts
|
## Going further
|
||||||
|
|
||||||
| Script | Runs on | Purpose |
|
This repo also holds the scripts behind the app and field notes on how the
|
||||||
|---|---|---|
|
Frame's software fits together, all checked against a real headset and labelled
|
||||||
| `scripts/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 | Back up the compatibility database locally and to Google Drive (daily LaunchAgent) (**verified**) |
|
|
||||||
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](docs/vr-video.md) |
|
|
||||||
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
|
|
||||||
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
|
|
||||||
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
|
|
||||||
|
|
||||||
## Security notes
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||||
|
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||||
|
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||||
|
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||||
|
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||||
|
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||||
|
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Security notes</b></summary>
|
||||||
|
|
||||||
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
|
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
|
||||||
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
||||||
@@ -215,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.
|
||||||
@@ -223,16 +200,20 @@ 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
|
||||||
|
|
||||||
|
[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||||
|
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||||
@@ -27,8 +27,8 @@ rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
|
|||||||
|
|
||||||
## Compatibility reports
|
## Compatibility reports
|
||||||
|
|
||||||
Reports live in Frame Control's private database, a Lakebed capsule at
|
Reports are saved on your Mac. The maintainer's copy of Frame Control also
|
||||||
`https://frame-compat.lakebed.app` that only the app can read or write (see
|
syncs them to a private Lakebed database (see
|
||||||
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
||||||
records whether an app stays up in its own instance (`result`); **Report**
|
records whether an app stays up in its own instance (`result`); **Report**
|
||||||
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
|
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
|
||||||
|
|||||||
@@ -1,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
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.1.1",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.1.1",
|
"version": "0.3.0",
|
||||||
"license": "UNLICENSED",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
"electron-builder": "^26.15.3"
|
"electron-builder": "^26.15.3"
|
||||||
|
|||||||
+60
-6
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.1.1",
|
"version": "0.3.0",
|
||||||
"description": "Mac app for managing a Valve Steam Frame over SSH",
|
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"license": "UNLICENSED",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||||
"dist": "electron-builder --mac --arm64 --publish never",
|
"dist": "electron-builder --mac --arm64 --publish never",
|
||||||
"dist:dir": "electron-builder --mac --arm64 --dir"
|
"dist:dir": "electron-builder --mac --arm64 --dir",
|
||||||
|
"dist:linux": "electron-builder --linux --x64 --arm64 --publish never",
|
||||||
|
"dist:win": "node build/fetch-python.js && electron-builder --win --x64 --publish never"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
@@ -25,7 +27,8 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"package.json"
|
"package.json",
|
||||||
|
"build/icon.png"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
@@ -73,7 +76,8 @@
|
|||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
||||||
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
|
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
|
||||||
}
|
},
|
||||||
|
"artifactName": "Frame-Control-mac-${arch}.${ext}"
|
||||||
},
|
},
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"title": "Frame Control ${version}"
|
"title": "Frame Control ${version}"
|
||||||
@@ -82,6 +86,56 @@
|
|||||||
"runAsNode": false,
|
"runAsNode": false,
|
||||||
"enableNodeOptionsEnvironmentVariable": false,
|
"enableNodeOptionsEnvironmentVariable": false,
|
||||||
"enableNodeCliInspectArguments": false
|
"enableNodeCliInspectArguments": false
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"category": "Utility",
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"executableName": "frame-control",
|
||||||
|
"synopsis": "Manage a Valve Steam Frame over SSH",
|
||||||
|
"artifactName": "Frame-Control-linux-${arch}.${ext}",
|
||||||
|
"desktop": {
|
||||||
|
"entry": {
|
||||||
|
"StartupWMClass": "frame-control"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deb": {
|
||||||
|
"depends": [
|
||||||
|
"python3",
|
||||||
|
"openssh-client"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "build/python-win",
|
||||||
|
"to": "python",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"artifactName": "Frame-Control-Setup-${arch}.${ext}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/saphid/steam-frame",
|
||||||
|
"author": {
|
||||||
|
"name": "saphid",
|
||||||
|
"email": "4596216+saphid@users.noreply.github.com"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-7
@@ -1,11 +1,12 @@
|
|||||||
# compat-db: Frame Control's compatibility database
|
# compat-db: Frame Control's compatibility database
|
||||||
|
|
||||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||||
reports for Android apps on the Steam Frame. Only Frame Control can read or
|
reports for Android apps on the Steam Frame. For now only the maintainer's
|
||||||
write it.
|
copy of Frame Control has the key to read or write it. Everyone else's reports
|
||||||
|
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
|
||||||
|
|
||||||
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
||||||
owned by `saphid`, doesn't expire). The browser page only says it's private.
|
claimed, so it doesn't expire). The browser page only says it's private.
|
||||||
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
|
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
|
||||||
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
||||||
no Lakebed queries or mutations, so nothing else can reach the rows.
|
no Lakebed queries or mutations, so nothing else can reach the rows.
|
||||||
@@ -22,10 +23,9 @@ write it.
|
|||||||
`scripts/compat-db-backup.sh` exports every report through the app key and
|
`scripts/compat-db-backup.sh` exports every report through the app key and
|
||||||
keeps dated copies in
|
keeps dated copies in
|
||||||
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
||||||
When the data has changed, it also uploads them to Google Drive
|
When the data has changed, it also uploads them with `gog` to the Google
|
||||||
(**the backup folder**, folder
|
Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
|
||||||
`<drive-folder-id>`) with `gog`. The LaunchAgent
|
`EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
|
||||||
`frame-compat-backup` runs it daily at 03:40; the log is
|
|
||||||
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
|
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
|
||||||
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
|
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
|
||||||
nothing is uploaded, and every later run refuses too until you rerun with
|
nothing is uploaded, and every later run refuses too until you rerun with
|
||||||
|
|||||||
+7
-8
@@ -135,11 +135,11 @@ collects community reports for Steam games only and has no public API, and
|
|||||||
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
|
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
|
||||||
(for example `lepton-stable`) also cover Steam games only
|
(for example `lepton-stable`) also cover Steam games only
|
||||||
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
|
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
|
||||||
So we keep our own, in a private Lakebed database
|
So Frame Control keeps its own. Your reports are saved on your Mac; the
|
||||||
(`https://frame-compat.lakebed.app`) that only Frame Control can read or write.
|
maintainer's copy also syncs them to a private Lakebed database.
|
||||||
**Test** records whether the app stays up in its own instance, and **Report**
|
**Test** records whether the app stays up in its own instance, and **Report**
|
||||||
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
|
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
|
||||||
A daily job backs it up locally and to Google Drive. See
|
See
|
||||||
[compat-db/README.md](../compat-db/README.md) and
|
[compat-db/README.md](../compat-db/README.md) and
|
||||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||||
|
|
||||||
@@ -188,13 +188,12 @@ But pasta runs with `--map-gw`, so the **gateway address inside Lepton
|
|||||||
|
|
||||||
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
|
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
|
||||||
|
|
||||||
1. The LaunchAgent `~/Library/LaunchAgents/frame-t3-tunnel.plist`
|
1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
|
||||||
keeps `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running. launchd
|
for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
|
||||||
restarts it if it drops. Log: `~/Library/Logs/frame-t3-tunnel.log`.
|
it drops.
|
||||||
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
|
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
|
||||||
|
|
||||||
The app on the Frame was built from the v2 nightly source (fork commit
|
The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
|
||||||
`d0c468e3`) with `expo prebuild` and `gradlew assembleRelease
|
|
||||||
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
||||||
`android-commandlinetools` SDK. It's signed with the debug key.
|
`android-commandlinetools` SDK. It's signed with the debug key.
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -30,6 +30,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
|||||||
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
||||||
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
||||||
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
||||||
|
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
|
||||||
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
|
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
|
||||||
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
||||||
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
||||||
@@ -43,7 +44,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
|||||||
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
|
| 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` |
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# WebXR in Chromium on the Frame
|
||||||
|
|
||||||
|
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
|
||||||
|
press its VR button, and watch in 3D in the headset.
|
||||||
|
|
||||||
|
## Why Flathub Chromium can't
|
||||||
|
|
||||||
|
**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub
|
||||||
|
`org.chromium.Chromium` 154.0.8037.57 aarch64):
|
||||||
|
|
||||||
|
- `navigator.xr` exists, but `isSessionSupported("immersive-vr")` is `false`.
|
||||||
|
Flags don't change that, and neither does `--force-webxr-runtime=openxr`
|
||||||
|
with SteamVR exposed to the Flatpak.
|
||||||
|
- The binary has no OpenXR loader: no `XR_RUNTIME_JSON`,
|
||||||
|
`xrGetInstanceProcAddr` or `XR_LOADER_DEBUG` strings. The only OpenXR
|
||||||
|
strings are the `chrome://flags` entries.
|
||||||
|
|
||||||
|
**Cause (verified against Chromium source, same date).** M154 is the first
|
||||||
|
release that compiles OpenXR on Linux:
|
||||||
|
|
||||||
|
- `device/vr/buildflags/buildflags.gni` adds `is_linux` to `enable_openxr`.
|
||||||
|
- Flathub's tarball sets `checkout_openxr = true`.
|
||||||
|
- Flathub's GN args don't turn it off.
|
||||||
|
|
||||||
|
But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates
|
||||||
|
the OpenXR device under `ENABLE_OPENXR && IS_WIN`. That's true on 154, 155 and
|
||||||
|
`main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The
|
||||||
|
rest of the Linux port is in two unmerged CLs (bug 506004811):
|
||||||
|
|
||||||
|
- [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736)
|
||||||
|
runs the XR device service in a Linux sandbox that allows SteamVR's
|
||||||
|
sockets, `/dev/shm` and `flock`.
|
||||||
|
- [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979)
|
||||||
|
wires the provider to `OpenXrPlatformHelperLinux`. `kOpenXR` stays off by
|
||||||
|
default, so it needs `--enable-features=OpenXR`.
|
||||||
|
|
||||||
|
8132979 sits on top of 8441736, so fetching `refs/changes/79/8132979/<ps>`
|
||||||
|
gets both.
|
||||||
|
|
||||||
|
**The Frame side is ready.** `~/.config/openxr/1/active_runtime.json` names
|
||||||
|
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
|
||||||
|
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
|
||||||
|
|
||||||
|
## Building it
|
||||||
|
|
||||||
|
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
|
||||||
|
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
|
||||||
|
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
|
||||||
|
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
|
||||||
|
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
|
||||||
|
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
|
||||||
|
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
|
||||||
|
Progress is logged to `~/chromium-xr/stage`. The build aborts if `/` drops
|
||||||
|
below 12 GB free.
|
||||||
|
|
||||||
|
First run, 2026-09-25, on a 12-core, 31 GB x64 Linux box: 9 h 33 min for
|
||||||
|
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
|
||||||
|
takes under a minute, plus about 4 minutes to repack.
|
||||||
|
|
||||||
|
**The extra fix.** The CL's XR seccomp policy refuses `getsockopt`. SteamVR's
|
||||||
|
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
|
||||||
|
so the XR process died with a seccomp crash (arm64 syscall 209). The script
|
||||||
|
allows that one option.
|
||||||
|
|
||||||
|
## Running it on the Frame
|
||||||
|
|
||||||
|
[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
|
||||||
|
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
|
||||||
|
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
|
||||||
|
gamescope's X display, the same way as [`panel-on-frame.sh`](panels.md), so
|
||||||
|
the Plasma desktop doesn't need to be open. It uses its own profile
|
||||||
|
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
|
||||||
|
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
|
||||||
|
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
|
||||||
|
visiting the site*.
|
||||||
|
|
||||||
|
**Seccomp is off.** `launch` passes `--disable-seccomp-filter-sandbox`. With
|
||||||
|
the XR seccomp policy on, SteamVR's client reads `/proc/self/status` through
|
||||||
|
Chrome's file broker and gets the broker's pid. SteamVR then binds the app to
|
||||||
|
the wrong process ("Unable to init path manager: VRInitError_Init_Internal")
|
||||||
|
and `xrCreateInstance` fails. The broker can't answer `/proc/self` for another
|
||||||
|
process, so fixing this needs a change in Chromium's broker client or in the
|
||||||
|
CL. The namespace sandbox stays on, but seccomp is off for every process, so
|
||||||
|
use this profile for VR sites rather than everyday browsing.
|
||||||
|
|
||||||
|
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
|
||||||
|
this build):
|
||||||
|
|
||||||
|
- `isSessionSupported('immersive-vr')` is `true`. The WebXR samples page
|
||||||
|
shows "VR support detected".
|
||||||
|
- `requestSession('immersive-vr')` succeeds after the prompt. With a WebGL
|
||||||
|
layer, the first XR frame has a viewer pose with 2 views and a
|
||||||
|
2880 × 1440 framebuffer (1440 × 1440 per eye).
|
||||||
|
- SteamVR moves the app from `VRApplication_OpenXRInstance` to
|
||||||
|
`VRApplication_OpenXRScene` and gives it scene focus. `xrEndFrame` submits
|
||||||
|
both projection views, and the compositor receives the 2880 × 1440 scene.
|
||||||
|
- The OpenXR runtime uses Vulkan (`XR_KHR_vulkan_enable2`). Chromium's own GPU
|
||||||
|
process uses ANGLE on GL, running on zink over Turnip Vulkan (Adreno 750);
|
||||||
|
Chromium's Vulkan backend is off. That doesn't stop the session.
|
||||||
|
- Unprivileged user namespaces work (`unshare -Ur true`), so the namespace
|
||||||
|
sandbox runs without the setuid `chrome_sandbox`.
|
||||||
|
|
||||||
|
**Not verified yet:** nobody was wearing the headset during the test, so
|
||||||
|
SteamVR kept it in standby. The session stayed at
|
||||||
|
`XR_SESSION_STATE_SYNCHRONIZED` (the page saw `visibilityState: "hidden"`)
|
||||||
|
and only the first frame ran. Still open:
|
||||||
|
|
||||||
|
- Whether the image shows up correctly in the headset, and at what frame rate.
|
||||||
|
- Whether VR180 or 360 video players (DeoVR, DL8 embeds) play in 3D.
|
||||||
|
- Controller and hand input in the session.
|
||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
||||||
|
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
|
||||||
|
# immersive-vr works on the Frame.
|
||||||
|
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
|
||||||
|
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
|
||||||
|
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
|
||||||
|
# Progress: ~/chromium-xr/stage. Output: ~/chromium-xr/chromium-xr-arm64.tar.xz,
|
||||||
|
# which scripts/chromium-xr.sh install copies to the Frame.
|
||||||
|
# Re-running resumes: existing checkout and out/XR are reused.
|
||||||
|
set -euo pipefail
|
||||||
|
W=~/chromium-xr
|
||||||
|
cd "$W"
|
||||||
|
stage(){ echo "$(date -Is) $*" | tee -a "$W/stage"; }
|
||||||
|
# Returns non-zero below 12 GB free; set -e turns that into an exit at top level.
|
||||||
|
guard(){ avail=$(df --output=avail -BG "$W" | tail -n 1 | tr -dc 0-9); if [ "$avail" -lt 12 ]; then stage "ABORT: only ${avail}G free for $W"; return 3; fi; }
|
||||||
|
[ -d depot_tools ] || git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git
|
||||||
|
export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0
|
||||||
|
CL_REF=refs/changes/79/8132979/44
|
||||||
|
if [ ! -f .gclient ]; then
|
||||||
|
cat > .gclient <<'G'
|
||||||
|
solutions = [{ "name": "src", "url": "https://chromium.googlesource.com/chromium/src.git",
|
||||||
|
"managed": False, "custom_deps": {}, "custom_vars": { "checkout_nacl": False } }]
|
||||||
|
target_os = ["linux"]
|
||||||
|
target_cpu = ["arm64"]
|
||||||
|
G
|
||||||
|
fi
|
||||||
|
# Keyed on a real commit, so an interrupted first fetch is retried on re-run.
|
||||||
|
if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
|
||||||
|
stage "clone src at $CL_REF"
|
||||||
|
mkdir -p src
|
||||||
|
[ -d src/.git ] || git -C src init -q
|
||||||
|
git -C src remote get-url origin >/dev/null 2>&1 || git -C src remote add origin https://chromium.googlesource.com/chromium/src.git
|
||||||
|
git -C src fetch -q --depth=1 origin "$CL_REF"
|
||||||
|
git -C src checkout -q FETCH_HEAD
|
||||||
|
fi
|
||||||
|
guard
|
||||||
|
stage "src at $(git -C src log -1 --format='%h %s')"
|
||||||
|
stage "gclient sync"
|
||||||
|
gclient sync --nohooks --no-history -D --shallow --revision "src@$(git -C src rev-parse HEAD)" -j 8
|
||||||
|
guard
|
||||||
|
stage "runhooks"
|
||||||
|
gclient runhooks
|
||||||
|
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
||||||
|
guard
|
||||||
|
cd src
|
||||||
|
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
|
||||||
|
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
|
||||||
|
# process (verified on the Frame 2026-09-26). Allow only that option.
|
||||||
|
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
|
||||||
|
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
index 435e13d396..297453f582 100644
|
||||||
|
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
@@ -11,6 +11,7 @@
|
||||||
|
#include "sandbox/linux/system_headers/linux_syscalls.h"
|
||||||
|
#include "sandbox/policy/linux/sandbox_linux.h"
|
||||||
|
|
||||||
|
+using sandbox::bpf_dsl::AllOf;
|
||||||
|
using sandbox::bpf_dsl::Allow;
|
||||||
|
using sandbox::bpf_dsl::Arg;
|
||||||
|
using sandbox::bpf_dsl::Error;
|
||||||
|
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
|
||||||
|
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||||
|
switch (system_call_number) {
|
||||||
|
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
|
||||||
|
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
|
||||||
|
- // stay disallowed; add a narrow level/optname restriction if ever needed.
|
||||||
|
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
|
||||||
|
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
|
||||||
|
#if defined(__NR_getpeername)
|
||||||
|
case __NR_getpeername:
|
||||||
|
#endif
|
||||||
|
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||||
|
case __NR_get_robust_list:
|
||||||
|
#endif
|
||||||
|
return Allow();
|
||||||
|
+#if defined(__NR_getsockopt)
|
||||||
|
+ case __NR_getsockopt: {
|
||||||
|
+ // SteamVR's IPC client checks who is on the other end of its socket
|
||||||
|
+ // with SO_PEERCRED. Nothing else is readable.
|
||||||
|
+ const Arg<int> level(1);
|
||||||
|
+ const Arg<int> optname(2);
|
||||||
|
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
|
||||||
|
+ .Else(Error(EPERM));
|
||||||
|
+ }
|
||||||
|
+#endif
|
||||||
|
#if defined(__NR_kill)
|
||||||
|
case __NR_kill: {
|
||||||
|
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
|
||||||
|
P
|
||||||
|
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
|
||||||
|
printf '%s\n' "$PEERCRED_PATCH" | git apply
|
||||||
|
stage "applied SO_PEERCRED patch"
|
||||||
|
fi
|
||||||
|
mkdir -p out/XR
|
||||||
|
cat > out/XR/args.gn <<'A'
|
||||||
|
target_os = "linux"
|
||||||
|
target_cpu = "arm64"
|
||||||
|
is_debug = false
|
||||||
|
is_official_build = false
|
||||||
|
is_component_build = false
|
||||||
|
dcheck_always_on = false
|
||||||
|
symbol_level = 0
|
||||||
|
blink_symbol_level = 0
|
||||||
|
v8_symbol_level = 0
|
||||||
|
proprietary_codecs = true
|
||||||
|
ffmpeg_branding = "Chrome"
|
||||||
|
use_remoteexec = false
|
||||||
|
use_siso = true
|
||||||
|
treat_warnings_as_errors = false
|
||||||
|
A
|
||||||
|
stage "gn gen"
|
||||||
|
gn gen out/XR
|
||||||
|
gn args out/XR --list=enable_openxr --short | tee -a "$W/stage"
|
||||||
|
stage "build"
|
||||||
|
( while sleep 600; do guard || { pkill -u "$(id -u)" -f "siso|ninja"; exit 3; }; done ) &
|
||||||
|
GUARD=$!
|
||||||
|
trap 'kill $GUARD 2>/dev/null || true' EXIT
|
||||||
|
autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler
|
||||||
|
stage "package"
|
||||||
|
cd out/XR
|
||||||
|
files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales)
|
||||||
|
# GPU libraries aren't produced by every config; pack the ones that exist.
|
||||||
|
for f in libEGL.so libGLESv2.so libvk_swiftshader.so libvulkan.so.1 vk_swiftshader_icd.json; do
|
||||||
|
[ -e "$f" ] && files+=("$f")
|
||||||
|
done
|
||||||
|
tar -cJf "$W/chromium-xr-arm64.tar.xz" "${files[@]}"
|
||||||
|
stage "DONE $(ls -la $W/chromium-xr-arm64.tar.xz)"
|
||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
# Mac-side: install and launch the WebXR-enabled Chromium build on the Frame.
|
||||||
|
#
|
||||||
|
# Flathub Chromium can't enter immersive WebXR on Linux: upstream only wires
|
||||||
|
# the OpenXR device on Windows (see docs/webxr-chromium.md). This deploys an
|
||||||
|
# arm64 build with the Linux OpenXR CLs, made on a Linux host by
|
||||||
|
# scripts/build-chromium-xr.sh, into ~/chromium-xr on the Frame (not a
|
||||||
|
# Flatpak, so SteamVR's sockets and the XR sandbox work unmodified).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
|
||||||
|
# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
|
||||||
|
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||||
|
BUILD_HOST=${BUILD_HOST:-}
|
||||||
|
BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz}
|
||||||
|
DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223}
|
||||||
|
here=${0:A:h}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
install)
|
||||||
|
tarball=${2:-}
|
||||||
|
if [[ -z "$tarball" ]]; then
|
||||||
|
[[ -n "$BUILD_HOST" ]] || { print -u2 "Pass a tarball, or set BUILD_HOST to the build machine"; exit 2; }
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
tarball=$tmp/chromium-xr-arm64.tar.xz
|
||||||
|
scp -q "$BUILD_HOST:$BUILD_TARBALL" "$tarball"
|
||||||
|
fi
|
||||||
|
ssh "$FRAME_ALIAS" 'rm -rf ~/chromium-xr.new && mkdir -p ~/chromium-xr.new'
|
||||||
|
ssh "$FRAME_ALIAS" 'tar -xJf - -C ~/chromium-xr.new' < "$tarball"
|
||||||
|
# Check the new build runs before replacing the old one.
|
||||||
|
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
|
||||||
|
;;
|
||||||
|
launch)
|
||||||
|
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
|
||||||
|
# need to be open. The app starts in $HOME, so the profile path is relative.
|
||||||
|
# Without --no-first-run and --password-store=basic, startup can stop at a
|
||||||
|
# first-run or keyring prompt before DevTools comes up.
|
||||||
|
# --disable-seccomp-filter-sandbox: under the XR seccomp policy, SteamVR's
|
||||||
|
# client reads /proc/self/status through the file broker, gets the
|
||||||
|
# broker's pid, and SteamVR binds the app to the wrong process, so
|
||||||
|
# xrCreateInstance fails. The namespace sandbox stays on, but seccomp is
|
||||||
|
# off for every process, so keep this profile for VR sites.
|
||||||
|
exec "$here/panel-on-frame.sh" --name chromium-xr -- '~/chromium-xr/chrome' \
|
||||||
|
--user-data-dir=.config/chromium-xr \
|
||||||
|
--enable-features=OpenXR \
|
||||||
|
--ozone-platform=x11 \
|
||||||
|
--no-first-run --no-default-browser-check --password-store=basic \
|
||||||
|
--disable-seccomp-filter-sandbox \
|
||||||
|
--remote-debugging-port="$DEVTOOLS_PORT" \
|
||||||
|
"${2:-https://immersive-web.github.io/webxr-samples/}"
|
||||||
|
;;
|
||||||
|
check)
|
||||||
|
# DevTools listens on the Frame's loopback only; evaluate there.
|
||||||
|
ssh "$FRAME_ALIAS" python3 - "$DEVTOOLS_PORT" <<'EOF'
|
||||||
|
import json, sys, urllib.request, base64, os, socket, struct
|
||||||
|
port = int(sys.argv[1])
|
||||||
|
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=10))
|
||||||
|
page = next((t for t in tabs if t["type"] == "page"), None)
|
||||||
|
if page is None:
|
||||||
|
sys.exit("no open page: run 'chromium-xr.sh launch' first")
|
||||||
|
path = page["webSocketDebuggerUrl"].split(f":{port}", 1)[1]
|
||||||
|
s = socket.create_connection(("127.0.0.1", port), timeout=30)
|
||||||
|
key = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
s.sendall(f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n"
|
||||||
|
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
|
||||||
|
"Sec-WebSocket-Version: 13\r\n\r\n".encode())
|
||||||
|
s.recv(4096)
|
||||||
|
msg = json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
|
||||||
|
"expression": "navigator.xr ? navigator.xr.isSessionSupported('immersive-vr') : 'no navigator.xr'",
|
||||||
|
"awaitPromise": True}}).encode()
|
||||||
|
mask = os.urandom(4)
|
||||||
|
hdr = bytes([0x81]) + (bytes([0x80 | len(msg)]) if len(msg) < 126
|
||||||
|
else bytes([0x80 | 126]) + struct.pack(">H", len(msg)))
|
||||||
|
s.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(msg)))
|
||||||
|
buf = b""
|
||||||
|
reply = None
|
||||||
|
while reply is None:
|
||||||
|
chunk = s.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
sys.exit("DevTools closed the connection")
|
||||||
|
buf += chunk
|
||||||
|
# Consume every complete frame already buffered before reading again.
|
||||||
|
while len(buf) >= 2:
|
||||||
|
n = buf[1] & 0x7F
|
||||||
|
off = 2
|
||||||
|
if n == 126:
|
||||||
|
if len(buf) < 4:
|
||||||
|
break
|
||||||
|
n, off = struct.unpack(">H", buf[2:4])[0], 4
|
||||||
|
elif n == 127:
|
||||||
|
if len(buf) < 10:
|
||||||
|
break
|
||||||
|
n, off = struct.unpack(">Q", buf[2:10])[0], 10
|
||||||
|
if len(buf) < off + n:
|
||||||
|
break
|
||||||
|
frame, buf = buf[off:off + n], buf[off + n:]
|
||||||
|
msg = json.loads(frame)
|
||||||
|
if msg.get("id") == 1:
|
||||||
|
reply = msg
|
||||||
|
break
|
||||||
|
print("immersive-vr supported:", reply["result"]["result"].get("value"))
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
*) sed -n '2,13p' "$0"; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
#
|
#
|
||||||
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
|
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
|
||||||
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
|
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
|
||||||
# 60), and uploads to Google Drive (the backup folder) when
|
# 60), and uploads to the Google Drive folder DRIVE_FOLDER_ID when the data
|
||||||
# the data changed since the last upload. Run daily by the LaunchAgent
|
# changed since the last upload. Maintainer-only: it needs the database key.
|
||||||
# frame-compat-backup (see docs/apks.md).
|
# Run it daily from a LaunchAgent (see compat-db/README.md).
|
||||||
#
|
#
|
||||||
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
|
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
|
||||||
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
|
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
|
||||||
@@ -14,8 +14,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="${0:A:h}/.."
|
ROOT="${0:A:h}/.."
|
||||||
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
|
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
|
||||||
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-<drive-folder-id>}
|
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-}
|
||||||
GOG_WRAPPER=${GOG_WRAPPER:-$HOME/bin/gog-with-keyring.sh}
|
GOG_WRAPPER=${GOG_WRAPPER:-$(command -v gog || true)}
|
||||||
upload=1 force=0 accept_shrink=0
|
upload=1 force=0 accept_shrink=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
@@ -60,9 +60,10 @@ if (( upload )); then
|
|||||||
print "==> Unchanged since the last Drive upload; skipped"
|
print "==> Unchanged since the last Drive upload; skipped"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
[[ -x "$GOG_WRAPPER" ]] || { print -u2 "gog wrapper not found at $GOG_WRAPPER"; exit 1; }
|
[[ -n "$DRIVE_FOLDER_ID" ]] || { print -u2 "Set DRIVE_FOLDER_ID, or pass --no-upload"; exit 1; }
|
||||||
|
[[ -n "$GOG_WRAPPER" && -x "$GOG_WRAPPER" ]] || { print -u2 "gog not found; install it or set GOG_WRAPPER"; exit 1; }
|
||||||
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
||||||
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
||||||
print -r -- "$digest" > "$last"
|
print -r -- "$digest" > "$last"
|
||||||
print "==> Uploaded to Google Drive (the backup folder)"
|
print "==> Uploaded to Google Drive"
|
||||||
fi
|
fi
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Compatibility reports without the maintainer's key: saved locally, never sent.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_compat_db as db # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class NoKey(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
state = tmp.name
|
||||||
|
for name, value in (("STATE", state), ("OUTBOX", os.path.join(state, "outbox.jsonl")),
|
||||||
|
("MIRROR", os.path.join(state, "mirror.json"))):
|
||||||
|
p = mock.patch.object(db, name, value)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
db._mem.update(at=0, reports=None, source=None)
|
||||||
|
env = mock.patch.dict(os.environ, {}, clear=False)
|
||||||
|
env.start()
|
||||||
|
self.addCleanup(env.stop)
|
||||||
|
os.environ.pop("FRAME_CONTROL_KEY", None)
|
||||||
|
# No Keychain entry, and any network use fails the test.
|
||||||
|
no_key = mock.patch.object(db.subprocess, "run",
|
||||||
|
return_value=mock.Mock(returncode=44, stdout=""))
|
||||||
|
no_key.start()
|
||||||
|
self.addCleanup(no_key.stop)
|
||||||
|
net = mock.patch.object(db._opener, "open", side_effect=AssertionError("network used"))
|
||||||
|
net.start()
|
||||||
|
self.addCleanup(net.stop)
|
||||||
|
|
||||||
|
def test_report_is_kept_locally(self):
|
||||||
|
self.assertFalse(db.shared())
|
||||||
|
r = db.add({"package": "org.example.app", "date": "2026-09-26T10:00:00", "rating": "works"})
|
||||||
|
reports = db.load()
|
||||||
|
self.assertEqual([x["id"] for x in reports], [r["id"]])
|
||||||
|
self.assertEqual(db._mem["source"], "mirror")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -83,6 +83,7 @@ class ServerGuards(unittest.TestCase):
|
|||||||
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
|
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
|
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
|
||||||
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
||||||
|
|
||||||
@@ -116,6 +117,11 @@ class ServerGuards(unittest.TestCase):
|
|||||||
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||||
self.assertEqual(status, 400, shot)
|
self.assertEqual(status, 400, shot)
|
||||||
|
|
||||||
|
def test_stream_settings_checked_before_ssh(self):
|
||||||
|
for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"):
|
||||||
|
status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"})
|
||||||
|
self.assertEqual(status, 400, query)
|
||||||
|
|
||||||
def test_bad_bodies(self):
|
def test_bad_bodies(self):
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||||
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
|
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
|
||||||
@@ -130,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()
|
||||||
|
|||||||
+29
-11
@@ -1,20 +1,23 @@
|
|||||||
"""Frame Control's compatibility database: a private Lakebed capsule
|
"""Frame Control's compatibility database: a private Lakebed capsule
|
||||||
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
|
||||||
write, using a key kept in the macOS Keychain (service frame-control-compat-db,
|
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
|
||||||
account app-key).
|
frame-control-compat-db, account app-key). Without one (anyone but the
|
||||||
|
maintainer), reports stay local.
|
||||||
|
|
||||||
New reports go to a local outbox first and are sent from there, so nothing is
|
New reports go to a local outbox first and are sent from there, so nothing is
|
||||||
lost offline. A mirror of every report is kept for offline reads. Both live in
|
lost offline. A mirror of every report is kept for offline reads. Both live in
|
||||||
~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
|
frame_host.data_dir('compat-db'). Python stdlib only.
|
||||||
|
|
||||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
||||||
(import restores a backup; reports already in the database are skipped.)
|
(import restores a backup; reports already in the database are skipped.)
|
||||||
"""
|
"""
|
||||||
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
||||||
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
|
STATE = str(frame_host.data_dir('compat-db'))
|
||||||
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
|
||||||
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
MIRROR = os.path.join(STATE, 'compat-mirror.json')
|
||||||
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
|
||||||
@@ -32,14 +35,26 @@ def key():
|
|||||||
k = os.environ.get('FRAME_CONTROL_KEY')
|
k = os.environ.get('FRAME_CONTROL_KEY')
|
||||||
if k:
|
if k:
|
||||||
return k
|
return k
|
||||||
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
p = None
|
||||||
capture_output=True, text=True)
|
if frame_host.MAC:
|
||||||
if p.returncode != 0 or not p.stdout.strip():
|
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
|
||||||
raise DBError('No compatibility-database key in the Keychain '
|
capture_output=True, text=True)
|
||||||
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
if p is None or p.returncode != 0 or not p.stdout.strip():
|
||||||
|
raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
|
||||||
|
f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
|
||||||
return p.stdout.strip()
|
return p.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def shared():
|
||||||
|
"""Whether reports reach the shared database. Without the key (anyone but the
|
||||||
|
maintainer), reports stay in this computer's outbox and ratings come from the catalogue."""
|
||||||
|
try:
|
||||||
|
key()
|
||||||
|
return True
|
||||||
|
except DBError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
"""Never follow redirects: urllib would copy the key header to the new host."""
|
"""Never follow redirects: urllib would copy the key header to the new host."""
|
||||||
def redirect_request(self, *args, **kwargs):
|
def redirect_request(self, *args, **kwargs):
|
||||||
@@ -169,6 +184,8 @@ def load():
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||||
try:
|
try:
|
||||||
|
if not shared():
|
||||||
|
raise DBError('no key')
|
||||||
try:
|
try:
|
||||||
flush()
|
flush()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -196,8 +213,9 @@ def add(report):
|
|||||||
with _lock, open(OUTBOX, 'a') as f:
|
with _lock, open(OUTBOX, 'a') as f:
|
||||||
f.write(json.dumps(r, ensure_ascii=False) + '\n')
|
f.write(json.dumps(r, ensure_ascii=False) + '\n')
|
||||||
try:
|
try:
|
||||||
flush()
|
if shared():
|
||||||
_mem['at'] = 0 # refetch on next load
|
flush()
|
||||||
|
_mem['at'] = 0 # refetch on next load
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # stays queued; load() shows it and a later call sends it
|
pass # stays queued; load() shows it and a later call sends it
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+141
-17
@@ -321,7 +321,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="action" id="shotBtn">Capture</button>
|
<button class="action" id="shotBtn">Capture</button>
|
||||||
<button id="liveBtn" title="Keep capturing">Live</button>
|
<button id="liveBtn" title="Keep updating: video of the headset view, or repeated captures of the desktop panel">Live</button>
|
||||||
<button id="saveBtn" disabled>Save</button>
|
<button id="saveBtn" disabled>Save</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer" id="viewer">
|
<div class="viewer" id="viewer">
|
||||||
@@ -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>
|
||||||
@@ -434,8 +434,8 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
|
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
|
||||||
<div class="list" id="repList"><div class="sub">Loading…</div></div>
|
<div class="list" id="repList"><div class="sub">Loading…</div></div>
|
||||||
<div class="hint">Reports go to Frame Control's private compatibility database and change the
|
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
|
||||||
verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid.</div>
|
reported, including ones not on F-Droid.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -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>
|
||||||
@@ -575,7 +575,7 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
|
|||||||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||||||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||||||
const HINTS = {
|
const HINTS = {
|
||||||
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live refreshes about twice a second. Captures show everything on screen, including anything private.",
|
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
|
||||||
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
||||||
};
|
};
|
||||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
|
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
|
||||||
@@ -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) };
|
||||||
@@ -751,10 +760,12 @@ function render(s) {
|
|||||||
|
|
||||||
// ---- view ----
|
// ---- view ----
|
||||||
function setView(v) {
|
function setView(v) {
|
||||||
|
const changed = v !== view;
|
||||||
view = v;
|
view = v;
|
||||||
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
||||||
$("eyeSeg").style.visibility = v === "headset" ? "visible" : "hidden";
|
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
|
||||||
$("viewHint").textContent = HINTS[v];
|
$("viewHint").textContent = HINTS[v];
|
||||||
|
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
|
||||||
}
|
}
|
||||||
function setEye(e) {
|
function setEye(e) {
|
||||||
eye = e;
|
eye = e;
|
||||||
@@ -841,6 +852,7 @@ function isBlank(ctx, w, h) {
|
|||||||
return max - min < 6;
|
return max - min < 6;
|
||||||
}
|
}
|
||||||
async function capture() {
|
async function capture() {
|
||||||
|
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
|
||||||
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
||||||
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
||||||
let url = null;
|
let url = null;
|
||||||
@@ -883,7 +895,115 @@ function toggleLive(on) {
|
|||||||
liveFailures = 0;
|
liveFailures = 0;
|
||||||
$("liveBtn").classList.toggle("on", live);
|
$("liveBtn").classList.toggle("on", live);
|
||||||
$("liveBadge").hidden = !live;
|
$("liveBadge").hidden = !live;
|
||||||
if (live) liveLoop(); else clearTimeout(liveTimer);
|
clearTimeout(liveTimer);
|
||||||
|
stopVideo();
|
||||||
|
if (!live) return;
|
||||||
|
if (view === "headset" && "VideoDecoder" in window) {
|
||||||
|
const started = startVideo();
|
||||||
|
const ctl = video.ctl;
|
||||||
|
started.catch(e => {
|
||||||
|
if (!live || ctl !== video.ctl || e.name === "AbortError") return;
|
||||||
|
log("Live video failed: " + e.message, "e");
|
||||||
|
toast("Live video failed, using captures instead: " + e.message, true);
|
||||||
|
stopVideo();
|
||||||
|
liveLoop();
|
||||||
|
});
|
||||||
|
} else liveLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- live video of the headset view ----
|
||||||
|
// The server relays raw H.264 (Annex B) from SteamVR's headset-view device.
|
||||||
|
// Every frame starts with an access unit delimiter (NAL type 9), which is how
|
||||||
|
// the stream is cut into frames for WebCodecs.
|
||||||
|
const STREAM_QUERY = "h=720&fps=30", STREAM_FPS = 30;
|
||||||
|
const video = { ctl: null, dec: null, gen: 0 };
|
||||||
|
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1; }
|
||||||
|
function nalTypes(au) {
|
||||||
|
const types = [];
|
||||||
|
for (let i = 0; i + 3 < au.length; i++) if (startCode(au, i)) { types.push(au[i + 3] & 0x1f); i += 3; }
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
async function startVideo() {
|
||||||
|
const gen = ++video.gen, ctl = new AbortController();
|
||||||
|
video.ctl = ctl;
|
||||||
|
$("eyeSeg").style.visibility = "hidden";
|
||||||
|
if (!lastImg) $("viewer").classList.add("busy");
|
||||||
|
let frames = 0, shown = 0, second = performance.now(), needKey = true, ts = 0;
|
||||||
|
const c = $("canvas"), ctx = c.getContext("2d");
|
||||||
|
const dec = video.dec = new VideoDecoder({
|
||||||
|
output: frame => {
|
||||||
|
if (gen !== video.gen) return frame.close();
|
||||||
|
if (c.width !== frame.displayWidth || c.height !== frame.displayHeight) {
|
||||||
|
c.width = frame.displayWidth; c.height = frame.displayHeight;
|
||||||
|
$("viewer").style.aspectRatio = `${c.width} / ${c.height}`;
|
||||||
|
setZoom(1);
|
||||||
|
}
|
||||||
|
ctx.drawImage(frame, 0, 0);
|
||||||
|
frame.close();
|
||||||
|
if (!shown++) {
|
||||||
|
viewGen++; // a capture still in flight mustn't replace the video
|
||||||
|
lastImg = null; lastSource = "video"; lastShot = null;
|
||||||
|
$("viewer").classList.remove("busy");
|
||||||
|
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||||||
|
$("srcBadge").hidden = false; $("srcBadge").textContent = "Headset view · video";
|
||||||
|
$("stamp").hidden = false; $("saveBtn").disabled = false;
|
||||||
|
}
|
||||||
|
frames++;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - second >= 1000) {
|
||||||
|
$("stamp").textContent = `${Math.round(frames * 1000 / (now - second))} fps`;
|
||||||
|
frames = 0; second = now;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: e => log("Video decoder: " + e.message, "e"),
|
||||||
|
});
|
||||||
|
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
let buf = new Uint8Array(0), scan = 0, auStart = -1;
|
||||||
|
const onAU = au => {
|
||||||
|
const types = nalTypes(au), key = types.includes(5);
|
||||||
|
if (dec.state === "unconfigured") {
|
||||||
|
const sps = types.indexOf(7);
|
||||||
|
if (sps < 0) return;
|
||||||
|
let i = 0, n = -1; // find the SPS bytes: profile, constraints, level follow its header
|
||||||
|
for (; i + 3 < au.length; i++) if (startCode(au, i) && ++n === sps) break;
|
||||||
|
if (i + 6 >= au.length) return;
|
||||||
|
const hex = [au[i + 4], au[i + 5], au[i + 6]].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
dec.configure({ codec: `avc1.${hex}`, optimizeForLatency: true });
|
||||||
|
}
|
||||||
|
// If decoding falls behind, drop frames until the next keyframe rather than lag.
|
||||||
|
if (dec.decodeQueueSize > 3) needKey = true;
|
||||||
|
if (needKey && !key) return;
|
||||||
|
needKey = false;
|
||||||
|
dec.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: au }));
|
||||||
|
ts += 1e6 / STREAM_FPS;
|
||||||
|
};
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done || gen !== video.gen) break;
|
||||||
|
const next = new Uint8Array(buf.length + value.length);
|
||||||
|
next.set(buf); next.set(value, buf.length);
|
||||||
|
buf = next;
|
||||||
|
if (buf.length > 8 << 20) throw new Error("the stream isn't split into frames");
|
||||||
|
for (; scan + 3 < buf.length; scan++) {
|
||||||
|
if (!startCode(buf, scan) || (buf[scan + 3] & 0x1f) !== 9) continue;
|
||||||
|
if (auStart >= 0) onAU(buf.subarray(auStart, scan));
|
||||||
|
auStart = scan;
|
||||||
|
scan += 3;
|
||||||
|
}
|
||||||
|
if (auStart > 0) { buf = buf.slice(auStart); scan -= auStart; auStart = 0; }
|
||||||
|
}
|
||||||
|
if (gen === video.gen && live) throw new Error(shown ? "the stream ended" : "no video arrived");
|
||||||
|
}
|
||||||
|
function stopVideo() {
|
||||||
|
video.gen++;
|
||||||
|
if (video.ctl) video.ctl.abort();
|
||||||
|
if (video.dec && video.dec.state !== "closed") video.dec.close();
|
||||||
|
video.ctl = video.dec = null;
|
||||||
|
$("viewer").classList.remove("busy");
|
||||||
|
if (lastSource === "video") { $("srcBadge").textContent = "Headset view · video (stopped)"; $("stamp").hidden = true; }
|
||||||
|
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
|
||||||
}
|
}
|
||||||
$("shotBtn").onclick = () => capture();
|
$("shotBtn").onclick = () => capture();
|
||||||
$("liveBtn").onclick = () => toggleLive(!live);
|
$("liveBtn").onclick = () => toggleLive(!live);
|
||||||
@@ -949,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");
|
||||||
@@ -980,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"));
|
||||||
}
|
}
|
||||||
@@ -1345,9 +1465,12 @@ const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", run
|
|||||||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||||||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||||||
async function loadReports() {
|
async function loadReports() {
|
||||||
let reps;
|
let reps, shared;
|
||||||
try { reps = (await api("/api/android/reports")).reports; }
|
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||||||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||||
|
$("repHint").textContent = shared
|
||||||
|
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||||
|
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||||
const k = r.rating || r.result;
|
const k = r.rating || r.result;
|
||||||
@@ -1408,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: [] };
|
||||||
@@ -1433,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.
|
||||||
@@ -1492,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 => {
|
||||||
|
|||||||
+273
-88
@@ -1,16 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
|
"""Frame Control: a small local web UI for managing the Steam Frame from a computer.
|
||||||
|
|
||||||
Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
|
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||||
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
|
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
||||||
|
up by scripts/connect.sh or ui/frame_connect.py.
|
||||||
|
|
||||||
Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
|
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||||
Env: FRAME_ALIAS (default frame)
|
Env: FRAME_ALIAS (default frame)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
@@ -25,19 +28,25 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
import frame_android
|
# Windows' embedded Python (bundled with the app) doesn't put the script's own
|
||||||
import frame_catalog
|
# folder on sys.path, so add it for the sibling modules below.
|
||||||
import frame_store
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import frame_android # noqa: E402
|
||||||
|
import frame_catalog # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
import frame_store # noqa: E402
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
SCRIPTS = HERE.parent / "scripts"
|
|
||||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||||
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||||
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
|
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||||
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
|
# supports it (not on Windows: there every command connects on its own).
|
||||||
|
CONTROL = frame_host.control_path()
|
||||||
|
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||||
# Commands use the master when it's up and connect directly when it isn't.
|
# Commands use the master when it's up and connect directly when it isn't.
|
||||||
SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
|
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||||
|
|
||||||
# Android helpers share the multiplexed connection when it's up.
|
# Android helpers share the multiplexed connection when it's up.
|
||||||
frame_android.SSH_OPTS = SSH[1:]
|
frame_android.SSH_OPTS = SSH[1:]
|
||||||
@@ -81,9 +90,12 @@ def ensure_master():
|
|||||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||||
"""
|
"""
|
||||||
global _master
|
global _master
|
||||||
|
if not CONTROL:
|
||||||
|
return
|
||||||
|
|
||||||
def up():
|
def up():
|
||||||
try:
|
try:
|
||||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
|
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
|
||||||
timeout=5).returncode == 0
|
timeout=5).returncode == 0
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False
|
return False
|
||||||
@@ -96,7 +108,7 @@ def ensure_master():
|
|||||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
if up() or _master.poll() is not None:
|
if up() or _master.poll() is not None:
|
||||||
return
|
return
|
||||||
@@ -106,7 +118,10 @@ def ensure_master():
|
|||||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||||
try:
|
try:
|
||||||
ensure_master()
|
ensure_master()
|
||||||
r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True,
|
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||||
|
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||||
|
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||||
|
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||||
text=text, errors="replace" if text else None, timeout=timeout)
|
text=text, errors="replace" if text else None, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Timed out talking to {FRAME}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
@@ -118,40 +133,16 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
def script(name, *args, stdin=None, timeout=900):
|
|
||||||
"""Run one of ../scripts and return its combined output."""
|
|
||||||
try:
|
|
||||||
r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
|
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
||||||
env={**os.environ, "FRAME_ALIAS": FRAME})
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise Failure(f"{name} timed out")
|
|
||||||
out = strip_ansi(r.stdout).strip()
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise Failure(out or f"{name} exited {r.returncode}")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def strip_ansi(s):
|
def strip_ansi(s):
|
||||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||||
|
|
||||||
|
|
||||||
def terminal(command):
|
def terminal(argv):
|
||||||
"""Open Terminal.app running `command` (for anything needing a password)."""
|
"""Open a terminal window running argv (for anything needing a password)."""
|
||||||
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
|
try:
|
||||||
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
|
return frame_host.open_terminal(argv)
|
||||||
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
|
except frame_host.HostError as e:
|
||||||
capture_output=True, text=True)
|
raise Failure(str(e), 500)
|
||||||
if r.returncode != 0:
|
|
||||||
# Usually macOS Automation consent for Terminal was denied.
|
|
||||||
raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
def open_app(name, fallback_url):
|
|
||||||
if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
|
|
||||||
return f"Opened {name}"
|
|
||||||
subprocess.run(["open", fallback_url])
|
|
||||||
return f"{name} isn't installed; opened its download page"
|
|
||||||
|
|
||||||
|
|
||||||
# ---- actions ---------------------------------------------------------------
|
# ---- actions ---------------------------------------------------------------
|
||||||
@@ -258,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:
|
||||||
@@ -272,6 +263,41 @@ def save_shots(body):
|
|||||||
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
||||||
|
|
||||||
|
|
||||||
|
# Live video of the headset view. SteamVR's steamvr-v4l2cam.service copies the
|
||||||
|
# headset view (one undistorted 1920x1080 image) into the v4l2loopback device
|
||||||
|
# /dev/video99. ffmpeg encodes it with x264 (the hardware encoder crashes
|
||||||
|
# ffmpeg) and the raw H.264 comes back over SSH for the page to decode with
|
||||||
|
# WebCodecs. An access unit delimiter starts every frame so the page can split
|
||||||
|
# the stream, and repeated SPS/PPS let it start at any keyframe. ffmpeg runs in
|
||||||
|
# the background while the shell waits for our stdin to close: when the local
|
||||||
|
# ssh goes, the channel closes and the shell kills ffmpeg, even one that has
|
||||||
|
# stopped writing (and so would never get SIGPIPE).
|
||||||
|
STREAM_DEVICE = "/dev/video99"
|
||||||
|
STREAM_HEIGHTS = (720, 1080)
|
||||||
|
STREAM_FPS = (30, 60)
|
||||||
|
STREAM_STALL = 10 # seconds without video before the stream is dropped
|
||||||
|
_stream_lock = threading.Lock()
|
||||||
|
_stream_proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def stream_command(query):
|
||||||
|
q = parse_qs(query)
|
||||||
|
try:
|
||||||
|
height = int((q.get("h") or ["720"])[0])
|
||||||
|
fps = int((q.get("fps") or ["30"])[0])
|
||||||
|
except ValueError:
|
||||||
|
raise Failure("h and fps must be integers", 400)
|
||||||
|
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
|
||||||
|
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
|
||||||
|
rate = 3 if height == 720 else 6 # Mbit/s
|
||||||
|
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
|
||||||
|
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
|
||||||
|
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
|
||||||
|
f"-g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
|
||||||
|
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
|
||||||
|
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
|
||||||
|
|
||||||
|
|
||||||
def launch(body):
|
def launch(body):
|
||||||
appid = str(body.get("appid", ""))
|
appid = str(body.get("appid", ""))
|
||||||
if not APPID.match(appid):
|
if not APPID.match(appid):
|
||||||
@@ -332,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):
|
||||||
@@ -346,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}"}
|
||||||
@@ -355,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -402,7 +463,8 @@ def android(body):
|
|||||||
runtime=body.get("runtime") or "instance",
|
runtime=body.get("runtime") or "instance",
|
||||||
label=body.get("label"), source=body.get("source"))
|
label=body.get("label"), source=body.get("source"))
|
||||||
name = r.get("label") or pkg
|
name = r.get("label") or pkg
|
||||||
return {"message": f"Saved your report for {name}", "report": r}
|
where = "" if frame_catalog.compat_db.shared() else " on this computer"
|
||||||
|
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(e))
|
raise Failure(str(e))
|
||||||
raise Failure("unknown action", 400)
|
raise Failure("unknown action", 400)
|
||||||
@@ -423,20 +485,19 @@ FONT_RANGE = (0.5, 2.0)
|
|||||||
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
|
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
|
||||||
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
|
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
|
||||||
_adb_lock = threading.Lock()
|
_adb_lock = threading.Lock()
|
||||||
_live_tunnels = set() # ssh processes to kill if the server stops mid-request
|
_live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the server stops mid-request
|
||||||
|
|
||||||
|
|
||||||
def adb_path():
|
def adb_path():
|
||||||
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
|
try:
|
||||||
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
|
return frame_host.adb()
|
||||||
if cand and os.access(cand, os.X_OK):
|
except frame_host.HostError as e:
|
||||||
return cand
|
raise Failure(str(e), 500)
|
||||||
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
|
|
||||||
|
|
||||||
|
|
||||||
def adb(adb_bin, *args, timeout=20):
|
def adb(adb_bin, *args, timeout=20):
|
||||||
try:
|
try:
|
||||||
r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
|
r = subprocess.run([adb_bin, *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
errors="replace", timeout=timeout)
|
errors="replace", timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
||||||
@@ -453,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.
|
||||||
@@ -551,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:
|
||||||
@@ -705,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
|
||||||
@@ -751,13 +856,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||||
|
elif path == "/api/host":
|
||||||
|
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||||
|
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||||
elif path == "/api/android":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"apps": frame_android.list_apps()})
|
self.send_json({"apps": frame_android.list_apps()})
|
||||||
elif path == "/api/android/displays":
|
elif path == "/api/android/displays":
|
||||||
self.send_json(android_displays())
|
self.send_json(android_displays())
|
||||||
elif path == "/api/android/reports":
|
elif path == "/api/android/reports":
|
||||||
self.send_json({"reports": frame_catalog.recent_reports()})
|
self.send_json({"reports": frame_catalog.recent_reports(),
|
||||||
|
"shared": frame_catalog.compat_db.shared()})
|
||||||
elif path == "/api/android/catalog":
|
elif path == "/api/android/catalog":
|
||||||
self.send_json({"apps": frame_catalog.catalog()})
|
self.send_json({"apps": frame_catalog.catalog()})
|
||||||
elif path == "/api/status":
|
elif path == "/api/status":
|
||||||
@@ -770,6 +879,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(list_shots())
|
self.send_json(list_shots())
|
||||||
elif path == "/api/shots/image":
|
elif path == "/api/shots/image":
|
||||||
self.send_bytes(*shot_image(url.query))
|
self.send_bytes(*shot_image(url.query))
|
||||||
|
elif path == "/api/stream":
|
||||||
|
self.stream_video(url.query)
|
||||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
||||||
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
||||||
elif path == "/api/screenshot":
|
elif path == "/api/screenshot":
|
||||||
@@ -779,6 +890,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"error": "not found"}, 404)
|
self.send_json({"error": "not found"}, 404)
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
self.send_json({"error": str(e)}, e.status)
|
self.send_json({"error": str(e)}, e.status)
|
||||||
|
except frame_android.FrameError as e:
|
||||||
|
self.send_json({"error": str(e)}, 502)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||||
|
|
||||||
@@ -805,9 +918,67 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"error": str(e)}, e.status)
|
self.send_json({"error": str(e)}, e.status)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||||
|
except frame_android.FrameError as e:
|
||||||
|
self.send_json({"error": str(e)}, 502)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||||
|
|
||||||
|
def stream_video(self, query):
|
||||||
|
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
||||||
|
global _stream_proc
|
||||||
|
remote = stream_command(query)
|
||||||
|
ensure_master()
|
||||||
|
# stderr goes to a file: nothing reads it while streaming, and a full
|
||||||
|
# pipe would stall ffmpeg. It's only read if the stream fails to start.
|
||||||
|
errors = tempfile.TemporaryFile()
|
||||||
|
proc = subprocess.Popen([*SSH, FRAME, remote], stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE, stderr=errors)
|
||||||
|
try:
|
||||||
|
_live_tunnels.add(proc)
|
||||||
|
# One viewer at a time: a new stream (another tab, a reload) ends the last one.
|
||||||
|
with _stream_lock:
|
||||||
|
old, _stream_proc = _stream_proc, proc
|
||||||
|
if old and old.poll() is None:
|
||||||
|
old.terminate()
|
||||||
|
chunks = _pipe_reader(proc.stdout)
|
||||||
|
# Nothing is sent until the first bytes arrive, so a failure to
|
||||||
|
# start still comes back as a JSON error.
|
||||||
|
first = _next_chunk(chunks, 20)
|
||||||
|
if not first:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
errors.seek(0)
|
||||||
|
err = strip_ansi(errors.read().decode(errors="replace")).strip()
|
||||||
|
raise Failure(err or "The headset view sent no video for 20 s")
|
||||||
|
chunk = first
|
||||||
|
try:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "video/h264")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
|
self.end_headers()
|
||||||
|
self.close_connection = True # the body ends when the connection does
|
||||||
|
while chunk:
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
# A stalled headset view ends the stream rather than
|
||||||
|
# holding this thread (and the page) forever.
|
||||||
|
chunk = _next_chunk(chunks, STREAM_STALL)
|
||||||
|
except OSError:
|
||||||
|
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
for f in (proc.stdin, proc.stdout, errors):
|
||||||
|
f.close()
|
||||||
|
_live_tunnels.discard(proc)
|
||||||
|
|
||||||
def upload(self):
|
def upload(self):
|
||||||
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
|
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
|
||||||
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
|
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
|
||||||
@@ -850,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)
|
||||||
|
|
||||||
@@ -858,20 +1029,34 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||||
|
ap.add_argument("--exit-on-eof", action="store_true",
|
||||||
|
help="stop cleanly when stdin closes (the app closes it on quit; "
|
||||||
|
"Windows has no SIGTERM to catch)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
if not frame_host.WINDOWS:
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||||
|
if args.exit_on_eof:
|
||||||
|
def watch_stdin():
|
||||||
|
sys.stdin.buffer.read()
|
||||||
|
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||||
|
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||||
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||||
try:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
# The app both closes stdin and sends SIGTERM on quit; a second signal
|
||||||
|
# mid-cleanup would abort it and leave the SSH master running.
|
||||||
|
if not frame_host.WINDOWS:
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||||
# The master was started with -N, so it stays up until told to exit.
|
# The master was started with -N, so it stays up until told to exit.
|
||||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
if CONTROL:
|
||||||
|
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
||||||
if _master and _master.poll() is None:
|
if _master and _master.poll() is None:
|
||||||
_master.terminate()
|
_master.terminate()
|
||||||
for proc in list(_live_tunnels): # ADB forwards of requests cut off mid-way
|
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user