mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +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
+427
-54
No files matched your search
+122
-10
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user