Merge main into devices: several headsets alongside the app store, comfort, panels and media

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-29 12:44:30 +10:00
commit 02b9413f78
129 files changed
+12672 -148

No files matched your search

+380 -13
View File
@@ -44,18 +44,25 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_agent # noqa: E402
import frame_assistant # noqa: E402
import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
import frame_steamgriddb
import frame_comfort # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
import frame_media # noqa: E402
import frame_panels # noqa: E402
import frame_report # noqa: E402
import frame_store # noqa: E402
import frame_telemetry # noqa: E402
import frame_titles # noqa: E402
import frame_webinstall # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
import frame_compat_db # noqa: E402
frame_host.trust_bundled_cas()
@@ -235,7 +242,41 @@ _jobs_lock = threading.Lock()
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
def start_job(label, work):
_backfill = {"running": False, "last": 0.0}
_backfill_lock = threading.Lock()
def backfill_art(apps=(), titles=()):
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
names the user may have customised. Runs in the background, at most once every five minutes."""
pkgs = [a["package"] for a in apps if a.get("art_pending")]
gids = [t["id"] for t in titles if t.get("art_pending")]
with _backfill_lock:
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
return False
_backfill.update(running=True, last=time.time())
def run():
try:
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
[(frame_titles.refresh_art, g) for g in gids]:
try:
refresh(key, fill_only=True)
except Exception as e:
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
except Exception:
pass
finally:
with _backfill_lock:
_backfill["running"] = False
threading.Thread(target=run, daemon=True).start()
return True
def start_job(label, work, progress=False):
"""Run work() in the background. It returns a dict with a "message"."""
now = time.time()
with _jobs_lock:
@@ -244,14 +285,20 @@ def start_job(label, work):
job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
def report(stage, percent=None):
with _jobs_lock:
_jobs[job].update(stage=stage, percent=percent)
def run():
fields = {}
try:
result = work()
result = work(report) if progress else work()
fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
except SourceError as e: # already user-readable, and about a store, not the Frame
fields = {"error": str(e)}
except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
@@ -322,6 +369,37 @@ def status(_body):
return s
def comfort(body):
try:
frame_comfort.validate(body)
except ValueError as e:
raise Failure(str(e), 400)
# Content-addressed, user-only helper bundle. Desktop and phone use the same
# on-headset state/lock; no listener, service registration or third-party app.
import hashlib
files = {name: (HERE / name).read_text() for name in
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
script = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
""" % (files, version, json.dumps(body))
out = json.loads(ssh("python3 -", stdin=script, timeout=65))
if out.get("error") and "active" not in out:
raise Failure(out["error"], 409)
return out
def headset_view():
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
# Counts as work: the copy and clean-up must reach the headset that took the capture.
@@ -458,6 +536,7 @@ _stream_proc = None
def stream_command(query):
"""The ffmpeg that streams H.264: the headset view, or one panel's own window (src=panel)."""
q = parse_qs(query)
try:
height = int((q.get("h") or ["720"])[0])
@@ -467,6 +546,16 @@ def stream_command(query):
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
rate = 3 if height == 720 else 6 # Mbit/s
if q.get("src") == ["panel"]:
# The panel's own pixels (x11grab of its window: gamescope keeps them), fitted
# to the height asked for. It stays still however the wearer moves their head.
window, display = panel_target(q)
return (f"DISPLAY={display} ffmpeg -hide_banner -loglevel error -nostdin -f x11grab -framerate {fps} "
f"-window_id {window} -i {display} "
f"-vf \"scale=-2:'trunc(min({height},ih)/2)*2',format=yuv420p\" -c:v libx264 -preset ultrafast "
f"-tune zerolatency -g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
@@ -519,6 +608,27 @@ def steam(body):
return res
def vr(body):
try:
action = frame_vr.validate(body)
except ValueError as e:
raise Failure(str(e), 400)
sources = {name: (HERE / (name + '.py')).read_text() for name in ('frame_status', 'frame_vr')}
script = "import sys, types, json, os\n"
for name, source in sources.items():
script += f"m = types.ModuleType({name!r}); sys.modules[{name!r}] = m\nexec({source!r}, m.__dict__)\n"
# Only the optional HUD needs files: its terminal child survives this SSH call.
if action == 'hud-start':
script += "m.ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)\n"
for name, source in sources.items():
script += f"p = m.ROOT / {name + '.py'!r}; t = p.with_suffix('.' + str(os.getpid()) + '.tmp')\nt.write_text({source!r}); os.replace(t, p)\n"
script += f"try:\n print(json.dumps(m.dispatch({body!r})))\nexcept Exception as e:\n print(json.dumps({{'error': str(e)}}))\n"
result = json.loads(ssh('python3 -', stdin=script, timeout=20))
if result.get('error'):
raise Failure(result['error'])
return result
def steam_search(query):
q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper()
@@ -686,7 +796,7 @@ class InputAgent:
"""
def __init__(self, source=HERE / "frame_input_agent.py", packages=None):
self.source, self.proc, self.lock = source, None, threading.Lock()
self.source, self.proc, self.lock, self.write_lock = source, None, threading.Lock(), threading.Lock()
self.packages = kdeconnect_packages() if packages is None else packages
# generation counts stop()s; launching is the generation a launch is under way for.
self.status, self.launching, self.generation = {"state": "off"}, None, 0
@@ -859,8 +969,10 @@ class InputAgent:
self.start()
elif ready and events:
try:
proc.stdin.write((json.dumps(events) + "\n").encode())
proc.stdin.flush()
# One writer at a time: two devices sending at once mustn't tear a line.
with self.write_lock:
proc.stdin.write((json.dumps(events) + "\n").encode())
proc.stdin.flush()
sent = True
except (BrokenPipeError, OSError, ValueError):
pass # _watch reports how it ended
@@ -900,6 +1012,115 @@ def remote_input(body):
return _input.send([input_event(e) for e in events])
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
PANEL_WINDOW = re.compile(r"^\d{1,10}$")
PANEL_DISPLAY = re.compile(r"^:\d{1,2}$")
TOUCH_BUTTONS = ("left", "right", "middle")
def panels():
"""The headset's app panels (window, display, name, size) and which has focus."""
return json.loads(ssh("python3 - panels", stdin=(HERE / "frame_touch.py").read_text(), timeout=20))
def panel_target(q):
window, display = (q.get("window") or [""])[0], (q.get("display") or [""])[0]
if not PANEL_WINDOW.match(window) or not PANEL_DISPLAY.match(display):
raise Failure("window must be a window id and display an X display such as :1", 400)
return window, display
def panel_capture(query):
"""One frame of a panel's own window, as PNG (its pixels, without the room around it)."""
window, display = panel_target(parse_qs(query))
return ssh(f"DISPLAY={display} timeout 10 ffmpeg -hide_banner -loglevel error -nostdin -f x11grab "
f"-window_id {window} -i {display} -frames:v 1 -f image2pipe -c:v png -", timeout=20, text=False)
def touch_event(event):
"""A frame_touch.py event with only the fields it knows, in range."""
if not isinstance(event, dict):
raise Failure("each touch event must be an object", 400)
def num(name, limit):
value = event.get(name)
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
raise Failure(f"{name} must be a number", 400)
return max(-limit, min(limit, round(float(value), 4)))
out = {}
if "fx" in event or "fy" in event:
if "window" not in event:
raise Failure("a position needs the panel's window and display", 400)
out.update(fx=num("fx", 1), fy=num("fy", 1))
# Any event can name the panel it's meant for; the Frame drops it if another has focus.
if "window" in event:
window, display = event.get("window"), event.get("display")
if isinstance(window, bool) or not isinstance(window, int) or window <= 0:
raise Failure("window must be the panel's window id", 400)
if not isinstance(display, str) or not PANEL_DISPLAY.match(display):
raise Failure("display must be an X display such as :1", 400)
out.update(window=window, display=display)
for name in ("dx", "dy"):
if name in event:
out[name] = num(name, INPUT_MOVE_LIMIT)
if "button" in event:
if event["button"] not in TOUCH_BUTTONS:
raise Failure(f"button must be one of {', '.join(TOUCH_BUTTONS)}", 400)
out["button"], out["down"] = event["button"], event.get("down") is not False
if "scroll" in event:
sc = event["scroll"]
if not isinstance(sc, list) or len(sc) != 2:
raise Failure("scroll must be [dx, dy]", 400)
out["scroll"] = [num_value(v, 5000) for v in sc]
if "key" in event:
key = event["key"]
if isinstance(key, bool) or not isinstance(key, int) or not 0 < key < 768:
raise Failure("key must be a Linux key code", 400)
out["key"], out["down"] = key, event.get("down") is not False
if "text" in event:
text = event["text"]
if not isinstance(text, str) or not 0 < len(text) <= INPUT_TEXT_LIMIT:
raise Failure(f"text must be 1 to {INPUT_TEXT_LIMIT} characters", 400)
out["text"] = text
if not set(out) - {"window", "display"}:
raise Failure("touch event has nothing to do", 400)
return out
def num_value(value, limit):
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
raise Failure("scroll values must be numbers", 400)
return max(-limit, min(limit, round(float(value), 2)))
class TouchAgent(InputAgent):
"""frame_touch.py on the Frame, fed events over one long-lived ssh. Nothing to install:
it uses gamescope's own input socket and the libei that's on the image."""
def __init__(self):
super().__init__(source=HERE / "frame_touch.py", packages=[])
def deliver(self, report, force=False):
return ""
def command(self, folder=""):
code = base64.b64encode(self.source.read_bytes()).decode()
return "python3 -u -c " + shlex.quote(
f"import base64;exec(compile(base64.b64decode('{code}'),'frame_touch','exec'))")
_touch = TouchAgent()
def remote_touch(body):
"""{"events": [...]} points, clicks, scrolls and types into the focused panel."""
events = body.get("events", [])
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
return _touch.send([touch_event(e) for e in events])
def flatpak(body):
app, action = str(body.get("id", "")), body.get("action")
if not FLATPAK_ID.match(app):
@@ -1001,6 +1222,14 @@ def android(body):
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
"app": m}
return start_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
raise Failure('choose a package or all apps', 400)
if not body.get('all'):
return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]})
# Everything Frame Control sideloaded: Android apps and devkit titles.
return start_job('Refresh Steam artwork', lambda: {
'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()})
if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg)
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
@@ -1148,12 +1377,14 @@ def titles(body):
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
return {"message": f"Installing {entry['plan']['source']}", "job": token}
if action not in ("launch", "remove"):
if action not in ("launch", "remove", "refresh-art"):
raise Failure("unknown action", 400)
gid = str(body.get("id", ""))
if not frame_titles.ID_RE.match(gid):
raise Failure("bad title id", 400)
ensure_master()
if action == "refresh-art":
return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]})
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
@@ -1677,6 +1908,32 @@ def _sweep_one(prefix, d):
pass
# ---- Panel switcher (same helper on the companion and in the headset) ----
def panels_action(body):
action = body.get("action", "list")
script = (HERE / "frame_panels.py").read_text()
if action == "open":
# Installed in the user account so the page can outlive this SSH call.
remote = ('umask 077; mkdir -p ~/.local/share/frame-control/panels && '
'tmp=$(mktemp ~/.local/share/frame-control/panels/install.XXXXXX) && '
'cat > "$tmp" && mv "$tmp" ~/.local/share/frame-control/panels/switcher.py && '
'python3 ~/.local/share/frame-control/panels/switcher.py --open')
elif action == "list":
remote = "python3 -"
elif action == "focus":
key = body.get("key")
if not isinstance(key, str) or not frame_panels.KEY.fullmatch(key):
raise Failure("Choose an open panel.", 400)
remote = "python3 - --focus " + shlex.quote(key)
else:
raise Failure("Unknown panel action", 400)
result = json.loads(ssh(remote, stdin=script, timeout=45))
if "error" in result:
raise Failure(result["error"], 502)
return result
# ---- Our Frame-side media player -----------------------------------------
_MEDIA_LOCK = threading.Lock()
@@ -1812,18 +2069,94 @@ def agent_approval(body):
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
POST = {"/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/input": remote_input,
def source_text(body, key, optional=False):
value = body.get(key)
if optional and value in (None, ''):
return None
if not isinstance(value, str) or not value.strip() or len(value) > 2000:
raise Failure('Provide a valid ' + key, 400)
return value.strip()
def source_search(query):
args = parse_qs(query)
q = args.get('q', [''])[0]
vr = args.get('vr', [''])[0]
installable = args.get('installable', [''])[0]
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
raise Failure('Invalid search filters', 400)
try:
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
def source_install(body):
source, entry = source_text(body, 'source'), source_text(body, 'id')
version = body.get('version_code')
if version is not None and (type(version) is not int or version < 0):
raise Failure('version_code must be a non-negative integer', 400)
try:
_, selected = apk_search.resolve(source)
if not selected['enabled']:
raise SourceError('This source is disabled')
except SourceError as e:
raise Failure(str(e), 400)
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
def source_manage(body):
action = body.get('action')
try:
if action == 'enable':
if type(body.get('enabled')) is not bool:
raise Failure('enabled must be true or false', 400)
return apk_search.set_enabled(source_text(body, 'source'), body['enabled'])
if action == 'add':
url = source_text(body, 'url')
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
parts = urlparse(url)
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
apk_search.repo_module() # fail now if this build can't manage repositories
def add(): # downloads and verifies the whole index: a job, not a request
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
message = 'Added ' + source['name']
if source.get('trust_on_first_use'):
message += '. Trusted on first use: ' + source['fingerprint'].upper()
return {'message': message, 'source': {k: source.get(k) for k in
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
return start_job('Add repository', add)
if action == 'game-data':
package = source_text(body, 'package')
return start_job('Add game data', lambda: apk_search.add_game_data(package))
if action == 'remove':
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
return {'message': 'Repository removed'}
raise Failure('Unknown source action', 400)
except SourceError as e:
raise Failure(str(e), 400)
POST = {
"/api/vr": vr,
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
"/api/devices": lambda body: devices_post(body)}
# ---- headsets and the connection (frame_devices.py, frame_link.py) ----------
def open_setup(alias, host=None):
@@ -1973,6 +2306,10 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/assistant":
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
self.send_bytes(page.encode(), "text/html; charset=utf-8")
@@ -1989,18 +2326,43 @@ class Handler(BaseHTTPRequestHandler):
self.connection_events()
elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"):
self.send_json(devices_get(path, url.query))
elif path.startswith("/source-image/"):
from apk_sources import _images
try:
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
except Exception:
self.send_json({"error": "Artwork unavailable"}, 404)
elif path == "/api/sources/details":
args = parse_qs(url.query)
try:
self.send_json(apk_search.details(source_text({k: v[0] for k, v in args.items()}, "source"),
source_text({k: v[0] for k, v in args.items()}, "id")))
except SourceError as e:
raise Failure(str(e), 400)
elif path == "/api/sources":
self.send_json({"sources": apk_search.sources()})
elif path == "/api/search":
self.send_json(source_search(url.query))
elif path == "/api/apk-versions":
self.send_json(apk_versions(url.query))
elif path == "/api/android":
ensure_master()
self.send_json({"apps": frame_android.list_apps()})
apps = frame_android.list_apps()
backfill_art(apps=apps)
self.send_json({"apps": apps})
elif path == "/api/titles":
ensure_master()
self.send_json({"titles": frame_titles.list_titles()})
titles_list = frame_titles.list_titles()
backfill_art(titles=titles_list)
self.send_json({"titles": titles_list})
elif path == "/api/titles/job":
self.send_json(title_job(url.query))
elif path == "/api/licenses":
self.send_json({"notices": licenses()})
elif path == "/api/panels":
self.send_json(panels())
elif path == "/api/touch":
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/input":
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
elif path == "/api/job":
@@ -2012,6 +2374,8 @@ class Handler(BaseHTTPRequestHandler):
"shared": frame_catalog.compat_db.shared()})
elif path == "/api/android/catalog":
self.send_json({"apps": frame_catalog.catalog()})
elif path == "/api/vr/utilities":
self.send_json(frame_utilities.catalogue(steam_frame("utilities")["utilities"], frame_compat_db.load()))
elif path == "/api/macview":
self.send_json(macview_state(parse_qs(url.query)))
elif path == "/api/telemetry":
@@ -2032,6 +2396,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_bytes(*shot_image(url.query))
elif path == "/api/stream":
self.stream_video(url.query)
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["panel"]:
self.send_bytes(panel_capture(url.query), "image/png", headers=[("X-Capture-Source", "panel")])
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":
@@ -2136,7 +2502,7 @@ class Handler(BaseHTTPRequestHandler):
proc.wait()
errors.seek(0)
err = strip_ansi(errors.read().decode(errors="replace")).strip()
raise Failure(err or "The headset view sent no video for 20 s")
raise Failure(err or "The Frame sent no video for 20 s")
chunk = first
try:
self.send_response(200)
@@ -2276,6 +2642,7 @@ def main():
args = ap.parse_args()
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
sweep_tmp()
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
frame_telemetry.start()
global LINK, _ONE_SERVER
if not LOCAL: