mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d73912f8c | ||
|
|
60571dbfac | ||
|
|
f324aac690 | ||
|
|
8b7c46a63a | ||
|
|
f3ae71ab05 | ||
|
|
eabf4cd1f9 |
No files matched your search
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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
|
||||
|
||||
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
|
||||
desktop or panels.
|
||||
|
||||
@@ -22,6 +22,7 @@ desktop or panels.
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
|
||||
|
||||
@@ -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,8 +1,13 @@
|
||||
# Steam Frame ↔ Mac
|
||||
|
||||
This repo holds notes and Mac-side helpers for controlling a Valve Steam Frame
|
||||
(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.
|
||||
**Frame Control** is a Mac app for managing a Valve Steam Frame (standalone VR
|
||||
headset: SteamOS 3, Arch-based, arm64, Snapdragon 8 Gen 3) over SSH: live
|
||||
headset view, battery and status, your Steam library, Android (Lepton) apps,
|
||||
file and clipboard transfer. This repo also holds the scripts behind it and
|
||||
field notes on how the Frame's software works, all aimed at as little typing
|
||||
on the headset's virtual keyboard as possible.
|
||||
|
||||
It's an unofficial hobby project, not affiliated with Valve.
|
||||
|
||||
Status: written 2026-09-25 and checked against a real Frame the same day
|
||||
(SteamOS 0.3.0, variant `vr`, build 20260922). The **Frame Control** Mac app
|
||||
@@ -10,8 +15,41 @@ 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
|
||||
[Frame Control](#frame-control-mac-app) from the DMG.
|
||||
## Trying it out
|
||||
|
||||
You need:
|
||||
|
||||
- A Steam Frame with **Developer Mode** on (next section; it's a toggle).
|
||||
- A Mac with Apple Silicon (M1 or later). Tested on macOS 26. There's no Intel
|
||||
build.
|
||||
- `python3` on the Mac (`xcode-select --install` provides it).
|
||||
- Optional: `adb` for Android apps (`brew install android-platform-tools`).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Download the DMG from the
|
||||
[latest release](https://github.com/saphid/steam-frame/releases/latest),
|
||||
open it and drag **Frame Control** to Applications.
|
||||
2. The app isn't notarized (no paid Apple developer account), so macOS will
|
||||
say it's damaged or can't be checked. Clear the download quarantine once:
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
3. Open it. With no `frame` SSH alias yet, it offers to run the connection
|
||||
setup in Terminal. That asks for the Developer Mode password once, then
|
||||
uses a key from then on.
|
||||
|
||||
**Feedback:** please open a
|
||||
[GitHub issue](https://github.com/saphid/steam-frame/issues) with what you
|
||||
tried, your SteamOS build (Steam Settings → System) and the server log
|
||||
(**Frame → Show Server Log**, at `~/Library/Logs/Frame Control/server.log`).
|
||||
Features are marked **verified** or not below; the unverified ones are the
|
||||
most useful to hear about.
|
||||
|
||||
**What it changes on your Frame:** only what you click. Installs go to your
|
||||
user account (`--user` Flatpaks, Lepton instances, Steam downloads), and
|
||||
nothing needs `sudo` except the power buttons. On the Mac it adds a `Host
|
||||
frame` entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
@@ -33,8 +71,10 @@ On the Frame:
|
||||
|
||||
On the Mac:
|
||||
|
||||
To use the scripts from a checkout instead of the app:
|
||||
|
||||
```sh
|
||||
cd ~/projects/steam-frame
|
||||
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
|
||||
```
|
||||
@@ -105,10 +145,12 @@ the same UI in a browser without packaging:
|
||||
|
||||
- **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; 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**
|
||||
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
|
||||
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
|
||||
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer fits the whole frame; zoom with − / + (or scroll, or
|
||||
double-click), drag to pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots` API through Python `ctypes`
|
||||
(`ui/frame_vrshot.py`). Nothing extra is installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel**
|
||||
captures gamescope's flat layer instead.
|
||||
- Battery with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature
|
||||
@@ -125,9 +167,10 @@ the same UI in a browser without packaging:
|
||||
- **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`)
|
||||
worked (F-Droid or not: pick a file, type a package, or use an installed app). Your
|
||||
reports are saved on your Mac and change the verdicts you see. They aren't
|
||||
uploaded anywhere: the shared database is maintainer-only for now (see
|
||||
`compat-db/README.md`)
|
||||
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
||||
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
||||
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
||||
@@ -175,9 +218,7 @@ 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.
|
||||
(`brew install android-platform-tools`). The F-Droid ratings are bundled with the app.
|
||||
|
||||
The build is ad-hoc signed and not notarized. A copy you build yourself opens
|
||||
normally. A copy downloaded from GitHub Releases is quarantined; clear it with
|
||||
@@ -191,6 +232,7 @@ showed live status and the library.
|
||||
|
||||
| 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**) |
|
||||
@@ -199,7 +241,7 @@ showed live status and the library.
|
||||
| `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/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
|
||||
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](docs/vr-video.md) |
|
||||
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
|
||||
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
|
||||
@@ -219,8 +261,9 @@ showed live status and the library.
|
||||
you've switched to key auth, a short password still protects `sudo` and
|
||||
RDP, so pick one that isn't trivially guessable.
|
||||
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
||||
use Tailscale (Flatpak/package availability for the Frame hasn't been
|
||||
checked).
|
||||
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
||||
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -234,3 +277,8 @@ may find (Xcode Command Line Tools), plus syntax checks for every script and the
|
||||
Electron main process (`.github/workflows/checks.yml`). Anything that touches the
|
||||
headset is verified by hand against a real Frame, and the docs label it
|
||||
**verified** or **inferred**.
|
||||
|
||||
## 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
|
||||
|
||||
Reports live in Frame Control's private database, a Lakebed capsule at
|
||||
`https://frame-compat.lakebed.app` that only the app can read or write (see
|
||||
Reports are saved on your Mac. The maintainer's copy of Frame Control also
|
||||
syncs them to a private Lakebed database (see
|
||||
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
||||
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
|
||||
|
||||
Generated
+3
-3
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
"electron-builder": "^26.15.3"
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Mac app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
"license": "UNLICENSED",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
|
||||
+7
-7
@@ -1,11 +1,12 @@
|
||||
# compat-db: Frame Control's compatibility database
|
||||
|
||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||
reports for Android apps on the Steam Frame. Only Frame Control can read or
|
||||
write it.
|
||||
reports for Android apps on the Steam Frame. For now only the maintainer's
|
||||
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`,
|
||||
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
|
||||
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
||||
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
|
||||
keeps dated copies in
|
||||
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
||||
When the data has changed, it also uploads them to Google Drive
|
||||
(**the backup folder**, folder
|
||||
`<drive-folder-id>`) with `gog`. The LaunchAgent
|
||||
`frame-compat-backup` runs it daily at 03:40; the log is
|
||||
When the data has changed, it also uploads them with `gog` to the Google
|
||||
Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
|
||||
`EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
|
||||
`~/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`,
|
||||
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`
|
||||
(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)).
|
||||
So we keep our own, in a private Lakebed database
|
||||
(`https://frame-compat.lakebed.app`) that only Frame Control can read or write.
|
||||
So Frame Control keeps its own. Your reports are saved on your Mac; the
|
||||
maintainer's copy also syncs them to a private Lakebed database.
|
||||
**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.
|
||||
A daily job backs it up locally and to Google Drive. See
|
||||
See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[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:
|
||||
|
||||
1. The LaunchAgent `~/Library/LaunchAgents/frame-t3-tunnel.plist`
|
||||
keeps `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running. launchd
|
||||
restarts it if it drops. Log: `~/Library/Logs/frame-t3-tunnel.log`.
|
||||
1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
|
||||
for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
|
||||
it drops.
|
||||
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
|
||||
`d0c468e3`) with `expo prebuild` and `gradlew assembleRelease
|
||||
The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
|
||||
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
||||
`android-commandlinetools` SDK. It's signed with the debug key.
|
||||
|
||||
|
||||
@@ -30,12 +30,13 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
||||
| `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` |
|
||||
| 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 |
|
||||
| `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) |
|
||||
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
|
||||
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb`, `wayvnc`. | Script design |
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
|
||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
|
||||
@@ -43,8 +44,10 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
|
||||
| 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` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs. Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). 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) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
|
||||
|
||||
## Debug recipes
|
||||
@@ -70,5 +73,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Files and clipboard: [file-transfer.md](file-transfer.md)
|
||||
- Android apps: [apks.md](apks.md)
|
||||
- Installing and buying Steam games: [steam-games.md](steam-games.md)
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
@@ -25,7 +25,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
(`vrserver`, `vrcompositor`) and `xrdp` are running.
|
||||
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
|
||||
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc`
|
||||
are **not**. `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
|
||||
are **not** (Tailscale can be added in `~`; see [tailscale.md](tailscale.md)). `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
|
||||
text correctly.
|
||||
- Flathub is already configured as a **system** remote; Chromium is the only
|
||||
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
|
||||
@@ -40,7 +40,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
||||
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
||||
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–21.
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
|
||||
## Check on the headset (in order)
|
||||
|
||||
@@ -82,8 +82,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–21.
|
||||
to type locally.
|
||||
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC)
|
||||
reach the Linux side? Does USB power from the Mac cope?
|
||||
16. **Tailscale**: can it be installed persistently (Flatpak? a
|
||||
userspace `tailscaled` in `~`?) for access off the home LAN?
|
||||
16. ~~**Tailscale**~~: answered 2026-09-25. A userspace `tailscaled` in `~`
|
||||
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
|
||||
Still open: reaching the Frame from outside the home network, and the service
|
||||
starting after a reboot.
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
||||
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
||||
World** / **Move** / **Size**? Do floating positions survive closing and
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Tailscale: use the Frame from anywhere
|
||||
|
||||
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
|
||||
so does everything built on it: Frame Control, the scripts and the Mac app.
|
||||
When the Mac and the Frame are on the same network, Tailscale connects them
|
||||
directly, so there's no relay in the way (`tailscale ping frame` → `via
|
||||
192.168.1.50:41641`, 20 ms).
|
||||
|
||||
```sh
|
||||
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
|
||||
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
|
||||
scripts/tailscale-on-frame.sh --uninstall
|
||||
```
|
||||
|
||||
## How it's installed
|
||||
|
||||
There's no Tailscale Flatpak, and the rootfs is read-only. So the script
|
||||
installs Tailscale's static arm64 build in the `steamos` user's home and runs
|
||||
`tailscaled --tun=userspace-networking` as a systemd **user** service. It
|
||||
doesn't need sudo, and SteamOS updates don't touch it.
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `~/.local/share/tailscale/<version>/` | `tailscale`, `tailscaled` (SHA-256 checked against pkgs.tailscale.com) |
|
||||
| `~/.local/share/tailscale/current` | Symlink to the active version |
|
||||
| `~/.local/share/tailscale/state/` | Node key and state |
|
||||
| `~/.local/bin/tailscale` | CLI wrapper that points at the daemon's socket (`$XDG_RUNTIME_DIR/tailscale/tailscaled.sock`) |
|
||||
| `~/.config/systemd/user/tailscaled.service` | The service |
|
||||
|
||||
Lingering (`loginctl enable-linger`) is on, so the service starts at boot
|
||||
without anyone logging in. polkit allowed that without sudo. Re-running the
|
||||
script is safe. It restarts `tailscaled` only if the version or unit changed,
|
||||
and then does so detached after 3 s, because the SSH session may itself run
|
||||
over Tailscale.
|
||||
|
||||
To update, run the script again; it installs the latest stable version. To
|
||||
manage the node, use `ssh frame '~/.local/bin/tailscale status'` (or `set`,
|
||||
`down`, `up`).
|
||||
|
||||
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926, Tailscale
|
||||
1.102.4):**
|
||||
|
||||
- First install and login approval. This ran an earlier revision of the script,
|
||||
which restarted the daemon unconditionally. The node is `frame`,
|
||||
with a 100.x.y.z tailnet address.
|
||||
- SSH works over Tailscale: the Frame serves the same ED25519 host key as it
|
||||
does on `frame.local`.
|
||||
- Frame Control's status and Get games work through the alias.
|
||||
- The current script: a re-run with nothing changed doesn't restart anything,
|
||||
and a re-run with a changed unit restarts `tailscaled` 3 s after the SSH
|
||||
session ends and then reads `Running`. The timer needs
|
||||
`AccuracySec=100ms`; the default of 1 min made it fire up to a minute late.
|
||||
The first-install guard was checked on its own.
|
||||
|
||||
**Not verified:**
|
||||
|
||||
- A clean first install and login with the current script end to end. It would
|
||||
mean removing the node from the tailnet.
|
||||
- Reaching the Frame from outside the home network. Only the direct LAN path
|
||||
was tested.
|
||||
- The service coming up after a reboot. That's **inferred** from linger plus
|
||||
`WantedBy=default.target`; the Frame hasn't been rebooted since.
|
||||
|
||||
## Exposure: every port is on the tailnet
|
||||
|
||||
In userspace mode, `tailscaled` passes inbound tailnet connections to the
|
||||
Frame's **loopback**. Any device on the tailnet can therefore reach **every**
|
||||
listening port, including ones meant to be local-only. Checked from the Mac on
|
||||
2026-09-25:
|
||||
|
||||
| Port | Service | Normally |
|
||||
|---|---|---|
|
||||
| 22 | sshd | LAN |
|
||||
| 8080 | Steam client DevTools (full control of the Steam client and account session) | loopback only |
|
||||
| 27062 | SteamVR `vrserver` | loopback only |
|
||||
| 5555 | Lepton ADB (unauthenticated shell into Android) | LAN |
|
||||
| 3389 | xrdp | LAN |
|
||||
|
||||
The user accepted this on 2026-09-25, since the tailnet only holds their own
|
||||
devices. Other options:
|
||||
|
||||
- `tailscale set --shields-up` blocks **all** inbound connections. That
|
||||
includes SSH and Tailscale SSH (`--ssh`), both checked.
|
||||
- A tailnet policy that tags the Frame (`tag:frame`) and allows only
|
||||
`tag:frame:22` keeps the other ports private. This is an admin-console
|
||||
change.
|
||||
- Kernel-mode Tailscale (a root install, e.g. systemd-sysext) wouldn't expose
|
||||
loopback-only ports, but it needs sudo and may not survive SteamOS updates.
|
||||
|
||||
If the Mac's Tailscale is off, the alias won't resolve. Use
|
||||
`scripts/connect.sh frame.local` to go back to the LAN name.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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, runs `gclient sync --no-history`,
|
||||
installs the sysroot, builds `chrome` with `symbol_level=0` and proprietary
|
||||
codecs, and packs `chromium-xr-arm64.tar.xz`. Progress is logged to
|
||||
`~/chromium-xr/stage`. The build aborts if `/` drops below 12 GB free.
|
||||
|
||||
First run: a 12-core, 31 GB x64 Linux box, started 2026-09-25.
|
||||
|
||||
## 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] # headset desktop, --enable-features=OpenXR
|
||||
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
|
||||
```
|
||||
|
||||
It runs natively, not as a Flatpak, so the XR sandbox and SteamVR's IPC work
|
||||
as the CL expects. It uses its own profile (`~/.config/chromium-xr`) and
|
||||
DevTools on loopback port 9223, so it doesn't collide with the Flatpak's 9222.
|
||||
|
||||
**Verified 2026-09-25:**
|
||||
|
||||
- Vulkan is there: Turnip (Mesa) on Adreno 750, API 1.4.359.
|
||||
- Unprivileged user namespaces work (`unshare -Ur true`), so Chromium's
|
||||
namespace sandbox shouldn't need the setuid `chrome_sandbox`.
|
||||
|
||||
**Unverified (inferred):**
|
||||
|
||||
- Chromium's GPU process may still fall back from Vulkan to GL on Turnip.
|
||||
- An immersive session started from a window on the nested desktop may not
|
||||
hand over cleanly to the SteamVR compositor.
|
||||
- If the sandbox fails to start, `--no-sandbox` is the fallback for a first
|
||||
test.
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
||||
# (8441736 + 8132979, bug 506004811) so WebXR immersive-vr works on the Frame.
|
||||
# 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
|
||||
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
+98
@@ -0,0 +1,98 @@
|
||||
#!/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 in the headset desktop
|
||||
# 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)
|
||||
# run-on-frame starts in $HOME on the Frame, so the profile path is relative.
|
||||
exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \
|
||||
--user-data-dir=.config/chromium-xr \
|
||||
--enable-features=OpenXR \
|
||||
--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
|
||||
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
|
||||
# 60), and uploads to Google Drive (the backup folder) when
|
||||
# the data changed since the last upload. Run daily by the LaunchAgent
|
||||
# frame-compat-backup (see docs/apks.md).
|
||||
# 60), and uploads to the Google Drive folder DRIVE_FOLDER_ID when the data
|
||||
# changed since the last upload. Maintainer-only: it needs the database key.
|
||||
# Run it daily from a LaunchAgent (see compat-db/README.md).
|
||||
#
|
||||
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
|
||||
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
|
||||
@@ -14,8 +14,8 @@ set -euo pipefail
|
||||
|
||||
ROOT="${0:A:h}/.."
|
||||
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
|
||||
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-<drive-folder-id>}
|
||||
GOG_WRAPPER=${GOG_WRAPPER:-$HOME/bin/gog-with-keyring.sh}
|
||||
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-}
|
||||
GOG_WRAPPER=${GOG_WRAPPER:-$(command -v gog || true)}
|
||||
upload=1 force=0 accept_shrink=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
@@ -60,9 +60,10 @@ if (( upload )); then
|
||||
print "==> Unchanged since the last Drive upload; skipped"
|
||||
exit 0
|
||||
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.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
|
||||
print -r -- "$digest" > "$last"
|
||||
print "==> Uploaded to Google Drive (the backup folder)"
|
||||
print "==> Uploaded to Google Drive"
|
||||
fi
|
||||
@@ -25,7 +25,7 @@ PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/
|
||||
launch=0 list=0
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
||||
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
|
||||
--launch) launch=1; shift ;;
|
||||
--list) list=1; shift ;;
|
||||
--) shift; break ;;
|
||||
@@ -33,21 +33,25 @@ while (( $# )); do
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
(( $# || launch || list )) || { sed -n '2,18p' "$0"; exit 2; }
|
||||
(( $# || launch || list )) || { sed -n '2,17p' "$0" >&2; exit 2; }
|
||||
|
||||
for f in "$@"; do
|
||||
[[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
|
||||
done
|
||||
|
||||
# Create the folder and link it into DeoVR's prefix (the prefix exists once
|
||||
# DeoVR has run). Never replace a real directory that's already there.
|
||||
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
|
||||
# DeoVR has run). Refresh a stale link, but never replace a real directory.
|
||||
if (( $# || launch )); then
|
||||
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
|
||||
p=~/$PREFIX_VIDEOS
|
||||
if [ -d \"\$p\" ] && [ ! -e \"\$p/VR\" ]; then ln -s ~/$REMOTE_DIR \"\$p/VR\"; fi
|
||||
if [ -d \"\$p\" ] && { [ -L \"\$p/VR\" ] || [ ! -e \"\$p/VR\" ]; }; then ln -sfn ~/$REMOTE_DIR \"\$p/VR\"
|
||||
elif [ -d \"\$p/VR\" ]; then echo \"warning: \$p/VR is a real folder, so uploads won't show under DeoVR's Videos; browse Z:\\\\home\\\\steamos\\\\Videos\\\\VR instead\" >&2; fi
|
||||
[ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2"
|
||||
fi
|
||||
|
||||
if (( $# )); then
|
||||
rsync -a --partial --progress "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
|
||||
# -L: send what a symlink points at; a Mac-side link would dangle on the Frame
|
||||
rsync -aL --partial --progress -- "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
|
||||
fi
|
||||
|
||||
if (( list )); then
|
||||
@@ -55,6 +59,7 @@ if (( list )); then
|
||||
fi
|
||||
|
||||
if (( launch )); then
|
||||
ssh "$FRAME_ALIAS" "steam steam://rungameid/$DEOVR_APPID >/dev/null 2>&1 &"
|
||||
ssh "$FRAME_ALIAS" "command -v steam >/dev/null || { echo 'steam not found on the Frame' >&2; exit 1; }
|
||||
steam steam://rungameid/$DEOVR_APPID </dev/null >/dev/null 2>&1 &"
|
||||
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
|
||||
fi
|
||||
@@ -10,6 +10,11 @@
|
||||
# ~/.config/systemd/user/tailscaled.service
|
||||
# `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root.
|
||||
#
|
||||
# Exposure: in userspace mode tailscaled forwards inbound tailnet connections
|
||||
# to the Frame's loopback, so EVERY port is reachable from the tailnet,
|
||||
# including localhost-only ones (Steam's DevTools on 8080, SteamVR, ADB).
|
||||
# `tailscale set --shields-up` blocks all inbound (SSH too). See docs/tailscale.md.
|
||||
#
|
||||
# Usage: scripts/tailscale-on-frame.sh [--version X.Y.Z] [--hostname NAME]
|
||||
# scripts/tailscale-on-frame.sh --uninstall
|
||||
# The first run prints a login URL (and opens it on the Mac) to add the Frame
|
||||
@@ -20,10 +25,10 @@ FRAME=${FRAME_ALIAS:-frame}
|
||||
version="" hostname="frame" uninstall=0
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--version) version=$2; shift ;;
|
||||
--hostname) hostname=$2; shift ;;
|
||||
--version) version=${2:?--version needs a value}; shift ;;
|
||||
--hostname) hostname=${2:?--hostname needs a value}; shift ;;
|
||||
--uninstall) uninstall=1 ;;
|
||||
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
|
||||
-h|--help) sed -n "2,21p" "$0"; exit 0 ;;
|
||||
*) print -u2 "unknown argument: $1"; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
@@ -33,13 +38,21 @@ done
|
||||
if (( uninstall )); then
|
||||
ssh "$FRAME" 'set -e
|
||||
systemctl --user disable --now tailscaled.service 2>/dev/null || true
|
||||
rm -f ~/.config/systemd/user/tailscaled.service ~/.local/bin/tailscale ~/.local/bin/tailscaled
|
||||
rm -f ~/.config/systemd/user/tailscaled.service ~/.local/bin/tailscale
|
||||
systemctl --user daemon-reload
|
||||
echo "Removed the service and wrappers. Binaries and node state are still in ~/.local/share/tailscale;"
|
||||
echo "delete that folder and remove the machine in the Tailscale admin console to finish."'
|
||||
echo "Removed the service and the CLI wrapper. Binaries and node state are still in"
|
||||
echo "~/.local/share/tailscale; delete that folder and remove the machine in the"
|
||||
echo "Tailscale admin console to finish. Linger stays on (loginctl disable-linger to undo)."'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# BackendState of the Frame's tailscaled (Running, NeedsLogin, Stopped, …), or
|
||||
# Unreachable when the probe itself fails (SSH down, daemon restarting).
|
||||
ts_state() {
|
||||
ssh -o ConnectTimeout=10 "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null |
|
||||
python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])"' 2>/dev/null || print Unreachable
|
||||
}
|
||||
|
||||
if [[ -z $version ]]; then
|
||||
version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" |
|
||||
python3 -c 'import json,sys; print(json.load(sys.stdin)["TarballsVersion"])')
|
||||
@@ -47,11 +60,13 @@ fi
|
||||
[[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { print -u2 "bad version: $version"; exit 2; }
|
||||
print "==> Installing Tailscale $version on $FRAME (userspace networking)"
|
||||
|
||||
ssh "$FRAME" "VERSION=$version HOSTNAME_TS=$hostname sh -s" <<'REMOTE'
|
||||
remote_out=$(ssh "$FRAME" "VERSION=$version sh -s" <<'REMOTE'
|
||||
set -eu
|
||||
base="$HOME/.local/share/tailscale"
|
||||
dir="$base/$VERSION"
|
||||
tgz="tailscale_${VERSION}_arm64.tgz"
|
||||
unit="$HOME/.config/systemd/user/tailscaled.service"
|
||||
sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock"
|
||||
mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
||||
|
||||
if [ ! -x "$dir/tailscaled" ]; then
|
||||
@@ -59,12 +74,15 @@ if [ ! -x "$dir/tailscaled" ]; then
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz"
|
||||
want=$(curl -fsSL "https://pkgs.tailscale.com/stable/$tgz.sha256" | cut -d' ' -f1)
|
||||
[ -n "$want" ] || { echo "couldn't fetch $tgz.sha256" >&2; exit 1; }
|
||||
got=$(sha256sum "$tmp/$tgz" | cut -d' ' -f1)
|
||||
[ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; }
|
||||
tar -xzf "$tmp/$tgz" -C "$tmp"
|
||||
mkdir -p "$dir"
|
||||
mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/"
|
||||
fi
|
||||
# Neither exists on a first install; don't let that trip set -e.
|
||||
before=$({ readlink "$base/current"; cat "$unit"; } 2>/dev/null || true)
|
||||
ln -sfn "$dir" "$base/current"
|
||||
|
||||
# The CLI looks for the daemon at /var/run/tailscale by default; point it at ours.
|
||||
@@ -74,7 +92,7 @@ exec "$HOME/.local/share/tailscale/current/tailscale" --socket="${XDG_RUNTIME_DI
|
||||
EOF
|
||||
chmod +x "$HOME/.local/bin/tailscale"
|
||||
|
||||
cat > "$HOME/.config/systemd/user/tailscaled.service" <<'EOF'
|
||||
cat > "$unit" <<'EOF'
|
||||
[Unit]
|
||||
Description=Tailscale (userspace networking, no root)
|
||||
After=network-online.target
|
||||
@@ -88,39 +106,74 @@ RestartSec=5
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
# Linger starts user services at boot, before anyone logs in; polkit allows it without sudo.
|
||||
loginctl enable-linger 2>/dev/null || echo "note: couldn't enable linger; tailscaled starts when the session does" >&2
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable tailscaled.service >/dev/null 2>&1
|
||||
systemctl --user restart tailscaled.service
|
||||
after=$(readlink "$base/current"; cat "$unit")
|
||||
restart=0
|
||||
if systemctl --user is-active --quiet tailscaled.service; then
|
||||
[ "$before" = "$after" ] || restart=1
|
||||
else
|
||||
systemctl --user start tailscaled.service
|
||||
fi
|
||||
for i in $(seq 1 50); do
|
||||
[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock" ] && break
|
||||
[ -S "$sock" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
"$HOME/.local/bin/tailscale" version | head -n 1
|
||||
[ -S "$sock" ] || { echo "tailscaled didn't open $sock; see: journalctl --user -u tailscaled" >&2; exit 1; }
|
||||
v=$("$HOME/.local/bin/tailscale" version)
|
||||
printf 'Tailscale %s\n' "$(printf '%s\n' "$v" | head -n 1)"
|
||||
if [ "$restart" = 1 ]; then
|
||||
# This SSH session may itself run over Tailscale, so restart detached, after
|
||||
# it has ended; the Mac waits and reconnects.
|
||||
systemd-run --user --quiet --on-active=3 --timer-property=AccuracySec=100ms --unit=tailscaled-restart --collect \
|
||||
systemctl --user restart tailscaled.service >/dev/null
|
||||
echo "RESTART_SCHEDULED"
|
||||
fi
|
||||
REMOTE
|
||||
)
|
||||
print -r -- "${remote_out//RESTART_SCHEDULED/Restarting tailscaled for the new version or unit…}"
|
||||
|
||||
# `up` blocks until the login is approved, so run it in the background on the
|
||||
# Frame and fetch the URL from its log.
|
||||
state=$(ssh "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])" 2>/dev/null || echo Unknown')
|
||||
if [[ $state != Running ]]; then
|
||||
ssh "$FRAME" "nohup ~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1 &"
|
||||
url=""
|
||||
for i in {1..40}; do
|
||||
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log | head -n 1' || true)
|
||||
[[ -n $url ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ -n $url ]]; then
|
||||
# Wait out a scheduled restart, then read a definite state.
|
||||
[[ $remote_out == *RESTART_SCHEDULED* ]] && sleep 6
|
||||
state=""
|
||||
for i in {1..30}; do
|
||||
state=$(ts_state)
|
||||
[[ $state == (Running|NeedsLogin|NeedsMachineAuth|Stopped|NoState) ]] && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
case $state in
|
||||
Running) ;;
|
||||
NeedsLogin|Stopped|NoState)
|
||||
# `up` blocks until the login is approved, so run it as its own transient
|
||||
# unit (it outlives this SSH session) and fetch the URL from its log.
|
||||
ssh "$FRAME" "rm -f /tmp/tailscale-up.log; systemd-run --user --quiet --collect --unit=tailscale-up-\$\$ \
|
||||
sh -c '~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1' >/dev/null"
|
||||
url=""
|
||||
for i in {1..40}; do
|
||||
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log 2>/dev/null | head -n 1' || true)
|
||||
[[ -n $url ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
[[ -n $url ]] || { print -u2 "No login URL after 20 s; see /tmp/tailscale-up.log on the Frame."; exit 1; }
|
||||
print "==> Approve the Frame in your tailnet: $url"
|
||||
open "$url" 2>/dev/null || true
|
||||
print " Waiting for approval (up to 10 minutes)…"
|
||||
for i in {1..300}; do
|
||||
state=$(ssh "$FRAME" '~/.local/bin/tailscale status --json 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[\"BackendState\"])"' || true)
|
||||
state=$(ts_state)
|
||||
[[ $state == Running ]] && break
|
||||
sleep 2
|
||||
done
|
||||
else
|
||||
print -u2 "No login URL yet; see /tmp/tailscale-up.log on the Frame."
|
||||
fi
|
||||
fi
|
||||
[[ $state == Running ]] || { print -u2 "Not approved yet (state: $state). Re-run to get a new URL."; exit 1; }
|
||||
;;
|
||||
NeedsMachineAuth) print -u2 "Logged in; approve the device in the Tailscale admin console, then re-run."; exit 1 ;;
|
||||
*) print -u2 "Couldn't read tailscaled's state (last: $state). Check: ssh $FRAME 'journalctl --user -u tailscaled'"; exit 1 ;;
|
||||
esac
|
||||
|
||||
ssh "$FRAME" '~/.local/bin/tailscale status --self --peers=false; printf "Tailscale IP: "; ~/.local/bin/tailscale ip -4'
|
||||
name=$(ssh "$FRAME" '~/.local/bin/tailscale status --json' | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')
|
||||
print "==> To use the Frame from anywhere, point the alias at Tailscale:"
|
||||
print " ssh-keyscan -t ed25519 $name >> ~/.ssh/known_hosts # after checking it matches"
|
||||
print " scripts/connect.sh $name"
|
||||
@@ -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()
|
||||
@@ -15,6 +15,7 @@ import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -81,6 +82,9 @@ class ServerGuards(unittest.TestCase):
|
||||
# <img src> and plain form posts from other sites can't set it.
|
||||
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/shots")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
||||
|
||||
def test_captures_are_not_cacheable(self):
|
||||
@@ -98,11 +102,26 @@ class ServerGuards(unittest.TestCase):
|
||||
("/api/volume", {"level": 1.5}),
|
||||
("/api/clipboard", {"text": ""}),
|
||||
("/api/open", {"what": "anything-else"}),
|
||||
("/api/shots/save", {"ids": []}),
|
||||
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||
("/api/shots/save", {"ids": [1]}),
|
||||
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
|
||||
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
|
||||
]
|
||||
for path, body in cases:
|
||||
status, payload = self.post(path, body)
|
||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||
|
||||
def test_screenshot_ids_checked_before_ssh(self):
|
||||
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
|
||||
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||
self.assertEqual(status, 400, shot)
|
||||
|
||||
def test_stream_settings_checked_before_ssh(self):
|
||||
for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"):
|
||||
status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"})
|
||||
self.assertEqual(status, 400, query)
|
||||
|
||||
def test_bad_bodies(self):
|
||||
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"})
|
||||
|
||||
+15
-2
@@ -40,6 +40,16 @@ def key():
|
||||
return p.stdout.strip()
|
||||
|
||||
|
||||
def shared():
|
||||
"""Whether reports reach the shared database. Without the key (anyone but the
|
||||
maintainer), reports stay in this Mac's outbox and ratings come from the catalogue."""
|
||||
try:
|
||||
key()
|
||||
return True
|
||||
except DBError:
|
||||
return False
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Never follow redirects: urllib would copy the key header to the new host."""
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
@@ -169,6 +179,8 @@ def load():
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
if not shared():
|
||||
raise DBError('no key')
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
@@ -196,8 +208,9 @@ def add(report):
|
||||
with _lock, open(OUTBOX, 'a') as f:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + '\n')
|
||||
try:
|
||||
flush()
|
||||
_mem['at'] = 0 # refetch on next load
|
||||
if shared():
|
||||
flush()
|
||||
_mem['at'] = 0 # refetch on next load
|
||||
except Exception:
|
||||
pass # stays queued; load() shows it and a later call sends it
|
||||
return r
|
||||
|
||||
+244
-18
@@ -152,6 +152,17 @@
|
||||
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
|
||||
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---- Steam screenshots from the headset ---- */
|
||||
.shot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
|
||||
.shot-card { display: flex; flex-direction: column; gap: 6px; }
|
||||
.shot-card .thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: rgba(0,0,0,.3);
|
||||
display: block; cursor: zoom-in; box-shadow: 0 6px 16px rgba(0,0,0,.45); }
|
||||
.shot-card .thumb:hover { box-shadow: 0 6px 16px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.25); }
|
||||
.shot-card .row { flex-wrap: nowrap; }
|
||||
.shot-card .grow { flex: 1; min-width: 0; }
|
||||
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.shot-card .s { color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
||||
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||
.capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
|
||||
@@ -279,6 +290,7 @@
|
||||
</a>
|
||||
<nav id="nav">
|
||||
<a href="#view" class="on">View</a>
|
||||
<a href="#shots">Shots</a>
|
||||
<a href="#library">Library</a>
|
||||
<a href="#getgames">Games</a>
|
||||
<a href="#android">Android</a>
|
||||
@@ -309,7 +321,7 @@
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="viewer" id="viewer">
|
||||
@@ -369,6 +381,16 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section id="shots">
|
||||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="shotsRefresh">Refresh</button>
|
||||
<button class="small" id="shotsFolder" title="Open ~/Pictures/SteamFrame">Show in Finder</button>
|
||||
<button class="action small" id="shotsSaveNew" disabled>Save new to Mac</button>
|
||||
</div>
|
||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||||
</section>
|
||||
|
||||
<section id="library">
|
||||
<div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div>
|
||||
<div class="shelf" id="games"><div class="sub">Loading…</div></div>
|
||||
@@ -412,8 +434,8 @@
|
||||
<div class="panel">
|
||||
<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="hint">Reports go to Frame Control's private compatibility database and change the
|
||||
verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid.</div>
|
||||
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
|
||||
reported, including ones not on F-Droid.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
@@ -553,12 +575,12 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
|
||||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||||
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.",
|
||||
};
|
||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel" };
|
||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
|
||||
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let lastImg = null, lastSource = null;
|
||||
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||||
|
||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
|
||||
@@ -729,10 +751,12 @@ function render(s) {
|
||||
|
||||
// ---- view ----
|
||||
function setView(v) {
|
||||
const changed = v !== view;
|
||||
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];
|
||||
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
|
||||
}
|
||||
function setEye(e) {
|
||||
eye = e;
|
||||
@@ -792,7 +816,7 @@ document.addEventListener("keydown", e => {
|
||||
function draw() {
|
||||
const img = lastImg, c = $("canvas");
|
||||
// Side-by-side stereo captures: crop to the left half for "one eye".
|
||||
const sbs = lastSource !== "gamescope" && img.naturalWidth >= img.naturalHeight * 1.5;
|
||||
const sbs = lastSource === "steamvr" && img.naturalWidth >= img.naturalHeight * 1.5;
|
||||
const crop = sbs && eye === "left";
|
||||
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
|
||||
if (c.width !== sw || c.height !== sh) {
|
||||
@@ -819,6 +843,8 @@ function isBlank(ctx, w, h) {
|
||||
return max - min < 6;
|
||||
}
|
||||
async function capture() {
|
||||
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
|
||||
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
||||
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
||||
let url = null;
|
||||
try {
|
||||
@@ -829,7 +855,8 @@ async function capture() {
|
||||
url = URL.createObjectURL(await r.blob());
|
||||
const img = new Image();
|
||||
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||||
lastImg = img; lastSource = source;
|
||||
if (gen !== viewGen) return true;
|
||||
lastImg = img; lastSource = source; lastShot = null;
|
||||
draw();
|
||||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||||
$("saveBtn").disabled = false;
|
||||
@@ -859,18 +886,129 @@ function toggleLive(on) {
|
||||
liveFailures = 0;
|
||||
$("liveBtn").classList.toggle("on", 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();
|
||||
$("liveBtn").onclick = () => toggleLive(!live);
|
||||
$("saveBtn").onclick = () => $("canvas").toBlob(b => {
|
||||
$("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : $("canvas").toBlob(b => {
|
||||
if (!b) return toast("Couldn't encode the image", true);
|
||||
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||||
}, "image/png");
|
||||
function download(blob, name) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}, "image/png");
|
||||
}
|
||||
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
|
||||
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
|
||||
|
||||
@@ -1318,9 +1456,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)" };
|
||||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||||
async function loadReports() {
|
||||
let reps;
|
||||
try { reps = (await api("/api/android/reports")).reports; }
|
||||
let reps, shared;
|
||||
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||||
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 Mac and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||
const k = r.rating || r.result;
|
||||
@@ -1382,15 +1523,100 @@ $("repForm").onsubmit = async e => {
|
||||
};
|
||||
loadReports();
|
||||
|
||||
// ---- Steam screenshots from the headset ----
|
||||
const shots = { list: [], urls: [] };
|
||||
const STEAMVR_APPID = "250820";
|
||||
function shotApp(appid) {
|
||||
if (appid === STEAMVR_APPID) return "SteamVR";
|
||||
const g = state?.games?.find(x => x.appid === appid);
|
||||
return g ? g.name : `App ${appid}`;
|
||||
}
|
||||
async function shotBlob(id, thumb) {
|
||||
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
|
||||
{ headers: {"X-Frame-UI": "1"} });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
}
|
||||
async function loadShots() {
|
||||
$("shotsRefresh").disabled = true;
|
||||
try {
|
||||
shots.list = (await api("/api/shots")).shots;
|
||||
} catch (e) {
|
||||
$("shotGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`;
|
||||
return;
|
||||
} finally { $("shotsRefresh").disabled = false; }
|
||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
||||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this Mac` : "") : "";
|
||||
$("shotsSaveNew").disabled = !unsaved;
|
||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
||||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
||||
<div class="row"><div class="grow">
|
||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||||
${s.saved ? `<span class="tag">On Mac</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||
</div></div>`).join("")
|
||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||
// Thumbnails one at a time over the shared SSH connection.
|
||||
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
|
||||
const s = shots.list[+img.dataset.shot];
|
||||
try {
|
||||
const url = URL.createObjectURL(await shotBlob(s.id, true));
|
||||
shots.urls.push(url);
|
||||
img.src = url;
|
||||
} catch (e) { img.alt = "Preview failed"; }
|
||||
if (!img.isConnected) return; // the list was reloaded meanwhile
|
||||
}
|
||||
}
|
||||
async function openShot(s) {
|
||||
if (live) toggleLive(false);
|
||||
const gen = ++viewGen;
|
||||
$("viewer").classList.add("busy");
|
||||
let url = null;
|
||||
try {
|
||||
const blob = await shotBlob(s.id, false);
|
||||
url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||||
if (gen !== viewGen) return;
|
||||
lastImg = img; lastSource = "shot"; lastShot = { blob, file: s.file };
|
||||
draw();
|
||||
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
||||
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
||||
$("saveBtn").disabled = false;
|
||||
$("view").scrollIntoView({ behavior: "smooth" });
|
||||
} catch (e) {
|
||||
toast("Couldn't open the screenshot: " + e.message, true);
|
||||
} finally {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
$("viewer").classList.remove("busy");
|
||||
}
|
||||
}
|
||||
async function saveShots(list, btn) {
|
||||
if (!list.length) return;
|
||||
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||||
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||||
if (res) loadShots();
|
||||
}
|
||||
$("shotGrid").onclick = e => {
|
||||
const img = e.target.closest("img[data-shot]");
|
||||
if (img) return openShot(shots.list[+img.dataset.shot]);
|
||||
const b = e.target.closest("[data-shot-save]");
|
||||
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
||||
};
|
||||
$("shotsRefresh").onclick = loadShots;
|
||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||
$("shotsFolder").onclick = e => act("Show in Finder", () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||
|
||||
// ---- nav highlight follows scroll ----
|
||||
const spy = new IntersectionObserver(entries => {
|
||||
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id));
|
||||
}, { rootMargin: "-80px 0px -55% 0px" });
|
||||
["view", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
|
||||
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
|
||||
|
||||
setView("headset");
|
||||
refresh();
|
||||
refresh().then(loadShots); // after status, so app names resolve
|
||||
setInterval(() => { if (!document.hidden) refresh(); }, 30000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+198
-5
@@ -12,6 +12,7 @@ import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
@@ -189,6 +190,124 @@ def headset_view():
|
||||
pass # frame_vrshot.py sweeps leftovers on the next capture
|
||||
|
||||
|
||||
# Screenshots taken in the headset with Steam's shortcut. Steam files each under the app
|
||||
# it was taken in: userdata/<account>/760/remote/<appid>/screenshots/<file>,
|
||||
# with a smaller copy in screenshots/thumbnails/. A shot's id is
|
||||
# "<account>/<appid>/<file>", checked here before it goes near a shell.
|
||||
SHOT_ROOT = ".local/share/Steam/userdata"
|
||||
SHOT_ID = re.compile(r"(\d{1,12})/(\d{1,20})/(\d{14}_\d{1,4}\.(?:jpg|png))")
|
||||
SHOTS_DIR = Path.home() / "Pictures" / "SteamFrame"
|
||||
LIST_SHOTS = f"""cd ~/{SHOT_ROOT} 2>/dev/null || exit 0
|
||||
find . -mindepth 6 -maxdepth 6 -path './*/760/remote/*/screenshots/*' -type f \\
|
||||
\\( -name '*.jpg' -o -name '*.png' \\) -printf '%P\\t%s\\t%T@\\n'"""
|
||||
|
||||
|
||||
def shot_path(shot_id, thumb=False):
|
||||
m = SHOT_ID.fullmatch(shot_id) if isinstance(shot_id, str) else None
|
||||
if not m:
|
||||
raise Failure("bad screenshot id", 400)
|
||||
return f"{SHOT_ROOT}/{m[1]}/760/remote/{m[2]}/screenshots/{'thumbnails/' if thumb else ''}{m[3]}"
|
||||
|
||||
|
||||
def list_shots():
|
||||
shots = []
|
||||
for line in ssh(LIST_SHOTS, timeout=20).splitlines():
|
||||
rel, _, rest = line.partition("\t")
|
||||
parts = rel.split("/") # account/760/remote/appid/screenshots/file
|
||||
size, _, mtime = rest.partition("\t")
|
||||
shot_id = f"{parts[0]}/{parts[3]}/{parts[-1]}" if len(parts) == 6 else ""
|
||||
if not SHOT_ID.fullmatch(shot_id) or not size.isdigit():
|
||||
continue
|
||||
try:
|
||||
when = float(mtime)
|
||||
except ValueError:
|
||||
continue
|
||||
local = SHOTS_DIR / parts[-1]
|
||||
shots.append({"id": shot_id, "appid": parts[3], "file": parts[-1], "size": int(size), "time": when,
|
||||
"saved": local.exists() and local.stat().st_size == int(size)})
|
||||
shots.sort(key=lambda s: s["time"], reverse=True)
|
||||
return {"shots": shots, "folder": str(SHOTS_DIR)}
|
||||
|
||||
|
||||
def shot_image(query):
|
||||
q = parse_qs(query)
|
||||
shot_id = (q.get("id") or [""])[0]
|
||||
full = shot_path(shot_id)
|
||||
if q.get("thumb") == ["1"]:
|
||||
# Steam writes the thumbnail a moment after the shot; fall back to the full image.
|
||||
thumb = shot_path(shot_id, thumb=True)
|
||||
remote = f"if [ -s {thumb} ]; then cat {thumb}; else cat {full}; fi"
|
||||
else:
|
||||
remote = f"cat {full}"
|
||||
ctype = "image/png" if shot_id.endswith(".png") else "image/jpeg"
|
||||
return ssh(remote, timeout=30, text=False), ctype
|
||||
|
||||
|
||||
def save_shots(body):
|
||||
"""Copy screenshots to ~/Pictures/SteamFrame, skipping ones already there."""
|
||||
ids = body.get("ids")
|
||||
if not isinstance(ids, list) or not 0 < len(ids) <= 1000:
|
||||
raise Failure("ids must be a list of 1-1000 screenshot ids", 400)
|
||||
paths = [shot_path(i) for i in ids]
|
||||
todo = [p for p in paths if not (SHOTS_DIR / p.rsplit("/", 1)[-1]).exists()]
|
||||
if todo:
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ensure_master()
|
||||
# Copy into a hidden folder and move complete files in, so a cut-off
|
||||
# copy never looks saved. -p keeps the time the shot was taken.
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||
try:
|
||||
try:
|
||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure("Copying screenshots timed out")
|
||||
if r.returncode != 0:
|
||||
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
|
||||
for f in incoming.iterdir():
|
||||
os.replace(f, SHOTS_DIR / f.name)
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
n, skipped = len(todo), len(ids) - len(todo)
|
||||
msg = f"Saved {n} screenshot{'s' * (n != 1)} to ~/Pictures/SteamFrame"
|
||||
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
||||
|
||||
|
||||
# Live video of the headset view. SteamVR's steamvr-v4l2cam.service copies the
|
||||
# headset view (one undistorted 1920x1080 image) into the v4l2loopback device
|
||||
# /dev/video99. ffmpeg encodes it with x264 (the hardware encoder crashes
|
||||
# ffmpeg) and the raw H.264 comes back over SSH for the page to decode with
|
||||
# WebCodecs. An access unit delimiter starts every frame so the page can split
|
||||
# the stream, and repeated SPS/PPS let it start at any keyframe. ffmpeg runs in
|
||||
# the background while the shell waits for our stdin to close: when the local
|
||||
# ssh goes, the channel closes and the shell kills ffmpeg, even one that has
|
||||
# stopped writing (and so would never get SIGPIPE).
|
||||
STREAM_DEVICE = "/dev/video99"
|
||||
STREAM_HEIGHTS = (720, 1080)
|
||||
STREAM_FPS = (30, 60)
|
||||
STREAM_STALL = 10 # seconds without video before the stream is dropped
|
||||
_stream_lock = threading.Lock()
|
||||
_stream_proc = None
|
||||
|
||||
|
||||
def stream_command(query):
|
||||
q = parse_qs(query)
|
||||
try:
|
||||
height = int((q.get("h") or ["720"])[0])
|
||||
fps = int((q.get("fps") or ["30"])[0])
|
||||
except ValueError:
|
||||
raise Failure("h and fps must be integers", 400)
|
||||
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
|
||||
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
|
||||
rate = 3 if height == 720 else 6 # Mbit/s
|
||||
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
|
||||
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
|
||||
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
|
||||
f"-g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
|
||||
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
|
||||
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
|
||||
|
||||
|
||||
def launch(body):
|
||||
appid = str(body.get("appid", ""))
|
||||
if not APPID.match(appid):
|
||||
@@ -287,6 +406,10 @@ def open_thing(body):
|
||||
if what == "sftp":
|
||||
terminal(f"sftp {alias}")
|
||||
return {"message": "Opened an SFTP session in Terminal"}
|
||||
if what == "shots":
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["open", str(SHOTS_DIR)])
|
||||
return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
|
||||
raise Failure("unknown target", 400)
|
||||
|
||||
|
||||
@@ -315,7 +438,8 @@ def android(body):
|
||||
runtime=body.get("runtime") or "instance",
|
||||
label=body.get("label"), source=body.get("source"))
|
||||
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 Mac"
|
||||
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
raise Failure("unknown action", 400)
|
||||
@@ -336,7 +460,7 @@ FONT_RANGE = (0.5, 2.0)
|
||||
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.
|
||||
_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():
|
||||
@@ -613,7 +737,7 @@ def android_display(body):
|
||||
|
||||
|
||||
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing}
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots}
|
||||
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
@@ -670,7 +794,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/android/displays":
|
||||
self.send_json(android_displays())
|
||||
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":
|
||||
self.send_json({"apps": frame_catalog.catalog()})
|
||||
elif path == "/api/status":
|
||||
@@ -679,6 +804,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(steam_frame("owned"))
|
||||
elif path == "/api/steam/search":
|
||||
self.send_json(steam_search(url.query))
|
||||
elif path == "/api/shots":
|
||||
self.send_json(list_shots())
|
||||
elif path == "/api/shots/image":
|
||||
self.send_bytes(*shot_image(url.query))
|
||||
elif path == "/api/stream":
|
||||
self.stream_video(url.query)
|
||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
||||
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
||||
elif path == "/api/screenshot":
|
||||
@@ -688,6 +819,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Failure as e:
|
||||
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:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
@@ -714,9 +847,69 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
except (ValueError, TypeError) as e:
|
||||
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:
|
||||
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()
|
||||
fd = proc.stdout.fileno()
|
||||
# Nothing is sent until the first bytes arrive, so a failure to
|
||||
# start still comes back as a JSON error.
|
||||
ready, _, _ = select.select([fd], [], [], 20)
|
||||
first = os.read(fd, 1 << 16) if ready else b""
|
||||
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.
|
||||
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
|
||||
chunk = os.read(fd, 1 << 16) if ready else b""
|
||||
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):
|
||||
"""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", "")))
|
||||
@@ -780,7 +973,7 @@ def main():
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
||||
if _master and _master.poll() is None:
|
||||
_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:
|
||||
proc.terminate()
|
||||
|
||||
|
||||
Reference in new issue
Block a user