mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
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:
1 parent
d4486a7681
commit
99653151c4
13 files changed
+340
-38
No files matched your search
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Executable
+60
@@ -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
|
||||
Executable
+126
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
Reference in new issue
Block a user