Address review findings in the app, server and tests

- app: startup shell, python and ssh probes run asynchronously so a slow
  shell profile can't freeze the window; PATH comes from the user's real
  login shell and a failed lookup isn't cached; a server that never
  answers is killed; the setup offer runs once per launch, only after the
  UI loads, and decides from HostName alone; connect.sh is started through
  `env ... zsh` so it works whatever the login shell is.
- server: volume validates the level before muting or changing anything.
- Steam: null-safe install-manager fields, http.client errors caught in
  store ratings, price fallback when a sale has no final price.
- tests: server output kept for diagnosis, any startup error retried, and
  captures asserted non-cacheable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-25 22:44:54 +10:00
1 parent d4486a7681
commit 99653151c4
13 files changed
+340 -38

No files matched your search

+2 -1
View File
@@ -171,7 +171,7 @@ npm start # run from the checkout without packaging
Open the DMG and drag **Frame Control** to Applications. You need `python3` on
the Mac (Xcode Command Line Tools or Homebrew). The app reads `PATH` from your
login shell, so Homebrew's `rsync` and `adb` work when you launch it from
Finder. On first launch, if there's no `frame` SSH alias yet, the app offers
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`
@@ -200,6 +200,7 @@ showed live status and the library.
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
| `scripts/compat-db-backup.sh` | Mac | Back up the compatibility database locally and to Google Drive (daily LaunchAgent) (**verified**) |
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](docs/vr-video.md) |
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
+33 -24
View File
@@ -2,13 +2,16 @@
// shows it in a native window. The server does all the work over the `frame`
// SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, dialog, shell } = require("electron");
const { execFile, execFileSync, spawn } = require("child_process");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const run = promisify(execFile);
// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const SERVER = path.join(ROOT, "ui", "server.py");
@@ -25,29 +28,31 @@ let quitting = false;
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
// Homebrew's python3, rsync and adb. Take PATH from the login shell instead.
// Runs asynchronously so a slow shell profile can't freeze the window.
let cachedPath = null;
function loginPath() {
async function loginPath() {
if (cachedPath) return cachedPath;
const shellPath = process.env.SHELL || "/bin/zsh";
const shellPath = os.userInfo().shell || process.env.SHELL || "/bin/zsh";
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
let fromShell = "";
try {
const out = execFileSync(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
fromShell = (out.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
{ encoding: "utf8", timeout: 5000 });
fromShell = (stdout.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
} catch {}
const parts = [...fromShell.split(":"), ...(process.env.PATH || "").split(":"), ...extra];
cachedPath = [...new Set(parts.filter(Boolean))].join(":");
return cachedPath;
const joined = [...new Set(parts.filter(Boolean))].join(":");
if (fromShell) cachedPath = joined; // retry next time if the shell didn't answer
return joined;
}
function findPython(env) {
async function findPython(env) {
for (const dir of env.PATH.split(":")) {
const p = path.join(dir, "python3");
try {
fs.accessSync(p, fs.constants.X_OK);
// /usr/bin/python3 is a stub until the Command Line Tools are installed.
execFileSync(p, ["-c", "import http.server"], { timeout: 10000, stdio: "ignore", env });
await run(p, ["-c", "import http.server"], { timeout: 10000, env });
return p;
} catch {}
}
@@ -75,8 +80,8 @@ function ping(target) {
}
async function startServer() {
const env = { ...process.env, PATH: loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
const python = findPython(env);
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
const python = await findPython(env);
if (!python) {
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
@@ -106,6 +111,8 @@ async function startServer() {
if (await ping(target)) { url = target; return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
child.kill("SIGTERM");
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
}
@@ -149,32 +156,34 @@ async function load() {
const gen = ++loadGen;
try {
if (!url) await startServer();
if (gen === loadGen && win) await win.loadURL(url);
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
}
}
// `ssh -G` prints the effective config. An alias nobody configured keeps its
// own name as HostName and the Mac user as User; connect.sh sets both.
function aliasConfigured(env) {
// own name as HostName; connect.sh always writes a HostName.
async function aliasConfigured(env) {
try {
const out = execFileSync("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env,
stdio: ["ignore", "pipe", "ignore"] });
const get = (k) => (out.match(new RegExp(`^${k} (.*)$`, "m")) || [])[1];
return get("hostname") !== FRAME || get("user") !== os.userInfo().username;
const { stdout } = await run("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env });
return (stdout.match(/^hostname (.*)$/m) || [])[1] !== FRAME;
} catch {
return true; // can't tell; don't nag
}
}
let setupOffered = false;
async function firstRunCheck() {
if (aliasConfigured({ ...process.env, PATH: loginPath() })) return;
if (setupOffered || !url) return; // not on the error page, and once per launch
if (await aliasConfigured({ ...process.env, PATH: await loginPath() })) return;
if (!win || setupOffered) return;
setupOffered = true;
const { response } = await dialog.showMessageBox(win, {
type: "info",
message: "Connect to your Steam Frame",
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Developer Mode and set `
+ "a user password (Steam Settings → System). Then run the setup script: it finds the "
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup script: it finds the "
+ "headset, creates a key, and asks for that password once in Terminal.",
buttons: ["Set Up Connection…", "Later"],
defaultId: 0, cancelId: 1,
@@ -189,7 +198,7 @@ function createWindow() {
titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
win.once("ready-to-show", () => { win.show(); firstRunCheck(); });
win.once("ready-to-show", () => win.show());
win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
// External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => {
@@ -215,7 +224,7 @@ function runInTerminal(command) {
const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
function setUpConnection() {
runInTerminal(`FRAME_ALIAS=${sh(FRAME)} ${sh(path.join(SCRIPTS, "connect.sh"))}`);
runInTerminal(`env ${sh(`FRAME_ALIAS=${FRAME}`)} zsh ${sh(path.join(SCRIPTS, "connect.sh"))}`);
}
function buildMenu() {
+2 -1
View File
@@ -43,7 +43,8 @@ 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 `xrCreateInstance`, no loader), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. To watch in 3D, use a native player. 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. 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) |
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
## Debug recipes
+6 -1
View File
@@ -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–17.
Still open: 4, 6, 7, 11 (in-headset connect), 12–21.
## Check on the headset (in order)
@@ -96,6 +96,11 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–17.
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
install it instead of 1.17.2.
21. **DeoVR local files:** does DeoVR's file browser show `Videos → VR`
(the symlink from `push-vr-video.sh`) or `Z:\home\steamos\Videos\VR`, and do
the colour-coded test clips play in 3D (red left eye, cyan right) for both
H.264 and H.265? Does the DLNA browser find a server on the Mac?
## Unconfirmed claims made in these docs
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
+3
View File
@@ -38,6 +38,9 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
| WiVRn / ALVR | VR streaming from a Linux or Windows PC | Irrelevant for a Mac host (no SteamVR/OpenXR runtime on macOS) | N/A |
For **VR video files** (180°/360° stereo), don't stream the Mac's screen. Play
them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
### Pre-seeding the Remmina profile (no typing in the headset)
`scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes
+81
View File
@@ -0,0 +1,81 @@
# Watching VR video (180°/360°) on the Frame
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
was checked on SteamOS 0.3.0, build 20260922.6101926.
## The short version
1. Install **DeoVR Video Player** from Steam (free, app 837380) and start it once
in the headset. That creates its Proton prefix.
2. On the Mac: `scripts/push-vr-video.sh --launch ~/Movies/beach_180_LR.mp4`
3. In the headset, open DeoVR's local file browser → **Videos → VR** and
pick the file.
## Why DeoVR
The Frame's Chromium can't play VR video in 3D. It has no immersive WebXR
([how-the-frame-works.md](how-the-frame-works.md)). DeoVR's Windows build
runs under Proton ARM64 + FEX as a real SteamVR app. **Verified 2026-09-25:**
it found the Steam Frame headset and controller over OpenVR, and decoded
7680×3840 and 8192×4096 H.265 VR180 side-by-side streams through AVPro's
hardware Media Foundation path, mapped onto a 180° dome or fisheye mesh.
Known quirks (verified):
- The first launch takes about 45 s while it compiles shaders.
- Grid thumbnails stay blank. Unity's own video player, which DeoVR uses for
previews, fails with `0xc00d36bb` under Proton. Full playback uses AVPro
and isn't affected.
- The in-app store and web content are separate from local files. You don't
need an account to play your own files.
## Getting files onto the headset
`scripts/push-vr-video.sh` copies files with `rsync --partial`, so an
interrupted upload resumes. They go to `~/Videos/VR` on the Frame (`/home`
has about 860 GB free). The script also links that folder into DeoVR's prefix
as `C:\users\steamuser\Videos\VR`. It's reachable at
`Z:\home\steamos\Videos\VR` as well. **Verified** that the upload and link
work. **Not yet checked** whether DeoVR's file browser lands there
(open question 21).
Speed: a test upload over Wi-Fi ran at about 3–5 MB/s (verified 2026-09-25,
one sample). At that rate an 8K file of several GB takes tens of minutes, so
start big uploads before you put the headset on.
## Naming files so they play correctly
DeoVR guesses the projection from the file name. Its binary contains the tags
`_180`, `_360`, `_fisheye`, `_fisheye190`, `_mkx200`, `_vrca220` and `_rf52`
(verified). For stereo layout, the common DeoVR convention is `_LR`/`_SBS`
(side by side) and `_TB` (top/bottom) (inferred). If a video looks wrong
(doubled, warped, or flat), change the projection and stereo mode in DeoVR's
player menu.
Examples: `trip_180_LR.mp4`, `concert_360_TB.mp4`, `hike_fisheye190_LR.mp4`.
Codecs: H.265 at 8K played (verified, streamed). Local H.264 and H.265
files haven't been played yet. The test clips below cover that.
## Test clips
The script doesn't include these. To check a setup, make two 20 s clips:
3840×1920, 180° side by side, with the left eye tinted red and the right eye
cyan. In the headset each eye should see only its own colour. A single mixed
colour means the stereo split is wrong.
```sh
ffmpeg -f lavfi -i testsrc2=size=1920x1920:rate=30:duration=20 \
-filter_complex "[0:v]split[a][b];[a]colorchannelmixer=rr=1:gg=0.3:bb=0.3[l];[b]colorchannelmixer=rr=0.3:gg=1:bb=1[r];[l][r]hstack" \
-c:v libx264 -pix_fmt yuv420p -b:v 20M frame-test_180_LR_h264.mp4
scripts/push-vr-video.sh frame-test_180_LR_h264.mp4
```
## Streaming from the Mac instead of copying (untested)
DeoVR has a DLNA browser (the binary contains `Searching for DLNA
devices...` and a UPnP ContentDirectory client). A DLNA server on the Mac
should therefore appear in DeoVR without copying anything, for example
`brew install rclone` then `rclone serve dlna ~/Movies/VR`. This is **inferred**, not
tried. 8K VR video needs roughly 50–100 Mbit/s sustained, and the Wi-Fi
sample above (about 30–40 Mbit/s) suggests copying first is the safer default.
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env zsh
# Mac-side: upload VR videos to the Steam Frame so DeoVR can play them in 3D.
#
# Files go to ~/Videos/VR on the Frame. The script links that folder into
# DeoVR's Proton prefix as C:\users\steamuser\Videos\VR, so DeoVR's file
# browser finds it under Videos. (It's also reachable as Z:\home\steamos\Videos\VR.)
# Uploads resume if interrupted.
#
# Name files so DeoVR picks the projection: include _180 or _360 (or _fisheye190,
# _mkx200, _rf52 ...) plus the stereo layout (_LR / _SBS side by side, _TB over-
# under), e.g. "beach_180_LR.mp4". You can also change it in DeoVR's player.
#
# Usage:
# scripts/push-vr-video.sh FILE_OR_DIR... # upload
# scripts/push-vr-video.sh --launch FILE... # upload, then start DeoVR
# scripts/push-vr-video.sh --launch # just start DeoVR
# scripts/push-vr-video.sh --list # what's on the Frame
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
DEOVR_APPID=837380
REMOTE_DIR="Videos/VR"
PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/users/steamuser/Videos"
launch=0 list=0
while (( $# )); do
case "$1" in
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
--launch) launch=1; shift ;;
--list) list=1; shift ;;
--) shift; break ;;
-*) print -u2 "push-vr-video: unknown option $1"; exit 2 ;;
*) break ;;
esac
done
(( $# || launch || list )) || { sed -n '2,18p' "$0"; 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
p=~/$PREFIX_VIDEOS
if [ -d \"\$p\" ] && [ ! -e \"\$p/VR\" ]; then ln -s ~/$REMOTE_DIR \"\$p/VR\"; 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"
if (( $# )); then
rsync -a --partial --progress "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
fi
if (( list )); then
ssh "$FRAME_ALIAS" "cd ~/$REMOTE_DIR && ls -lhR"
fi
if (( launch )); then
ssh "$FRAME_ALIAS" "steam steam://rungameid/$DEOVR_APPID >/dev/null 2>&1 &"
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
fi
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env zsh
# Mac-side: install Tailscale on the Frame in userspace mode, so the `frame` SSH
# alias (and Frame Control) work from anywhere, not just the home LAN.
#
# Everything lives in the steamos user's home, so it needs no sudo and survives
# SteamOS updates:
# ~/.local/share/tailscale/<version>/ static binaries (checksum-verified)
# ~/.local/share/tailscale/state/ node key and state
# ~/.local/bin/tailscale CLI wrapper that finds the daemon's socket
# ~/.config/systemd/user/tailscaled.service
# `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root.
#
# 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
# to your tailnet. Env: FRAME_ALIAS (default frame).
set -euo pipefail
FRAME=${FRAME_ALIAS:-frame}
version="" hostname="frame" uninstall=0
while (( $# )); do
case "$1" in
--version) version=$2; shift ;;
--hostname) hostname=$2; shift ;;
--uninstall) uninstall=1 ;;
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
*) print -u2 "unknown argument: $1"; exit 2 ;;
esac
shift
done
[[ $hostname =~ '^[A-Za-z0-9-]+$' ]] || { print -u2 "bad hostname: $hostname"; exit 2; }
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
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."'
exit 0
fi
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"])')
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'
set -eu
base="$HOME/.local/share/tailscale"
dir="$base/$VERSION"
tgz="tailscale_${VERSION}_arm64.tgz"
mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user"
if [ ! -x "$dir/tailscaled" ]; then
tmp=$(mktemp -d)
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)
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
ln -sfn "$dir" "$base/current"
# The CLI looks for the daemon at /var/run/tailscale by default; point it at ours.
cat > "$HOME/.local/bin/tailscale" <<'EOF'
#!/bin/sh
exec "$HOME/.local/share/tailscale/current/tailscale" --socket="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock" "$@"
EOF
chmod +x "$HOME/.local/bin/tailscale"
cat > "$HOME/.config/systemd/user/tailscaled.service" <<'EOF'
[Unit]
Description=Tailscale (userspace networking, no root)
After=network-online.target
[Service]
RuntimeDirectory=tailscale
ExecStart=%h/.local/share/tailscale/current/tailscaled --tun=userspace-networking --statedir=%h/.local/share/tailscale/state --socket=%t/tailscale/tailscaled.sock --port=41641
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable tailscaled.service >/dev/null 2>&1
systemctl --user restart tailscaled.service
for i in $(seq 1 50); do
[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/tailscale/tailscaled.sock" ] && break
sleep 0.2
done
"$HOME/.local/bin/tailscale" version | head -n 1
REMOTE
# `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
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 == Running ]] && break
sleep 2
done
else
print -u2 "No login URL yet; see /tmp/tailscale-up.log on the Frame."
fi
fi
ssh "$FRAME" '~/.local/bin/tailscale status --self --peers=false; printf "Tailscale IP: "; ~/.local/bin/tailscale ip -4'
+13 -3
View File
@@ -11,6 +11,7 @@ import os
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
@@ -29,22 +30,25 @@ class ServerGuards(unittest.TestCase):
def setUpClass(cls):
cls.port = free_port()
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
for _ in range(100):
try:
if cls.request("GET", "/")[0] == 200:
return
except OSError:
except Exception:
pass
time.sleep(0.05)
cls.proc.kill()
raise RuntimeError("server didn't start")
cls.log.seek(0)
raise RuntimeError("server didn't start:\n" + cls.log.read().decode(errors="replace"))
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=10)
cls.log.close()
@classmethod
def request(cls, method, path, body=None, headers=None):
@@ -79,6 +83,12 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
def test_captures_are_not_cacheable(self):
# Headset captures show everything on screen; nothing may cache them.
_, headers, _ = self.request("GET", "/api/screenshot", headers={"X-Frame-UI": "1"})
self.assertEqual(headers.get("Cache-Control"), "no-store")
self.assertIn("frame-ancestors 'none'", headers.get("Content-Security-Policy", ""))
def test_input_validation(self):
cases = [
("/api/launch", {"appid": "620; rm -rf ~"}),
+2 -2
View File
@@ -142,8 +142,8 @@ OWNED_JS = r"""
"""
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
state: i.eInstallState, app: i.currentAppID, need: i.nDiskSpaceRequired, free: i.nDiskSpaceAvailable,
error: i.eAppError, detail: i.errorDetail }))"""
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
def steam_url(url):
+2 -1
View File
@@ -6,6 +6,7 @@ Uses the store's public endpoints (no key, no login):
`frame_resolved_category` is the Frame's
Buying happens on the store page, signed in as the user; nothing here buys.
"""
import http.client
import json
import threading
import time
@@ -37,7 +38,7 @@ def frame_rating(appid):
r = _get("saleaction/ajaxgetdeckappcompatibilityreport", {"nAppID": appid, "l": "english"})
cat = int((r.get("results") or {}).get("frame_resolved_category") or 0)
cat = cat if 0 <= cat <= 3 else 0
except (OSError, ValueError, TypeError, AttributeError):
except (OSError, ValueError, TypeError, AttributeError, http.client.HTTPException):
return 0 # not cached, so the next search retries
with _lock:
_compat[appid] = (time.time(), cat)
+2 -1
View File
@@ -1024,7 +1024,8 @@ function price(p) {
if (!p) return "Free";
const f = n => { try { return new Intl.NumberFormat(undefined, { style: "currency", currency: p.currency }).format(n / 100); }
catch { return `${(n / 100).toFixed(2)} ${esc(p.currency || "")}`; } };
return p.initial > p.final ? `<s>${f(p.initial)}</s>${f(p.final)}` : f(p.final);
const fin = p.final ?? p.initial ?? 0;
return p.initial > fin ? `<s>${f(p.initial)}</s>${f(fin)}` : f(fin);
}
function gameButtons(id, name, free) {
+8 -4
View File
@@ -8,6 +8,7 @@ Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
Env: FRAME_ALIAS (default frame)
"""
import argparse
import http.client
import json
import os
import re
@@ -204,7 +205,7 @@ def steam_frame(*args, timeout=40):
except Failure as e:
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both.
for line in reversed([*getattr(e, "stdout", "").splitlines(), *str(e).splitlines()]):
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
try:
raise Failure(json.loads(line)["error"]) from None
except (ValueError, KeyError, TypeError):
@@ -230,17 +231,20 @@ def steam_search(query):
raise Failure("cc must be a two-letter country code", 400)
try:
return {"results": frame_store.search((q.get("q") or [""])[0], cc)}
except (OSError, ValueError, TypeError, AttributeError) as e:
except (OSError, ValueError, TypeError, AttributeError, http.client.HTTPException) as e:
raise Failure(f"Steam store search failed: {e}")
def set_volume(body):
if "muted" in body:
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
# Validate everything before touching the headset.
level = None
if "level" in body:
level = float(body["level"])
if not 0 <= level <= 1:
raise Failure("level must be 0..1", 400)
if "muted" in body:
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
if level is not None:
ssh(f"wpctl set-volume @DEFAULT_AUDIO_SINK@ {level:.2f}")
return {"message": "Volume updated"}