Add Steam screenshots, Tailscale remote access, VR-video fixes

- Screenshots: list the Frame's Steam screenshots, open them in the
  viewer, and save new ones to ~/Pictures/SteamFrame. Ids are validated
  before any shell, and copies land atomically.
- Tailscale: scripts/tailscale-on-frame.sh installs a userspace tailscaled
  as a lingering systemd --user service with no sudo, SHA-256 checked, safe
  to re-run, with --uninstall. docs/tailscale.md covers setup and warns that
  in userspace mode every Frame port, including loopback-only DevTools and
  ADB, is reachable from the tailnet.
- push-vr-video.sh: filenames starting with "-" are safe, symlinks are
  followed, and a real Videos\VR directory triggers a warning.
- Tests cover the screenshot routes (19 total).

Docs keep placeholder addresses for the headset and tailnet.

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 23:11:14 +10:00
1 parent 99653151c4
commit eabf4cd1f9
10 files changed
+427 -54

No files matched your search

+1
View File
@@ -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` | | 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` | | 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` | | 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` | | Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` | | 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` | | Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
+4 -2
View File
@@ -191,6 +191,7 @@ showed live status and the library.
| Script | Runs on | Purpose | | 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/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/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) | | `scripts/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 you've switched to key auth, a short password still protects `sudo` and
RDP, so pick one that isn't trivially guessable. RDP, so pick one that isn't trivially guessable.
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access, - 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 use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
checked). **every** Frame port is reachable from your tailnet, including Steam's DevTools
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
## Development ## Development
+5 -2
View File
@@ -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) | | 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 | | SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging | | The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb`, `wayvnc`. | Script design | | Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` | | 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 | | `/` 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` | | 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) | | Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) | | Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` | | The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs. 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) | | **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons | | Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
## Debug recipes ## 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) - Files and clipboard: [file-transfer.md](file-transfer.md)
- Android apps: [apks.md](apks.md) - Android apps: [apks.md](apks.md)
- Installing and buying Steam games: [steam-games.md](steam-games.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) - Floating windows in space: [panels.md](panels.md)
- What's still unverified: [open-questions.md](open-questions.md) - What's still unverified: [open-questions.md](open-questions.md)
+6 -4
View File
@@ -25,7 +25,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
(`vrserver`, `vrcompositor`) and `xrdp` are running. (`vrserver`, `vrcompositor`) and `xrdp` are running.
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present. - **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc` `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. text correctly.
- Flathub is already configured as a **system** remote; Chromium is the only - Flathub is already configured as a **system** remote; Chromium is the only
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB. installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
@@ -40,7 +40,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were 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). 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) ## Check on the headset (in order)
@@ -82,8 +82,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–21.
to type locally. to type locally.
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC) 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? reach the Linux side? Does USB power from the Mac cope?
16. **Tailscale**: can it be installed persistently (Flatpak? a 16. ~~**Tailscale**~~: answered 2026-09-25. A userspace `tailscaled` in `~`
userspace `tailscaled` in `~`?) for access off the home LAN? 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 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 panels from `panel-on-frame.sh` show up, take input, and offer **Float in
World** / **Move** / **Size**? Do floating positions survive closing and World** / **Move** / **Size**? Do floating positions survive closing and
+91
View File
@@ -0,0 +1,91 @@
# Tailscale: use the Frame from anywhere
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
so does everything built on it: Frame Control, the scripts and the Mac app.
When the Mac and the Frame are on the same network, Tailscale connects them
directly, so there's no relay in the way (`tailscale ping frame` → `via
192.168.1.50:41641`, 20 ms).
```sh
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
scripts/tailscale-on-frame.sh --uninstall
```
## How it's installed
There's no Tailscale Flatpak, and the rootfs is read-only. So the script
installs Tailscale's static arm64 build in the `steamos` user's home and runs
`tailscaled --tun=userspace-networking` as a systemd **user** service. It
doesn't need sudo, and SteamOS updates don't touch it.
| Path | What |
|---|---|
| `~/.local/share/tailscale/<version>/` | `tailscale`, `tailscaled` (SHA-256 checked against pkgs.tailscale.com) |
| `~/.local/share/tailscale/current` | Symlink to the active version |
| `~/.local/share/tailscale/state/` | Node key and state |
| `~/.local/bin/tailscale` | CLI wrapper that points at the daemon's socket (`$XDG_RUNTIME_DIR/tailscale/tailscaled.sock`) |
| `~/.config/systemd/user/tailscaled.service` | The service |
Lingering (`loginctl enable-linger`) is on, so the service starts at boot
without anyone logging in. polkit allowed that without sudo. Re-running the
script is safe. It restarts `tailscaled` only if the version or unit changed,
and then does so detached after 3 s, because the SSH session may itself run
over Tailscale.
To update, run the script again; it installs the latest stable version. To
manage the node, use `ssh frame '~/.local/bin/tailscale status'` (or `set`,
`down`, `up`).
**Verified 2026-09-25 (SteamOS 0.3.0, build 20260922.6101926, Tailscale
1.102.4):**
- First install and login approval. This ran an earlier revision of the script,
which restarted the daemon unconditionally. The node is `frame`,
with a 100.x.y.z tailnet address.
- SSH works over Tailscale: the Frame serves the same ED25519 host key as it
does on `frame.local`.
- Frame Control's status and Get games work through the alias.
- The current script: a re-run with nothing changed doesn't restart anything,
and a re-run with a changed unit restarts `tailscaled` 3 s after the SSH
session ends and then reads `Running`. The timer needs
`AccuracySec=100ms`; the default of 1 min made it fire up to a minute late.
The first-install guard was checked on its own.
**Not verified:**
- A clean first install and login with the current script end to end. It would
mean removing the node from the tailnet.
- Reaching the Frame from outside the home network. Only the direct LAN path
was tested.
- The service coming up after a reboot. That's **inferred** from linger plus
`WantedBy=default.target`; the Frame hasn't been rebooted since.
## Exposure: every port is on the tailnet
In userspace mode, `tailscaled` passes inbound tailnet connections to the
Frame's **loopback**. Any device on the tailnet can therefore reach **every**
listening port, including ones meant to be local-only. Checked from the Mac on
2026-09-25:
| Port | Service | Normally |
|---|---|---|
| 22 | sshd | LAN |
| 8080 | Steam client DevTools (full control of the Steam client and account session) | loopback only |
| 27062 | SteamVR `vrserver` | loopback only |
| 5555 | Lepton ADB (unauthenticated shell into Android) | LAN |
| 3389 | xrdp | LAN |
The user accepted this on 2026-09-25, since the tailnet only holds their own
devices. Other options:
- `tailscale set --shields-up` blocks **all** inbound connections. That
includes SSH and Tailscale SSH (`--ssh`), both checked.
- A tailnet policy that tags the Frame (`tag:frame`) and allows only
`tag:frame:22` keeps the other ports private. This is an admin-console
change.
- Kernel-mode Tailscale (a root install, e.g. systemd-sysext) wouldn't expose
loopback-only ports, but it needs sudo and may not survive SteamOS updates.
If the Mac's Tailscale is off, the alias won't resolve. Use
`scripts/connect.sh frame.local` to go back to the LAN name.
+12 -7
View File
@@ -25,7 +25,7 @@ PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/
launch=0 list=0 launch=0 list=0
while (( $# )); do while (( $# )); do
case "$1" in 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 ;; --launch) launch=1; shift ;;
--list) list=1; shift ;; --list) list=1; shift ;;
--) shift; break ;; --) shift; break ;;
@@ -33,21 +33,25 @@ while (( $# )); do
*) break ;; *) break ;;
esac esac
done done
(( $# || launch || list )) || { sed -n '2,18p' "$0"; exit 2; } (( $# || launch || list )) || { sed -n '2,17p' "$0" >&2; exit 2; }
for f in "$@"; do for f in "$@"; do
[[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; } [[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
done done
# Create the folder and link it into DeoVR's prefix (the prefix exists once # 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. # DeoVR has run). Refresh a stale link, but never replace a real directory.
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR if (( $# || launch )); then
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
p=~/$PREFIX_VIDEOS 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" [ -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 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 fi
if (( list )); then if (( list )); then
@@ -55,6 +59,7 @@ if (( list )); then
fi fi
if (( launch )); then if (( launch )); then
ssh "$FRAME_ALIAS" "steam steam://rungameid/$DEOVR_APPID >/dev/null 2>&1 &" ssh "$FRAME_ALIAS" "command -v steam >/dev/null || { echo 'steam not found on the Frame' >&2; exit 1; }
steam steam://rungameid/$DEOVR_APPID </dev/null >/dev/null 2>&1 &"
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR." print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
fi fi
+81 -28
View File
@@ -10,6 +10,11 @@
# ~/.config/systemd/user/tailscaled.service # ~/.config/systemd/user/tailscaled.service
# `tailscaled --tun=userspace-networking` needs no /dev/net/tun or root. # `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] # Usage: scripts/tailscale-on-frame.sh [--version X.Y.Z] [--hostname NAME]
# scripts/tailscale-on-frame.sh --uninstall # scripts/tailscale-on-frame.sh --uninstall
# The first run prints a login URL (and opens it on the Mac) to add the Frame # 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 version="" hostname="frame" uninstall=0
while (( $# )); do while (( $# )); do
case "$1" in case "$1" in
--version) version=$2; shift ;; --version) version=${2:?--version needs a value}; shift ;;
--hostname) hostname=$2; shift ;; --hostname) hostname=${2:?--hostname needs a value}; shift ;;
--uninstall) uninstall=1 ;; --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 ;; *) print -u2 "unknown argument: $1"; exit 2 ;;
esac esac
shift shift
@@ -33,13 +38,21 @@ done
if (( uninstall )); then if (( uninstall )); then
ssh "$FRAME" 'set -e ssh "$FRAME" 'set -e
systemctl --user disable --now tailscaled.service 2>/dev/null || true 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 systemctl --user daemon-reload
echo "Removed the service and wrappers. Binaries and node state are still in ~/.local/share/tailscale;" echo "Removed the service and the CLI wrapper. Binaries and node state are still in"
echo "delete that folder and remove the machine in the Tailscale admin console to finish."' 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 exit 0
fi 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 if [[ -z $version ]]; then
version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" | version=$(curl -fsS "https://pkgs.tailscale.com/stable/?mode=json" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["TarballsVersion"])') 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; } [[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || { print -u2 "bad version: $version"; exit 2; }
print "==> Installing Tailscale $version on $FRAME (userspace networking)" 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 set -eu
base="$HOME/.local/share/tailscale" base="$HOME/.local/share/tailscale"
dir="$base/$VERSION" dir="$base/$VERSION"
tgz="tailscale_${VERSION}_arm64.tgz" 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" mkdir -p "$base/state" "$HOME/.local/bin" "$HOME/.config/systemd/user"
if [ ! -x "$dir/tailscaled" ]; then if [ ! -x "$dir/tailscaled" ]; then
@@ -59,12 +74,15 @@ if [ ! -x "$dir/tailscaled" ]; then
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz" curl -fsSL -o "$tmp/$tgz" "https://pkgs.tailscale.com/stable/$tgz"
want=$(curl -fsSL "https://pkgs.tailscale.com/stable/$tgz.sha256" | cut -d' ' -f1) 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) got=$(sha256sum "$tmp/$tgz" | cut -d' ' -f1)
[ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; } [ "$want" = "$got" ] || { echo "checksum mismatch for $tgz" >&2; exit 1; }
tar -xzf "$tmp/$tgz" -C "$tmp" tar -xzf "$tmp/$tgz" -C "$tmp"
mkdir -p "$dir" mkdir -p "$dir"
mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/" mv "$tmp/tailscale_${VERSION}_arm64/tailscale" "$tmp/tailscale_${VERSION}_arm64/tailscaled" "$dir/"
fi 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" ln -sfn "$dir" "$base/current"
# The CLI looks for the daemon at /var/run/tailscale by default; point it at ours. # 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 EOF
chmod +x "$HOME/.local/bin/tailscale" chmod +x "$HOME/.local/bin/tailscale"
cat > "$HOME/.config/systemd/user/tailscaled.service" <<'EOF' cat > "$unit" <<'EOF'
[Unit] [Unit]
Description=Tailscale (userspace networking, no root) Description=Tailscale (userspace networking, no root)
After=network-online.target After=network-online.target
@@ -88,39 +106,74 @@ RestartSec=5
[Install] [Install]
WantedBy=default.target WantedBy=default.target
EOF 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 daemon-reload
systemctl --user enable tailscaled.service >/dev/null 2>&1 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 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 sleep 0.2
done 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 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 # Wait out a scheduled restart, then read a definite state.
# Frame and fetch the URL from its log. [[ $remote_out == *RESTART_SCHEDULED* ]] && sleep 6
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') state=""
if [[ $state != Running ]]; then for i in {1..30}; do
ssh "$FRAME" "nohup ~/.local/bin/tailscale up --hostname=$hostname --timeout=10m > /tmp/tailscale-up.log 2>&1 &" state=$(ts_state)
url="" [[ $state == (Running|NeedsLogin|NeedsMachineAuth|Stopped|NoState) ]] && break
for i in {1..40}; do sleep 2
url=$(ssh "$FRAME" 'grep -Eo "https://login\.tailscale\.com/[A-Za-z0-9/_-]+" /tmp/tailscale-up.log | head -n 1' || true) done
[[ -n $url ]] && break
sleep 0.5 case $state in
done Running) ;;
if [[ -n $url ]]; then 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" print "==> Approve the Frame in your tailnet: $url"
open "$url" 2>/dev/null || true open "$url" 2>/dev/null || true
print " Waiting for approval (up to 10 minutes)…" print " Waiting for approval (up to 10 minutes)…"
for i in {1..300}; do 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 [[ $state == Running ]] && break
sleep 2 sleep 2
done done
else [[ $state == Running ]] || { print -u2 "Not approved yet (state: $state). Re-run to get a new URL."; exit 1; }
print -u2 "No login URL yet; see /tmp/tailscale-up.log on the Frame." ;;
fi NeedsMachineAuth) print -u2 "Logged in; approve the device in the Tailscale admin console, then re-run."; exit 1 ;;
fi *) 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' 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"
+13
View File
@@ -15,6 +15,7 @@ import tempfile
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from urllib.parse import quote
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -81,6 +82,8 @@ class ServerGuards(unittest.TestCase):
# <img src> and plain form posts from other sites can't set it. # <img src> and plain form posts from other sites can't set it.
self.assertEqual(self.request("GET", "/api/status")[0], 403) self.assertEqual(self.request("GET", "/api/status")[0], 403)
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403) self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403) self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
def test_captures_are_not_cacheable(self): def test_captures_are_not_cacheable(self):
@@ -98,11 +101,21 @@ class ServerGuards(unittest.TestCase):
("/api/volume", {"level": 1.5}), ("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}), ("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}), ("/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: for path, body in cases:
status, payload = self.post(path, body) status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}") 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): def test_bad_bodies(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"}) conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
+122 -10
View File
@@ -152,6 +152,17 @@
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; } 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; } .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) ---- */ /* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; } .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; .capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
@@ -279,6 +290,7 @@
</a> </a>
<nav id="nav"> <nav id="nav">
<a href="#view" class="on">View</a> <a href="#view" class="on">View</a>
<a href="#shots">Shots</a>
<a href="#library">Library</a> <a href="#library">Library</a>
<a href="#getgames">Games</a> <a href="#getgames">Games</a>
<a href="#android">Android</a> <a href="#android">Android</a>
@@ -369,6 +381,16 @@
</section> </section>
</div> </div>
<section id="shots">
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
<button class="small" id="shotsRefresh">Refresh</button>
<button class="small" id="shotsFolder" title="Open ~/Pictures/SteamFrame">Show in Finder</button>
<button class="action small" id="shotsSaveNew" disabled>Save new to Mac</button>
</div>
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
</section>
<section id="library"> <section id="library">
<div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div> <div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div>
<div class="shelf" id="games"><div class="sub">Loading…</div></div> <div class="shelf" id="games"><div class="sub">Loading…</div></div>
@@ -556,9 +578,9 @@ const HINTS = {
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live refreshes about twice a second. Captures show everything on screen, including anything private.", headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live refreshes about twice a second. Captures show everything on screen, including anything private.",
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.", flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
}; };
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel" }; const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null; let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
let lastImg = null, lastSource = null; let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); } function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; } function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
@@ -792,7 +814,7 @@ document.addEventListener("keydown", e => {
function draw() { function draw() {
const img = lastImg, c = $("canvas"); const img = lastImg, c = $("canvas");
// Side-by-side stereo captures: crop to the left half for "one eye". // Side-by-side stereo captures: crop to the left half for "one eye".
const sbs = lastSource !== "gamescope" && img.naturalWidth >= img.naturalHeight * 1.5; const sbs = lastSource === "steamvr" && img.naturalWidth >= img.naturalHeight * 1.5;
const crop = sbs && eye === "left"; const crop = sbs && eye === "left";
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight; const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
if (c.width !== sw || c.height !== sh) { if (c.width !== sw || c.height !== sh) {
@@ -819,6 +841,7 @@ function isBlank(ctx, w, h) {
return max - min < 6; return max - min < 6;
} }
async function capture() { async function capture() {
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
let url = null; let url = null;
try { try {
@@ -829,7 +852,8 @@ async function capture() {
url = URL.createObjectURL(await r.blob()); url = URL.createObjectURL(await r.blob());
const img = new Image(); const img = new Image();
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; }); await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
lastImg = img; lastSource = source; if (gen !== viewGen) return true;
lastImg = img; lastSource = source; lastShot = null;
draw(); draw();
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString(); $("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
$("saveBtn").disabled = false; $("saveBtn").disabled = false;
@@ -863,14 +887,17 @@ function toggleLive(on) {
} }
$("shotBtn").onclick = () => capture(); $("shotBtn").onclick = () => capture();
$("liveBtn").onclick = () => toggleLive(!live); $("liveBtn").onclick = () => toggleLive(!live);
$("saveBtn").onclick = () => $("canvas").toBlob(b => { $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : $("canvas").toBlob(b => {
if (!b) return toast("Couldn't encode the image", true); if (!b) return toast("Couldn't encode the image", true);
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
}, "image/png");
function download(blob, name) {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(b); a.href = URL.createObjectURL(blob);
a.download = `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`; a.download = name;
a.click(); a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}, "image/png"); }
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view)); document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye)); document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
@@ -1382,15 +1409,100 @@ $("repForm").onsubmit = async e => {
}; };
loadReports(); loadReports();
// ---- Steam screenshots from the headset ----
const shots = { list: [], urls: [] };
const STEAMVR_APPID = "250820";
function shotApp(appid) {
if (appid === STEAMVR_APPID) return "SteamVR";
const g = state?.games?.find(x => x.appid === appid);
return g ? g.name : `App ${appid}`;
}
async function shotBlob(id, thumb) {
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
{ headers: {"X-Frame-UI": "1"} });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
return r.blob();
}
async function loadShots() {
$("shotsRefresh").disabled = true;
try {
shots.list = (await api("/api/shots")).shots;
} catch (e) {
$("shotGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`;
return;
} finally { $("shotsRefresh").disabled = false; }
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
const unsaved = shots.list.filter(s => !s.saved).length;
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this Mac` : "") : "";
$("shotsSaveNew").disabled = !unsaved;
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
<div class="row"><div class="grow">
<div class="t">${esc(shotApp(s.appid))}</div>
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
${s.saved ? `<span class="tag">On Mac</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
</div></div>`).join("")
: `<div class="sub">No screenshots on the Frame yet.</div>`;
// Thumbnails one at a time over the shared SSH connection.
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
const s = shots.list[+img.dataset.shot];
try {
const url = URL.createObjectURL(await shotBlob(s.id, true));
shots.urls.push(url);
img.src = url;
} catch (e) { img.alt = "Preview failed"; }
if (!img.isConnected) return; // the list was reloaded meanwhile
}
}
async function openShot(s) {
if (live) toggleLive(false);
const gen = ++viewGen;
$("viewer").classList.add("busy");
let url = null;
try {
const blob = await shotBlob(s.id, false);
url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
if (gen !== viewGen) return;
lastImg = img; lastSource = "shot"; lastShot = { blob, file: s.file };
draw();
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
$("saveBtn").disabled = false;
$("view").scrollIntoView({ behavior: "smooth" });
} catch (e) {
toast("Couldn't open the screenshot: " + e.message, true);
} finally {
if (url) URL.revokeObjectURL(url);
$("viewer").classList.remove("busy");
}
}
async function saveShots(list, btn) {
if (!list.length) return;
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
if (res) loadShots();
}
$("shotGrid").onclick = e => {
const img = e.target.closest("img[data-shot]");
if (img) return openShot(shots.list[+img.dataset.shot]);
const b = e.target.closest("[data-shot-save]");
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
};
$("shotsRefresh").onclick = loadShots;
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
$("shotsFolder").onclick = e => act("Show in Finder", () => api("/api/open", { what: "shots" }), e.currentTarget);
// ---- nav highlight follows scroll ---- // ---- nav highlight follows scroll ----
const spy = new IntersectionObserver(entries => { const spy = new IntersectionObserver(entries => {
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id)); if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id));
}, { rootMargin: "-80px 0px -55% 0px" }); }, { rootMargin: "-80px 0px -55% 0px" });
["view", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id))); ["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
setView("headset"); setView("headset");
refresh(); refresh().then(loadShots); // after status, so app names resolve
setInterval(() => { if (!document.hidden) refresh(); }, 30000); setInterval(() => { if (!document.hidden) refresh(); }, 30000);
</script> </script>
</body> </body>
+92 -1
View File
@@ -189,6 +189,89 @@ def headset_view():
pass # frame_vrshot.py sweeps leftovers on the next capture pass # frame_vrshot.py sweeps leftovers on the next capture
# Screenshots taken in the headset with Steam's shortcut. Steam files each under the app
# it was taken in: userdata/<account>/760/remote/<appid>/screenshots/<file>,
# with a smaller copy in screenshots/thumbnails/. A shot's id is
# "<account>/<appid>/<file>", checked here before it goes near a shell.
SHOT_ROOT = ".local/share/Steam/userdata"
SHOT_ID = re.compile(r"(\d{1,12})/(\d{1,20})/(\d{14}_\d{1,4}\.(?:jpg|png))")
SHOTS_DIR = Path.home() / "Pictures" / "SteamFrame"
LIST_SHOTS = f"""cd ~/{SHOT_ROOT} 2>/dev/null || exit 0
find . -mindepth 6 -maxdepth 6 -path './*/760/remote/*/screenshots/*' -type f \\
\\( -name '*.jpg' -o -name '*.png' \\) -printf '%P\\t%s\\t%T@\\n'"""
def shot_path(shot_id, thumb=False):
m = SHOT_ID.fullmatch(shot_id) if isinstance(shot_id, str) else None
if not m:
raise Failure("bad screenshot id", 400)
return f"{SHOT_ROOT}/{m[1]}/760/remote/{m[2]}/screenshots/{'thumbnails/' if thumb else ''}{m[3]}"
def list_shots():
shots = []
for line in ssh(LIST_SHOTS, timeout=20).splitlines():
rel, _, rest = line.partition("\t")
parts = rel.split("/") # account/760/remote/appid/screenshots/file
size, _, mtime = rest.partition("\t")
shot_id = f"{parts[0]}/{parts[3]}/{parts[-1]}" if len(parts) == 6 else ""
if not SHOT_ID.fullmatch(shot_id) or not size.isdigit():
continue
try:
when = float(mtime)
except ValueError:
continue
local = SHOTS_DIR / parts[-1]
shots.append({"id": shot_id, "appid": parts[3], "file": parts[-1], "size": int(size), "time": when,
"saved": local.exists() and local.stat().st_size == int(size)})
shots.sort(key=lambda s: s["time"], reverse=True)
return {"shots": shots, "folder": str(SHOTS_DIR)}
def shot_image(query):
q = parse_qs(query)
shot_id = (q.get("id") or [""])[0]
full = shot_path(shot_id)
if q.get("thumb") == ["1"]:
# Steam writes the thumbnail a moment after the shot; fall back to the full image.
thumb = shot_path(shot_id, thumb=True)
remote = f"if [ -s {thumb} ]; then cat {thumb}; else cat {full}; fi"
else:
remote = f"cat {full}"
ctype = "image/png" if shot_id.endswith(".png") else "image/jpeg"
return ssh(remote, timeout=30, text=False), ctype
def save_shots(body):
"""Copy screenshots to ~/Pictures/SteamFrame, skipping ones already there."""
ids = body.get("ids")
if not isinstance(ids, list) or not 0 < len(ids) <= 1000:
raise Failure("ids must be a list of 1-1000 screenshot ids", 400)
paths = [shot_path(i) for i in ids]
todo = [p for p in paths if not (SHOTS_DIR / p.rsplit("/", 1)[-1]).exists()]
if todo:
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
ensure_master()
# Copy into a hidden folder and move complete files in, so a cut-off
# copy never looks saved. -p keeps the time the shot was taken.
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try:
try:
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
for f in incoming.iterdir():
os.replace(f, SHOTS_DIR / f.name)
finally:
shutil.rmtree(incoming, ignore_errors=True)
n, skipped = len(todo), len(ids) - len(todo)
msg = f"Saved {n} screenshot{'s' * (n != 1)} to ~/Pictures/SteamFrame"
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
def launch(body): def launch(body):
appid = str(body.get("appid", "")) appid = str(body.get("appid", ""))
if not APPID.match(appid): if not APPID.match(appid):
@@ -287,6 +370,10 @@ def open_thing(body):
if what == "sftp": if what == "sftp":
terminal(f"sftp {alias}") terminal(f"sftp {alias}")
return {"message": "Opened an SFTP session in Terminal"} return {"message": "Opened an SFTP session in Terminal"}
if what == "shots":
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
subprocess.run(["open", str(SHOTS_DIR)])
return {"message": "Opened ~/Pictures/SteamFrame in Finder"}
raise Failure("unknown target", 400) raise Failure("unknown target", 400)
@@ -613,7 +700,7 @@ def android_display(body):
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing} "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots}
# ---- HTTP ------------------------------------------------------------------ # ---- HTTP ------------------------------------------------------------------
@@ -679,6 +766,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(steam_frame("owned")) self.send_json(steam_frame("owned"))
elif path == "/api/steam/search": elif path == "/api/steam/search":
self.send_json(steam_search(url.query)) self.send_json(steam_search(url.query))
elif path == "/api/shots":
self.send_json(list_shots())
elif path == "/api/shots/image":
self.send_bytes(*shot_image(url.query))
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]: elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")]) self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
elif path == "/api/screenshot": elif path == "/api/screenshot":