diff --git a/.claude/skills/steam-frame/SKILL.md b/.claude/skills/steam-frame/SKILL.md index 3cedf04..314fbf6 100644 --- a/.claude/skills/steam-frame/SKILL.md +++ b/.claude/skills/steam-frame/SKILL.md @@ -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` | diff --git a/README.md b/README.md index 47be037..c2934ac 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,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**) | @@ -219,8 +220,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 diff --git a/docs/how-the-frame-works.md b/docs/how-the-frame-works.md index 24c640f..32c69e0 100644 --- a/docs/how-the-frame-works.md +++ b/docs/how-the-frame-works.md @@ -35,7 +35,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600 | 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`. 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 +43,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/` 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 ` 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 +72,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) diff --git a/docs/open-questions.md b/docs/open-questions.md index 8fcba58..67f4645 100644 --- a/docs/open-questions.md +++ b/docs/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.`). 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 diff --git a/docs/tailscale.md b/docs/tailscale.md new file mode 100644 index 0000000..28520bf --- /dev/null +++ b/docs/tailscale.md @@ -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..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//` | `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. diff --git a/scripts/push-vr-video.sh b/scripts/push-vr-video.sh index 4110bcd..93cb5ec 100755 --- a/scripts/push-vr-video.sh +++ b/scripts/push-vr-video.sh @@ -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 2>&1 &" print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR." fi diff --git a/scripts/tailscale-on-frame.sh b/scripts/tailscale-on-frame.sh index 11edd27..68f05a2 100755 --- a/scripts/tailscale-on-frame.sh +++ b/scripts/tailscale-on-frame.sh @@ -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" diff --git a/tests/test_server.py b/tests/test_server.py index c59f6f5..053fa18 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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,8 @@ class ServerGuards(unittest.TestCase): # 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/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 +101,21 @@ 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_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"}) diff --git a/ui/index.html b/ui/index.html index ebc8320..07acfc6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -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 @@