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

+122 -10
View File
@@ -152,6 +152,17 @@
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
/* ---- Steam screenshots from the headset ---- */
.shot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.shot-card { display: flex; flex-direction: column; gap: 6px; }
.shot-card .thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: rgba(0,0,0,.3);
display: block; cursor: zoom-in; box-shadow: 0 6px 16px rgba(0,0,0,.45); }
.shot-card .thumb:hover { box-shadow: 0 6px 16px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.25); }
.shot-card .row { flex-wrap: nowrap; }
.shot-card .grow { flex: 1; min-width: 0; }
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shot-card .s { color: var(--muted); font-size: 12px; }
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
.capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
@@ -279,6 +290,7 @@
</a>
<nav id="nav">
<a href="#view" class="on">View</a>
<a href="#shots">Shots</a>
<a href="#library">Library</a>
<a href="#getgames">Games</a>
<a href="#android">Android</a>
@@ -369,6 +381,16 @@
</section>
</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">
<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>
@@ -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.",
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 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 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() {
const img = lastImg, c = $("canvas");
// 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 sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
if (c.width !== sw || c.height !== sh) {
@@ -819,6 +841,7 @@ function isBlank(ctx, w, h) {
return max - min < 6;
}
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
let url = null;
try {
@@ -829,7 +852,8 @@ async function capture() {
url = URL.createObjectURL(await r.blob());
const img = new Image();
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();
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
$("saveBtn").disabled = false;
@@ -863,14 +887,17 @@ function toggleLive(on) {
}
$("shotBtn").onclick = () => capture();
$("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);
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
}, "image/png");
function download(blob, name) {
const a = document.createElement("a");
a.href = URL.createObjectURL(b);
a.download = `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}, "image/png");
}
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
@@ -1382,15 +1409,100 @@ $("repForm").onsubmit = async e => {
};
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 ----
const spy = new IntersectionObserver(entries => {
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));
}, { 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");
refresh();
refresh().then(loadShots); // after status, so app names resolve
setInterval(() => { if (!document.hidden) refresh(); }, 30000);
</script>
</body>
+92 -1
View File
@@ -189,6 +189,89 @@ def headset_view():
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):
appid = str(body.get("appid", ""))
if not APPID.match(appid):
@@ -287,6 +370,10 @@ def open_thing(body):
if what == "sftp":
terminal(f"sftp {alias}")
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)
@@ -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,
"/api/flatpak": flatpak, "/api/open": open_thing}
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots}
# ---- HTTP ------------------------------------------------------------------
@@ -679,6 +766,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(steam_frame("owned"))
elif path == "/api/steam/search":
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"]:
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
elif path == "/api/screenshot":