mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Mac in the headset: stream Mac windows and screens into the Frame as panels
Frame Control can now show any Mac window, or a whole screen, as its own SteamVR panel on the Steam Frame (Tools -> Mac in the headset, macOS only). Place it with the SteamVR dashboard; the laser clicks and scrolls, and the Mac's own keyboard types. - mac/frame-mac-view (Swift, no dependencies): ScreenCaptureKit capture per window or display, VideoToolbox H.264 with low-latency rate control (JPEG fallback), a loopback HTTP/WebSocket server, CGEvent/AX input playback, and a display-awake assertion while anyone watches. - ui/frame_macview.py: starts the agent, runs an ssh -R tunnel with a supervisor that reopens it on the same port, and launches a Chromium app window per stream on gamescope's :0, tagged with STEAM_GAME for its own panel. - Frame Control's key never leaves the Mac: viewers get single-use, per-source tickets and reconnect keys that Stop revokes. - ui/mac-view.html: WebCodecs decode, keyframe recovery, pointer/wheel/keys back. - Bundled in the Mac app build; tests/test_macview.py builds and drives the agent on macOS. Verified on the Frame (build 20260925.6191901) with the test pattern: panel in about 1.5 s, about 60 fps, Mac-to-window about 11-17 ms, tunnel recovery in 4 s. Laser input and real window capture still need a person in the headset. Also commits the other thread's first-party rule (steam-frame skill) and the first-party options table in docs/streaming.md. Reviewed by GPT-6 Astra (xhigh, read-only) over six rounds; all findings fixed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0016d9200c
commit
a3c6e5c984
20 files changed
+2708
-6
No files matched your search
@@ -0,0 +1,361 @@
|
||||
"""Mac in the headset: stream Mac windows or displays into the Steam Frame as
|
||||
panels you can place anywhere, with the laser, wheel and keys driving the Mac.
|
||||
|
||||
Runs on the Mac, inside Frame Control's server. The pieces:
|
||||
|
||||
- mac/bin/frame-mac-view (Swift, built from mac/frame-mac-view): captures with
|
||||
ScreenCaptureKit, encodes with VideoToolbox, serves ui/mac-view.html and one
|
||||
WebSocket per stream on 127.0.0.1, and plays input back with CGEvent.
|
||||
- An `ssh -R` tunnel, so the Frame reaches the agent on its own 127.0.0.1.
|
||||
Every request carries a random token, so other programs on the Frame
|
||||
(Android apps included) can't watch or drive the Mac.
|
||||
- A Chromium app window on the Frame per stream, on gamescope's X display
|
||||
with its own STEAM_GAME id, which makes it its own SteamVR panel (see
|
||||
docs/panels.md). Chromium XR (~/chromium-xr) is preferred because it's
|
||||
built with H.264; Flathub Chromium is the fallback.
|
||||
|
||||
Stdlib only. MacView gets a plain ssh argv for the tunnel (its own
|
||||
connection) and the server's `run(remote, stdin=, timeout=)` for commands.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
PAGE = HERE / "mac-view.html"
|
||||
SOURCES = ROOT / "mac" / "frame-mac-view"
|
||||
AGENT = Path(os.environ.get("FRAME_MAC_VIEW") or ROOT / "mac" / "bin" / "frame-mac-view")
|
||||
REMOTE_PORTS = range(47900, 47920)
|
||||
SUPPORTED = sys.platform == "darwin"
|
||||
|
||||
# Stream settings by name: long side in pixels, frames per second, H.264 bits
|
||||
# per pixel per frame, codec. JPEG is for a Frame browser without H.264.
|
||||
QUALITY = {
|
||||
"sharp": {"max": 2560, "fps": 60, "bpp": 0.14, "codec": "h264"},
|
||||
"balanced": {"max": 1920, "fps": 60, "bpp": 0.1, "codec": "h264"},
|
||||
"light": {"max": 1280, "fps": 30, "bpp": 0.08, "codec": "h264"},
|
||||
"compatible": {"max": 1280, "fps": 20, "bpp": 0.1, "codec": "jpeg"},
|
||||
}
|
||||
# Viewer windows are fitted inside a panel's size. gamescope made a 1280x720
|
||||
# request 1920x1080 anyway (verified 2026-09-28, build 20260925.6191901).
|
||||
PANEL_BOX = (1920, 1080)
|
||||
|
||||
# Opens one viewer on gamescope's X display and gives its window its own panel
|
||||
# id. Args: appid url width height tag. The page puts "[tag]" in its title at
|
||||
# once, which is how its X window is found (Chromium may hand the URL to an
|
||||
# instance that's already running, so there's no process to follow).
|
||||
LAUNCH = r"""set -u
|
||||
appid=$1 url=$2 w=$3 h=$4 tag=$5
|
||||
export DISPLAY=:0 LC_ALL=C.UTF-8
|
||||
unset WAYLAND_DISPLAY
|
||||
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
|
||||
echo "The headset isn't showing anything (gamescope's display :0 isn't up). Wake it and try again." >&2
|
||||
exit 2
|
||||
fi
|
||||
common=(--ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check
|
||||
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars
|
||||
--disable-features=Translate,MediaRouter --autoplay-policy=no-user-gesture-required
|
||||
"--window-size=$w,$h" "--app=$url")
|
||||
if [ -x "$HOME/chromium-xr/chrome" ]; then
|
||||
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/mac-view" "${common[@]}")
|
||||
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
|
||||
cmd=(flatpak run org.chromium.Chromium
|
||||
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-mac-view" "${common[@]}")
|
||||
else
|
||||
echo "NO_BROWSER"
|
||||
exit 3
|
||||
fi
|
||||
log=/tmp/frame-mac-view.log
|
||||
setsid nohup "${cmd[@]}" >>"$log" 2>&1 </dev/null &
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 0.5
|
||||
# xprop, not xwininfo: in a C locale xwininfo can't print a non-ASCII title
|
||||
# at all, while xprop escapes those bytes and leaves the ASCII tag readable.
|
||||
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
|
||||
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "[$tag]" || continue
|
||||
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
|
||||
echo "panel valve.steam.desktopgame.$appid window $win"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "The viewer started, but its window didn't appear within 30 s. Chromium's log:" >&2
|
||||
tail -n 15 "$log" >&2
|
||||
exit 1
|
||||
"""
|
||||
|
||||
|
||||
class MacViewError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def panel_id(src):
|
||||
"""A stable panel id per source, in the range panel-on-frame.sh uses."""
|
||||
return 2_001_000_000 + zlib.crc32(f"mac:{src}".encode()) % 1_000_000
|
||||
|
||||
|
||||
def fit(w, h, box=PANEL_BOX):
|
||||
s = min(box[0] / max(w, 1), box[1] / max(h, 1))
|
||||
return max(320, round(w * s)), max(200, round(h * s))
|
||||
|
||||
|
||||
class MacView:
|
||||
def __init__(self, tunnel_ssh, run, frame, track=None):
|
||||
self.tunnel_ssh = list(tunnel_ssh)
|
||||
self.run = run
|
||||
self.frame = frame
|
||||
self.track = track or (lambda proc: None) # the server ends these on exit
|
||||
self.lock = threading.Lock()
|
||||
self.token = secrets.token_urlsafe(24)
|
||||
self.agent = None
|
||||
self.port = None
|
||||
self.tunnel = None
|
||||
self.remote_port = None
|
||||
self.supervisor = None
|
||||
self.closing = False
|
||||
self.shown = set() # sources with a viewer out there, connected or retrying
|
||||
|
||||
# ---- the agent on this Mac ----
|
||||
|
||||
def unavailable(self):
|
||||
"""Why this can't work here, or None."""
|
||||
if not SUPPORTED:
|
||||
return "Streaming your computer into the headset needs macOS."
|
||||
if not AGENT.exists() and not (SOURCES.exists() and shutil.which("xcrun")):
|
||||
return "The Mac streaming helper is missing from this copy of Frame Control."
|
||||
return None
|
||||
|
||||
def build(self):
|
||||
if AGENT.exists() and not self._stale():
|
||||
return
|
||||
if not (SOURCES / "build.sh").exists():
|
||||
if AGENT.exists():
|
||||
return
|
||||
raise MacViewError("The Mac streaming helper is missing.")
|
||||
r = subprocess.run(["/bin/sh", str(SOURCES / "build.sh"), str(AGENT)], capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=600)
|
||||
if r.returncode != 0:
|
||||
raise MacViewError("Couldn't build the Mac streaming helper: " + (r.stderr or r.stdout).strip()[-400:])
|
||||
|
||||
def _stale(self):
|
||||
try:
|
||||
built = AGENT.stat().st_mtime
|
||||
return any(p.stat().st_mtime > built for p in (SOURCES / "Sources").glob("*.swift"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def ensure_agent(self):
|
||||
with self.lock:
|
||||
if self.agent and self.agent.poll() is None:
|
||||
return
|
||||
reason = self.unavailable()
|
||||
if reason:
|
||||
raise MacViewError(reason)
|
||||
self.build()
|
||||
env = {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
|
||||
# The same port as before when restarting, so a running tunnel still
|
||||
# fits; otherwise (or if it's gone) whatever the system gives.
|
||||
for port in dict.fromkeys([self.port or 0, 0]):
|
||||
self.agent = subprocess.Popen([str(AGENT), "serve", "--port", str(port), "--page", str(PAGE),
|
||||
"--exit-on-eof"], env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
line = self.agent.stdout.readline()
|
||||
m = re.search(r"listening on 127\.0\.0\.1:(\d+)", line)
|
||||
if m:
|
||||
break
|
||||
self.agent.kill()
|
||||
else:
|
||||
raise MacViewError(f"The Mac streaming helper didn't start: {line.strip() or 'no output'}")
|
||||
if self.port != int(m.group(1)):
|
||||
self._drop_tunnel()
|
||||
self.port = int(m.group(1))
|
||||
self.track(self.agent)
|
||||
threading.Thread(target=self.agent.stdout.read, daemon=True).start() # drain
|
||||
|
||||
def call(self, path, method="GET", **query):
|
||||
"""A request to the agent; starts it if needed."""
|
||||
self.ensure_agent()
|
||||
url = f"http://127.0.0.1:{self.port}{path}?{urlencode({**query, 'k': self.token})}"
|
||||
req = urllib.request.Request(url, method=method, data=b"" if method == "POST" else None)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.load(r)
|
||||
except (urllib.error.URLError, OSError, ValueError) as e:
|
||||
raise MacViewError(f"The Mac streaming helper didn't answer: {e}")
|
||||
|
||||
# ---- the tunnel from the Frame ----
|
||||
|
||||
def _drop_tunnel(self):
|
||||
if self.tunnel and self.tunnel.poll() is None:
|
||||
self.tunnel.terminate()
|
||||
self.tunnel = None
|
||||
|
||||
def tunnel_up(self):
|
||||
return self.tunnel is not None and self.tunnel.poll() is None
|
||||
|
||||
def ensure_tunnel(self, allow_new_port=False):
|
||||
"""Open the tunnel, on the port viewers already use if there is one.
|
||||
A new port only when nobody is watching, since viewers can't move."""
|
||||
with self.lock:
|
||||
if self.tunnel_up():
|
||||
return
|
||||
last = ""
|
||||
ports = [self.remote_port] if self.remote_port else list(REMOTE_PORTS)
|
||||
if self.remote_port and allow_new_port:
|
||||
ports += [p for p in REMOTE_PORTS if p != self.remote_port]
|
||||
for port in ports:
|
||||
proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
|
||||
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
# A taken port makes ssh exit once it's connected; a working
|
||||
# tunnel answers the agent's status from the Frame's side.
|
||||
ok = False
|
||||
for _ in range(15):
|
||||
time.sleep(0.4)
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
if self._probe(port):
|
||||
ok = True
|
||||
break
|
||||
if ok:
|
||||
self.tunnel, self.remote_port = proc, port
|
||||
self.track(proc)
|
||||
self._supervise()
|
||||
return
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
last = (proc.stderr.read() or "").strip()
|
||||
if "forward" not in last.lower():
|
||||
break # not a port clash: the Frame is unreachable
|
||||
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
|
||||
|
||||
def _supervise(self):
|
||||
"""Reopen the tunnel on the same port after the headset sleeps or the
|
||||
network drops, so viewers that are retrying find the Mac again."""
|
||||
if self.supervisor and self.supervisor.is_alive():
|
||||
return
|
||||
|
||||
def loop():
|
||||
while not self.closing:
|
||||
time.sleep(5)
|
||||
if self.closing or self.tunnel_up() or not (self.agent and self.agent.poll() is None):
|
||||
continue
|
||||
try:
|
||||
self.ensure_tunnel()
|
||||
except MacViewError:
|
||||
pass # still unreachable; try again shortly
|
||||
|
||||
self.supervisor = threading.Thread(target=loop, daemon=True)
|
||||
self.supervisor.start()
|
||||
|
||||
def _probe(self, port):
|
||||
"""Whether the Frame reaches the agent through the tunnel on `port`."""
|
||||
# /ping needs no key, so none appears on the Frame's command lines.
|
||||
cmd = f"curl -s -m 2 'http://127.0.0.1:{port}/ping'"
|
||||
try:
|
||||
return self.run(cmd, timeout=8).strip() == "frame-mac-view"
|
||||
except Exception: # noqa: BLE001 - the server's Failure, timeouts: all mean "not yet"
|
||||
return False
|
||||
|
||||
# ---- viewers on the Frame ----
|
||||
|
||||
def show(self, src, quality="balanced", width=None, height=None):
|
||||
if src != "test" and not (src.startswith("window:") or src.startswith("display:")):
|
||||
raise MacViewError("Pick a window or display to show.")
|
||||
q = QUALITY.get(quality) or QUALITY["balanced"]
|
||||
state = self.call("/status")
|
||||
if src != "test" and not state.get("screen"):
|
||||
raise MacViewError("Frame Control needs Screen Recording permission first (Allow… under Mac in the headset).")
|
||||
self.shown -= set(state.get("finished", [])) # their Mac windows closed
|
||||
if src in self.shown or any(st.get("src") == src for st in state.get("streams", [])):
|
||||
# Showing it again replaces the old viewer, connected or not: this
|
||||
# revokes its keys so it can't come back alongside the new one.
|
||||
self.stop(src)
|
||||
# Viewers that lost the tunnel keep retrying its old port, even though
|
||||
# the agent no longer counts them, so only move when none are out there.
|
||||
others = self.shown - {src}
|
||||
self.ensure_tunnel(allow_new_port=not others)
|
||||
appid = panel_id(src)
|
||||
# Unique per launch, so a new window is never confused with an old one.
|
||||
tag = "fc" + secrets.token_hex(4)
|
||||
# A single-use ticket for this source, not Frame Control's key: the URL
|
||||
# is visible in the Frame's process list.
|
||||
ticket = self.call("/ticket", method="POST", src=src)["ticket"]
|
||||
params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
|
||||
"bpp": q["bpp"]}
|
||||
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
|
||||
w, h = fit(width or 1280, height or 720)
|
||||
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag))
|
||||
try:
|
||||
out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
|
||||
except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
|
||||
if "NO_BROWSER" in (getattr(e, "stdout", "") or ""):
|
||||
raise MacViewError("The Frame needs a browser for this: install Chromium (Tools → Linux apps, "
|
||||
"org.chromium.Chromium) or Chromium XR.")
|
||||
raise MacViewError(str(e))
|
||||
self.shown.add(src)
|
||||
return {"panel": f"valve.steam.desktopgame.{appid}", "src": src, "detail": out.strip()}
|
||||
|
||||
def stop(self, src=None):
|
||||
if src:
|
||||
self.shown.discard(src)
|
||||
else:
|
||||
self.shown.clear()
|
||||
if not (self.agent and self.agent.poll() is None):
|
||||
return {"closed": 0}
|
||||
return self.call("/close", method="POST", **({"src": src} if src else {}))
|
||||
|
||||
def state(self):
|
||||
reason = self.unavailable()
|
||||
if reason:
|
||||
return {"available": False, "reason": reason}
|
||||
status = self.call("/status")
|
||||
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
|
||||
displays = self.call("/displays").get("displays", [])
|
||||
return {"available": True, "screen": status.get("screen", False),
|
||||
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
|
||||
"windows": windows, "displays": displays, "tunnel": self.tunnel_up()}
|
||||
|
||||
def restart_agent(self):
|
||||
"""Only if it's missing a permission: a running stream would stop."""
|
||||
with self.lock:
|
||||
if not (self.agent and self.agent.poll() is None):
|
||||
return
|
||||
status = self.call("/status")
|
||||
if status.get("screen") and status.get("accessibility"):
|
||||
return
|
||||
with self.lock:
|
||||
self.agent.terminate()
|
||||
self.agent.wait(5)
|
||||
|
||||
def request_permissions(self):
|
||||
return self.call("/permissions", method="POST")
|
||||
|
||||
def shutdown(self):
|
||||
self.closing = True
|
||||
try:
|
||||
self.stop()
|
||||
except MacViewError:
|
||||
pass
|
||||
for proc in (self.tunnel, self.agent):
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
|
||||
def _quote(s):
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
Reference in new issue
Block a user