mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
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:
1 parent
99653151c4
commit
eabf4cd1f9
10 files changed
+422
-49
No files matched your search
@@ -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` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
[[ $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=""
|
url=""
|
||||||
for i in {1..40}; do
|
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)
|
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
|
[[ -n $url ]] && break
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
done
|
done
|
||||||
if [[ -n $url ]]; then
|
[[ -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"
|
||||||
@@ -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
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||||
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
|
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
@@ -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":
|
||||||
|
|||||||
Reference in new issue
Block a user