Devices: several headsets, several addresses each, and live connection status

Frame Control can now manage more than one Steam Frame, and reach each at any
of several addresses (LAN IPs per network, its .local name, Tailscale). A
connector in the server tries them all at once, picks the best one that
answers, follows ssh -v through each stage (network, finding, SSH, identity,
login) and streams that to the page. The header shows it live; a new Devices
tab (key 5) manages headsets, addresses and network names.

- ui/frame_devices.py: registry in devices.json, imported from the managed
  ~/.ssh/config blocks; per-headset host key pinning; config block updates.
- ui/frame_network.py: gateway IP+MAC fingerprint, Wi-Fi name, Tailscale.
- ui/frame_link.py: the connector, Test now, Tailscale/mDNS discovery, API.
- server.py: ensure_master delegates to the connector; /api/connection,
  /api/connection/events (SSE), /api/devices.
- Electron: headset switcher and Devices item in the Frame menu.
- frame_connect.py --alias; FRAME_CONTROL_DATA_DIR / FRAME_CONTROL_SSH_DIR
  keep tests off real data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 21:13:58 +10:00
1 parent dcf9689f64
commit a954fc83c9
24 files changed
+3597 -61

No files matched your search

+16 -2
View File
@@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of
scripts/connect.sh (which the Mac app uses); same config block, so either can
re-run over the other. Idempotent.
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for
terminals that don't pass the environment on, like Windows' `start`)
"""
import base64
import json
@@ -367,9 +368,22 @@ def pair_with_devkit(host, port, user):
return chosen[0], "paired, but key login still fails"
def use_alias(alias):
"""--alias: set up another headset under its own ~/.ssh/config alias (Devices tab)."""
global FRAME_ALIAS, BEGIN, END
if not NAME_RE.fullmatch(alias):
sys.exit(f"--alias must be a plain name, not {alias!r}")
FRAME_ALIAS = alias
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
def main(argv):
if argv and argv[0] in ("-h", "--help"):
sys.exit(__doc__)
if len(argv) >= 2 and argv[0] == "--alias":
use_alias(argv[1])
argv = argv[2:]
say("==> Looking for the Steam Frame")
found = pick_host(argv[0] if argv else None)
while not found:
+620
View File
@@ -0,0 +1,620 @@
"""The headsets Frame Control knows, and the addresses each can be reached at.
One headset can answer at several addresses: a LAN IP at home, another in the
office, its mDNS name (frame.local), its Tailscale IP or MagicDNS name. The
registry keeps them all, learns which worked on which network, and hands the
connector (frame_link.py) an order to try them in.
Stored as JSON in frame_host.data_dir("devices.json"). The format is plain so the
iPhone app can share it later; docs/devices.md describes it:
{"version": 1, "active": "<device id>",
"devices": [{"id", "name", "alias", "user", "port", "identity_files",
"addresses": [{"host", "kind": lan|mdns|tailscale|manual, "label",
"networks": [network ids it worked on], "last_ok", "last_rtt_ms"}]}],
"networks": {"<network id>": {"name", "ssid", "gateway", "gateway_mac", "last_seen"}}}
Headsets set up before this existed live only in ~/.ssh/config, in the managed
`# >>> steam-frame (ALIAS) >>>` blocks that scripts/connect.sh and
ui/frame_connect.py write; they're imported from there, so nobody has to add
them again. Each device keeps its alias: Terminal's `ssh frame` and the helper
scripts go on working, and the connector rewrites the block's HostName to the
last address that worked, so they follow it.
Host keys are pinned per headset, not per address: ssh gets
`-o HostKeyAlias=frame-control-<id>` and a known_hosts file of our own, so a
different device answering at a remembered IP is caught.
Python stdlib only.
"""
import copy
import json
import os
import re
import secrets
import subprocess
import threading
import time
from pathlib import Path
import frame_host
import frame_network
VERSION = 1
# Everything here can end up in ssh arguments or ~/.ssh/config, so nothing that
# could start an option, add a line, or carry a directive.
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
HOST_RE = re.compile(r"[A-Za-z0-9:][A-Za-z0-9.:-]{0,252}(%[A-Za-z0-9._-]{1,32})?")
TEXT_MAX = 60
KINDS = ("lan", "mdns", "tailscale", "manual")
KIND_LABEL = {"lan": "Local network", "mdns": "mDNS (.local)", "tailscale": "Tailscale", "manual": "Other"}
DEFAULT_USER = "steamos"
class DeviceError(ValueError):
"""Bad input from the page; the server answers 400 with the message."""
def ssh_dir():
"""~/.ssh, or $FRAME_CONTROL_SSH_DIR in tests so they never touch the real one."""
return Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
def ssh_config():
return ssh_dir() / "config"
def known_hosts():
return ssh_dir() / "frame-control_known_hosts"
def known_hosts_opt():
"""How ssh is told about our known_hosts file. `~` rather than the full path when it's
the usual place, so a home folder with a space in its name can't split the option."""
return "~/.ssh/frame-control_known_hosts" if not os.environ.get("FRAME_CONTROL_SSH_DIR") else str(known_hosts())
def host_key_alias(device_id):
return f"frame-control-{device_id}"
# ---- validation ----------------------------------------------------------------
def check_alias(alias):
if not isinstance(alias, str) or not NAME_RE.fullmatch(alias):
raise DeviceError("The SSH alias must be a plain name: letters, digits, dot, dash or underscore")
return alias
def check_user(user):
if not isinstance(user, str) or not NAME_RE.fullmatch(user):
raise DeviceError("The user name must be letters, digits, dot, dash or underscore")
return user
def check_host(host):
host = host.strip() if isinstance(host, str) else host
if (not isinstance(host, str) or not HOST_RE.fullmatch(host) or ".." in host
or ("%" in host and ":" not in host.split("%")[0])): # a zone only follows an IPv6 address
raise DeviceError(f"{host!r} isn't a host name or IP address")
return host
def check_port(port):
try:
port = int(port)
except (TypeError, ValueError):
raise DeviceError("The port must be a number") from None
if not 1 <= port <= 65535:
raise DeviceError("The port must be between 1 and 65535")
return port
def check_text(text, what):
text = (text or "").strip() if isinstance(text, (str, type(None))) else None
if text is None or len(text) > TEXT_MAX or re.search(r"[\x00-\x1f\x7f]", text):
raise DeviceError(f"The {what} must be plain text of at most {TEXT_MAX} characters")
return text
def check_kind(kind):
if kind not in KINDS:
raise DeviceError(f"The kind must be one of {', '.join(KINDS)}")
return kind
def ssh_host(host):
"""A host for ssh's HostName, which expands %-tokens: an IPv6 zone's % is doubled."""
return host.replace("%", "%%")
# ---- ~/.ssh/config's managed blocks ---------------------------------------------
BLOCK_RE = re.compile(r"# >>> steam-frame \((" + NAME_RE.pattern + r")\) >>>")
def begin_mark(alias):
return f"# >>> steam-frame ({alias}) >>>"
def end_mark(alias):
return f"# <<< steam-frame ({alias}) <<<"
def parse_blocks(text):
"""The managed blocks: [{"alias", "hostname", "user", "port", "identity_files"}]."""
blocks, cur = [], None
for line in text.splitlines():
m = BLOCK_RE.fullmatch(line.strip())
if m:
cur = {"alias": m.group(1), "hostname": None, "user": None, "port": 22, "identity_files": []}
continue
if cur is None:
continue
if line.strip() == end_mark(cur["alias"]):
blocks.append(cur)
cur = None
continue
f = line.split(None, 1)
if len(f) != 2:
continue
key, value = f[0].lower(), f[1].strip()
if key == "hostname" and cur["hostname"] is None:
cur["hostname"] = value.replace("%%", "%")
elif key == "user" and cur["user"] is None:
cur["user"] = value
elif key == "port" and value.isdigit():
cur["port"] = int(value)
elif key == "identityfile":
cur["identity_files"].append(value)
return blocks
def read_config(path=None):
path = Path(path or ssh_config())
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return ""
def _write_config(path, text):
"""Swap the file in whole (same as frame_connect.write_config), keeping it private."""
tmp = path.with_name("config.frame-control.tmp")
tmp.write_text(text, encoding="utf-8")
if not frame_host.WINDOWS:
tmp.chmod(0o600)
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
try:
os.replace(tmp, path)
return
except PermissionError:
time.sleep(0.25)
tmp.unlink()
raise OSError(f"{path} stayed locked by another program")
def rewrite_block(alias, path=None, hostname=None, user=None, port=None):
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
file alone. -> True if the file changed. Does nothing if there's no such block."""
path = Path(path or ssh_config())
text = read_config(path)
lines = text.splitlines()
begin, end = begin_mark(alias), end_mark(alias)
if begin not in lines or end not in lines:
return False
i, j = lines.index(begin), lines.index(end)
if j < i:
return False
block = lines[i:j]
want = {"hostname": ssh_host(hostname) if hostname else None, "user": user,
"port": str(port) if port else None}
out, seen = [], set()
for line in block:
f = line.split(None, 1)
key = f[0].lower() if f else ""
if key in want and want[key] is not None and key not in seen:
seen.add(key)
if key == "port" and want[key] == "22":
continue # the default; connect.sh leaves it out
out.append(f" {f[0]} {want[key]}")
else:
out.append(line)
if want["port"] and want["port"] != "22" and "port" not in seen:
at = next((n + 1 for n, line in enumerate(out) if line.split(None, 1)[:1] == ["HostName"]), 2)
out.insert(at, f" Port {want['port']}")
new = lines[:i] + out + lines[j:]
if new == lines:
return False
_write_config(path, "\n".join(new) + "\n")
return True
def remove_block(alias, path=None):
path = Path(path or ssh_config())
lines = read_config(path).splitlines()
begin, end = begin_mark(alias), end_mark(alias)
if begin not in lines or end not in lines:
return False
i, j = lines.index(begin), lines.index(end)
if j < i:
return False
_write_config(path, "\n".join(lines[:i] + lines[j + 1:]) + "\n")
return True
# ---- pinned host keys -------------------------------------------------------------
def _pin_lines(path=None):
try:
return Path(path or known_hosts()).read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return []
def pinned(device_id, path=None):
name = host_key_alias(device_id)
return any(line.split(None, 1)[0].split(",").count(name) for line in _pin_lines(path)
if line.strip() and not line.startswith("#"))
def seed_pin(device_id, hosts, port=22, sources=None, path=None):
"""Copy the host keys ssh already trusts for one of `hosts` into our file under the
device's alias, so moving to per-device pinning asks nobody to trust anything again.
-> True if a key was pinned."""
if pinned(device_id, path):
return True
sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"]
name = host_key_alias(device_id)
for host in hosts:
wanted = host if port == 22 else f"[{host}]:{port}"
keys = []
for src in sources:
if not Path(src).is_file():
continue
try:
out = subprocess.run(["ssh-keygen", "-F", wanted, "-f", str(src)], capture_output=True,
stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
continue
for line in out.splitlines():
f = line.split()
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
keys.append(f"{name} {f[1]} {f[2]}")
if keys:
target = Path(path or known_hosts())
target.parent.mkdir(parents=True, exist_ok=True)
with open(target, "a", encoding="utf-8") as fh:
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
if not frame_host.WINDOWS:
target.chmod(0o600)
return True
return False
def forget_pin(device_id, path=None):
"""Drop a device's pinned keys, e.g. after SteamOS was reinstalled. The next connection
trusts whatever key the headset shows, as a first connection does."""
target = Path(path or known_hosts())
name = host_key_alias(device_id)
lines = _pin_lines(target)
kept = [line for line in lines if not (line.strip() and name in line.split(None, 1)[0].split(","))]
if kept != lines:
target.write_text("".join(line + "\n" for line in kept), encoding="utf-8")
return True
return False
# ---- address order --------------------------------------------------------------------
def order_addresses(addresses, network_id, tailscale_up):
"""The order to try a device's addresses in, each with why it's there:
known to work on this network, then mDNS, then Tailscale if it's up, then the rest
(addresses that only ever worked elsewhere last). The user's order breaks ties."""
def group(a):
nets = a.get("networks") or []
if network_id and network_id in nets:
return 0, "worked on this network before"
if a["kind"] == "mdns":
return 1, "mDNS name"
if a["kind"] == "tailscale":
return (2, "Tailscale") if tailscale_up else (5, "Tailscale isn't running")
if nets:
return 4, "worked on another network"
return 3, "not tried on this network yet"
ranked = sorted(enumerate(addresses), key=lambda p: (group(p[1])[0], p[0]))
return [(a, group(a)[1]) for _, a in ranked]
# ---- the registry ------------------------------------------------------------------------
def new_address(host, kind=None, label=""):
host = check_host(host)
return {"host": host, "kind": check_kind(kind) if kind else frame_network.guess_kind(host),
"label": check_text(label, "label"), "networks": [], "last_ok": None, "last_rtt_ms": None}
class Registry:
"""devices.json, loaded once and saved on every change. Thread-safe."""
def __init__(self, path=None, config=None):
self.path = Path(path or frame_host.data_dir("devices.json"))
self.config = Path(config) if config else None # None: ssh_config() at call time
self.lock = threading.RLock()
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
self.load()
# -- storage --
def load(self):
with self.lock:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
if isinstance(data, dict) and isinstance(data.get("devices"), list):
data.setdefault("networks", {})
data.setdefault("active", None)
data["devices"] = [d for d in data["devices"] if self._sane(d)]
self.data = data
@staticmethod
def _sane(d):
try:
check_alias(d["alias"])
d["addresses"] = [a for a in d.get("addresses") or [] if isinstance(a, dict) and HOST_RE.fullmatch(a.get("host", ""))
and a.get("kind") in KINDS]
for a in d["addresses"]:
a.setdefault("networks", [])
a.setdefault("label", "")
return NAME_RE.fullmatch(d.get("id", "")) is not None
except (KeyError, TypeError, DeviceError):
return False
def save(self):
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_name(self.path.name + ".tmp")
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
os.replace(tmp, self.path)
def snapshot(self):
with self.lock:
return copy.deepcopy(self.data)
# -- lookups --
def devices(self):
with self.lock:
return copy.deepcopy(self.data["devices"])
def _find(self, device_id):
for d in self.data["devices"]:
if d["id"] == device_id:
return d
raise DeviceError("No such headset (it may have been removed)")
def get(self, device_id):
with self.lock:
return copy.deepcopy(self._find(device_id))
def by_alias(self, alias):
with self.lock:
return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None)
def active(self):
with self.lock:
return self.data.get("active")
def set_active(self, device_id):
with self.lock:
self._find(device_id)
self.data["active"] = device_id
self.save()
# -- devices --
def add_device(self, alias, name=None, user=DEFAULT_USER, port=22, hosts=(), identity_files=()):
with self.lock:
check_alias(alias)
if any(d["alias"] == alias for d in self.data["devices"]):
raise DeviceError(f"There's already a headset with the alias {alias}")
ids = {d["id"] for d in self.data["devices"]}
device_id = secrets.token_hex(4)
while device_id in ids:
device_id = secrets.token_hex(4)
d = {"id": device_id, "name": check_text(name or ("Steam Frame" if alias == "frame" else alias), "name"),
"alias": alias, "user": check_user(user or DEFAULT_USER), "port": check_port(port),
"identity_files": [str(f) for f in identity_files][:8], "addresses": [], "config_host": None,
"added": time.time()}
for host in hosts:
if host and not any(a["host"] == host for a in d["addresses"]):
d["addresses"].append(new_address(host))
self.data["devices"].append(d)
if not self.data.get("active"):
self.data["active"] = device_id
self.save()
return copy.deepcopy(d)
def update_device(self, device_id, name=None, user=None, port=None):
"""-> the device after the change. The caller mirrors user and port into ~/.ssh/config."""
with self.lock:
d = self._find(device_id)
if name is not None:
d["name"] = check_text(name, "name") or d["alias"]
if user is not None:
d["user"] = check_user(user)
if port is not None:
d["port"] = check_port(port)
self.save()
return copy.deepcopy(d)
def remove_device(self, device_id):
"""Forget a headset. Its ~/.ssh/config block (if kept) isn't imported again
unless Set Up Connection changes it."""
with self.lock:
d = self._find(device_id)
self.data["devices"].remove(d)
self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or ""
if self.data.get("active") == device_id:
self.data["active"] = self.data["devices"][0]["id"] if self.data["devices"] else None
self.save()
return d
# -- addresses --
def _addr(self, d, host):
for a in d["addresses"]:
if a["host"] == host:
return a
raise DeviceError(f"{host} isn't one of this headset's addresses")
def add_address(self, device_id, host, kind=None, label=""):
with self.lock:
d = self._find(device_id)
a = new_address(host, kind, label)
if any(x["host"] == a["host"] for x in d["addresses"]):
raise DeviceError(f"{a['host']} is already on the list")
if len(d["addresses"]) >= 32:
raise DeviceError("That's enough addresses for one headset")
d["addresses"].append(a)
self.save()
return copy.deepcopy(a)
def update_address(self, device_id, host, new_host=None, kind=None, label=None):
with self.lock:
d = self._find(device_id)
a = self._addr(d, host)
if new_host is not None and new_host != host:
new_host = check_host(new_host)
if any(x["host"] == new_host for x in d["addresses"]):
raise DeviceError(f"{new_host} is already on the list")
a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again
if kind is not None:
a["kind"] = check_kind(kind)
if label is not None:
a["label"] = check_text(label, "label")
self.save()
return copy.deepcopy(a)
def remove_address(self, device_id, host):
with self.lock:
d = self._find(device_id)
d["addresses"].remove(self._addr(d, host))
self.save()
def move_address(self, device_id, host, delta):
with self.lock:
d = self._find(device_id)
a = self._addr(d, host)
i = d["addresses"].index(a)
j = max(0, min(len(d["addresses"]) - 1, i + int(delta)))
d["addresses"].insert(j, d["addresses"].pop(i))
self.save()
def record_success(self, device_id, host, network_id, rtt_ms):
"""Learn: this address worked on this network."""
with self.lock:
try:
a = self._addr(self._find(device_id), host)
except DeviceError:
return
if network_id and network_id not in a["networks"]:
a["networks"] = (a["networks"] + [network_id])[-16:]
a["last_ok"] = time.time()
a["last_rtt_ms"] = rtt_ms
self.save()
def undismiss(self, alias):
"""Set Up Connection is about to run for this alias: import its block again."""
with self.lock:
if self.data.get("dismissed", {}).pop(alias, None) is not None:
self.save()
def set_config_host(self, device_id, host):
with self.lock:
try:
self._find(device_id)["config_host"] = host
except DeviceError:
return
self.save()
# -- networks --
def record_network(self, net):
"""Remember a network we've seen (for naming it), keeping its user-given name."""
if not net or not net.get("id"):
return
with self.lock:
known = self.data["networks"].get(net["id"]) or {"name": ""}
changed = (known.get("ssid") != (net.get("ssid") or known.get("ssid")) or
time.time() - (known.get("last_seen") or 0) > 3600 or "gateway" not in known)
known.update(ssid=net.get("ssid") or known.get("ssid"), gateway=net.get("gateway"), wifi=net.get("wifi"),
gateway_mac=net.get("gateway_mac"), last_seen=time.time())
self.data["networks"][net["id"]] = known
if changed:
self.save()
def name_network(self, network_id, name):
with self.lock:
if network_id not in self.data["networks"]:
raise DeviceError("That network hasn't been seen")
self.data["networks"][network_id]["name"] = check_text(name, "network name")
self.save()
def network_name(self, net):
"""What to call a network: the name given to it, its Wi-Fi name, or its router."""
if not net:
return "No network"
with self.lock:
known = self.data["networks"].get(net.get("id") or "") or {}
if known.get("name"):
return known["name"]
ssid = net.get("ssid") or known.get("ssid")
if ssid:
return ssid
if net.get("gateway"):
return f"{'Wi-Fi' if net.get('wifi') else 'Network'} via {net['gateway']}"
return "No network"
# -- ~/.ssh/config --
def sync_from_config(self, seed=True):
"""Import managed blocks we don't know yet, and pick up a HostName that Set Up
Connection changed since we last looked. -> True if anything changed."""
blocks = parse_blocks(read_config(self.config))
changed = False
with self.lock:
for b in blocks:
host = b["hostname"] if b["hostname"] and HOST_RE.fullmatch(b["hostname"]) else None
user = b["user"] if b["user"] and NAME_RE.fullmatch(b["user"]) else DEFAULT_USER
d = next((x for x in self.data["devices"] if x["alias"] == b["alias"]), None)
dismissed = self.data.get("dismissed", {})
if d is None and b["alias"] in dismissed:
if dismissed[b["alias"]] == (host or ""):
continue # removed on the Devices tab; unchanged since
del dismissed[b["alias"]]
if d is None:
try:
d = self._find(self.add_device(b["alias"], user=user, port=b["port"],
identity_files=b["identity_files"])["id"])
except DeviceError:
continue
if host:
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
d["config_host"] = host
changed = True
if seed and host:
seed_pin(d["id"], [host], b["port"])
elif host and host != d.get("config_host"):
# Set Up Connection ran again and found the headset somewhere new.
d["config_host"] = host
if not any(a["host"] == host for a in d["addresses"]):
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
if seed:
seed_pin(d["id"], [host], b["port"])
changed = True
if d["identity_files"] != b["identity_files"] and b["identity_files"]:
d["identity_files"] = b["identity_files"][:8]
changed = True
d["managed"] = True
aliases = {b["alias"] for b in blocks}
for d in self.data["devices"]:
d["managed"] = d["alias"] in aliases
if changed:
self.save()
return changed
+5 -2
View File
@@ -33,8 +33,11 @@ class HostError(RuntimeError):
def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME."""
if MAC:
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
if os.environ.get("FRAME_CONTROL_DATA_DIR"):
base = Path(os.environ["FRAME_CONTROL_DATA_DIR"])
elif MAC:
base = Path.home() / "Library" / "Application Support" / "Frame Control"
elif WINDOWS:
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
+905
View File
@@ -0,0 +1,905 @@
"""The connection to the active headset: which address to use, and every step of getting there.
A background thread (Link) keeps one SSH connection to the active headset open
and publishes what it's doing, stage by stage, for the page's connection pill:
1. network checking this computer's network (gateway, Wi-Fi, Tailscale)
2. find finding the headset: every address probed on port 22 at once
3. ssh opening SSH to the address that answered
4. identity checking the headset's identity (its pinned host key)
5. login logging in as the device's user
then connected (network, address, round trip), or failed at a stage with a
plain reason and a countdown to the next try.
Addresses go in the order frame_devices.order_addresses gives. All are probed
at once; the best-ranked one that answers wins, waiting a moment (PREFER) for a
better-ranked address that's still trying, happy-eyeballs style. If SSH to the
winner fails in a way another address could fix (a different device answered,
the link dropped), the next one that answered is tried.
The server hands in `apply(alias, host_opts)`, which points every ssh, scp and
rsync it runs at the alias with `-o HostName=<address>` and friends, so they all
follow. Where ssh can share one connection (not Windows), the master connection
lives here; it reconnects when it dies, when this computer changes networks, and
when the page asks.
Python stdlib only. Runs on this computer, never on the Frame.
"""
import copy
import queue
import re
import socket
import subprocess
import threading
import time
import frame_devices
import frame_host
import frame_network
PROBE_TIMEOUT = 4 # seconds for a TCP answer on port 22
PREFER = 0.35 # how long an answer waits for a better-ranked address still trying
HANDSHAKE_TIMEOUT = 25
TICK = 2 # the loop's heartbeat
NETWORK_EVERY = 5 # how often the network fingerprint is read
TAILSCALE_EVERY = 30
RETRY = (5, 10, 20, 30) # seconds before automatic retries after a failure
REQUEST_GAP = 5 # a request may start a new attempt this long after the last one
STAGES = [("network", "Checking this computer's network"), ("find", "Finding the headset"),
("ssh", "Opening SSH"), ("identity", "Checking the headset's identity"),
("login", "Logging in")]
# What ssh -v prints at each step (OpenSSH on macOS, Linux and Windows).
CONNECTING = re.compile(r"Connecting to (\S+) \[([^\]]+)\] port (\d+)")
ESTABLISHED = re.compile(r"Connection established")
HOSTKEY = re.compile(r"Server host key: (\S+) (\S+)")
KNOWN = re.compile(r"is known and matches")
ADDED = re.compile(r"Permanently added")
CHANGED = re.compile(r"REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed")
UNKNOWN = re.compile(r"No \S+ host key is known for")
AUTH_START = re.compile(r"Authentications that can continue|Next authentication method")
AUTHED = re.compile(r"Authenticated to |Authentication succeeded")
DENIED = re.compile(r"Permission denied")
def now():
return time.time()
def ssh_g(alias):
"""(hostname, port, user) from `ssh -G ALIAS`, for a headset that's only an ssh alias."""
try:
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
out = ""
got = {}
for line in out.splitlines():
k, _, v = line.partition(" ")
if k in ("hostname", "port", "user") and k not in got:
got[k] = v.strip()
port = int(got["port"]) if got.get("port", "").isdigit() else 22
return got.get("hostname") or alias, port, got.get("user")
def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
"""Try a TCP connection to host:port. -> {"state", "detail", "ip", "rtt_ms"}.
state: answered, unresolved, timeout, refused, unreachable or error. update(fields)
reports progress (resolving, trying) as it happens."""
update = update or (lambda **_: None)
update(state="resolving", detail="Looking up the name")
deadline = now() + timeout
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except (socket.gaierror, UnicodeError, OSError) as e:
return {"state": "unresolved", "detail": "Can't find this name on the network", "error": str(e)}
last = None
for family, kind, proto, _, addr in infos[:4]:
ip = addr[0]
left = deadline - now()
if left <= 0:
break
update(state="trying", detail=f"Trying {ip}", ip=ip)
s = socket.socket(family, kind, proto)
s.settimeout(left)
t0 = time.monotonic()
try:
s.connect(addr)
rtt = round((time.monotonic() - t0) * 1000, 1)
return {"state": "answered", "detail": f"Answered in {rtt:g} ms", "ip": ip, "rtt_ms": rtt}
except socket.timeout:
last = {"state": "timeout", "detail": "No answer", "ip": ip}
except ConnectionRefusedError:
last = {"state": "refused", "detail": "Refused: SSH isn't on at this address", "ip": ip}
except OSError as e:
last = {"state": "unreachable", "detail": f"Can't get there ({e.strerror or e})", "ip": ip}
finally:
s.close()
return last or {"state": "timeout", "detail": "No answer"}
def probe_raw(host, port, result):
"""ssh's own wording for a failed probe, so the server's UNREACHABLE table explains it."""
return {"unresolved": f"ssh: Could not resolve hostname {host}: not found",
"refused": f"ssh: connect to host {host} port {port}: Connection refused",
"unreachable": f"ssh: connect to host {host} port {port}: No route to host",
}.get(result["state"], f"ssh: connect to host {host} port {port}: Operation timed out")
class Link:
def __init__(self, registry, *, env_alias, mux_base, control, apply, explain):
self.reg = registry
self.override = env_alias # FRAME_ALIAS, if set: the headset this server starts on
self.mux_base = list(mux_base) # ["ssh", "-o", "BatchMode=yes", ControlPath...]
self.control = control # ControlPath, or None where ssh can't share connections
self.apply = apply # apply(alias, host_opts): point every ssh command at the headset
self.explain = explain # ssh error text -> plain reason, or None
self.cond = threading.Condition()
self.version = 0
self.stopped = False
self.kicks = [] # reasons someone asked for a (re)connect
self.busy = False # the loop is handling kicks
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
"started": None, "finished": None, "tests": {}, "devices_rev": 0}
self.master = None # the ssh ControlMaster process, if we started it
self.opts = [] # host options of the current connection
self.alias = None
self.fails = 0
self.last_fp = None
self.last_attempt = 0
self.config_mtime = None
self.thread = None
# ---- publishing ----
def publish(self, **fields):
with self.cond:
self.state.update(fields)
self.version += 1
self.cond.notify_all()
def snapshot(self):
with self.cond:
snap = copy.deepcopy(self.state)
snap["version"] = self.version
snap["now"] = now()
return snap
def wait(self, version, timeout):
"""The state once its version passes `version`, or None after `timeout` seconds."""
with self.cond:
if not self.cond.wait_for(lambda: self.version > version or self.stopped, timeout):
return None
return self.snapshot()
def stage(self, sid, state, detail=None):
"""Move one stage along (pending -> active -> done or failed) and publish."""
with self.cond:
for s in self.state["stages"]:
if s["id"] == sid:
if state == "active" and s["state"] != "active":
s["started"] = now()
if state in ("done", "failed", "skipped"):
s["ended"] = now()
s["started"] = s["started"] or s["ended"]
s["state"] = state
if detail is not None:
s["detail"] = detail
self.version += 1
self.cond.notify_all()
def probe_update(self, index, **fields):
with self.cond:
if index < len(self.state["probes"]):
self.state["probes"][index].update(fields)
self.version += 1
self.cond.notify_all()
def devices_changed(self):
with self.cond:
self.state["devices_rev"] += 1
self.version += 1
self.cond.notify_all()
# ---- control from the server ----
def start(self):
self.thread = threading.Thread(target=self.run, name="frame-link", daemon=True)
self.thread.start()
def kick(self, reason):
with self.cond:
self.kicks.append(reason)
self.cond.notify_all()
def stop(self):
with self.cond:
self.stopped = True
self.cond.notify_all()
self.close_master()
def alive(self):
if self.state["phase"] != "connected":
return False
if not self.control:
return True
return self.master is None or self.master.poll() is None
def ensure(self, wait=20):
"""Called before a command: make sure a connection is up, or being tried.
Waits (up to `wait` s) for an attempt already running, or starts one if the
last ended a while ago. Never raises: if the headset can't be reached, the
command runs anyway and fails with ssh's own error, as it always has."""
with self.cond:
if self.stopped or self.alive():
return
if self.state["phase"] != "connecting" and not self.kicks and now() - self.last_attempt > REQUEST_GAP:
self.kicks.append("request")
self.cond.wait_for(lambda: self.stopped or (not self.kicks and not self.busy and
self.state["phase"] != "connecting"), wait)
def use(self, device_id):
"""Switch to another headset."""
self.reg.set_active(device_id)
self.override = None
self.devices_changed()
self.kick("switch")
def lost(self, message):
"""A command couldn't reach the headset (Windows has no master to watch)."""
if self.state["phase"] == "connected":
self.kick(f"lost: {message}")
# ---- the device this server talks to ----
def active_device(self):
"""The active headset from the registry, or a stand-in for a bare ssh alias."""
if self.override:
return self.reg.by_alias(self.override) or self.bare(self.override)
want = self.reg.active()
if want:
try:
return self.reg.get(want)
except frame_devices.DeviceError:
pass
devices = self.reg.devices()
return devices[0] if devices else self.bare("frame")
@staticmethod
def bare(alias):
"""A headset that's only an ssh alias (no Set Up Connection block): ssh's config decides."""
return {"id": f"alias-{alias}", "name": alias, "alias": alias, "user": None, "port": None,
"addresses": [], "transient": True, "identity_files": []}
def host_opts(self, device, host):
"""What every ssh command adds to reach DEVICE at HOST."""
if device.get("transient") or not host:
return []
return ["-o", f"HostName={frame_devices.ssh_host(host)}",
"-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}",
"-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt()}",
"-o", f"User={device['user']}", "-o", f"Port={device['port']}"]
def public_device(self, d):
return {k: d.get(k) for k in ("id", "name", "alias", "user", "port", "transient")}
# ---- the loop ----
def run(self):
self.kick("start")
last_net = last_ts = 0
while True:
with self.cond:
self.cond.wait_for(lambda: self.stopped or self.kicks, TICK)
if self.stopped:
return
reasons, self.kicks = self.kicks, []
self.busy = bool(reasons)
try:
t = now()
if t - last_net >= NETWORK_EVERY:
last_net = t
fp = frame_network.fingerprint()
if self.last_fp is not None and fp[::2] != self.last_fp[::2]:
reasons.append("network")
self.last_fp = fp
self.watch_config()
if self.state["phase"] == "connected" and self.control and self.master is None \
and not self.check(self.opts):
reasons.append("dropped") # a master we found open, not one we started
if t - last_ts >= TAILSCALE_EVERY and self.state["network"] and not reasons:
last_ts = t
self.refresh_network()
phase = self.state["phase"]
if phase == "connected" and not self.alive():
reasons.append("dropped")
if phase == "failed" and self.state["retry_at"] and now() >= self.state["retry_at"]:
reasons.append("retry")
if reasons:
self.connect(reasons)
except Exception as e: # keep the loop alive whatever happens; say what went wrong
self.publish(phase="failed", error={"stage": "network", "message": f"{type(e).__name__}: {e}",
"raw": str(e)}, retry_at=now() + RETRY[-1])
finally:
with self.cond:
self.busy = False
self.cond.notify_all()
def watch_config(self):
"""Set Up Connection may have added a headset or found a new address: pick it up."""
try:
mtime = frame_devices.ssh_config().stat().st_mtime
except OSError:
mtime = None
if mtime != self.config_mtime:
self.config_mtime = mtime
if self.reg.sync_from_config():
self.devices_changed()
def refresh_network(self):
net = frame_network.current_network(self.last_fp)
self.reg.record_network(net)
net["name"] = self.reg.network_name(net)
self.publish(network=net)
# ---- one attempt ----
def connect(self, reasons):
why = self.describe(reasons)
self.last_attempt = now()
self.close_master()
device = self.active_device()
with self.cond:
self.state.update(phase="connecting", reason=why, device=self.public_device(device), via=None,
error=None, retry_at=None, attempt=self.state["attempt"] + 1, started=now(),
finished=None, probes=[],
stages=[{"id": i, "label": label, "state": "pending", "detail": "",
"started": None, "ended": None} for i, label in STAGES])
self.version += 1
self.cond.notify_all()
ok = False
try:
ok = self.attempt(device)
finally:
with self.cond:
self.state["finished"] = now()
if ok:
self.fails = 0
self.state.update(phase="connected", retry_at=None, error=None)
else:
self.fails += 1
self.state.update(phase="failed",
retry_at=now() + RETRY[min(self.fails, len(RETRY)) - 1])
if not self.state["error"]:
self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""}
self.version += 1
self.cond.notify_all()
@staticmethod
def describe(reasons):
for r in reasons:
if r == "network":
return "This computer changed networks"
if r == "dropped" or r.startswith("lost"):
return "The connection dropped"
if r == "switch":
return "Switched headset"
if "retry" in reasons:
return "Trying again"
if "start" in reasons:
return "Starting up"
return "Connecting"
def fail(self, sid, message, raw=""):
self.stage(sid, "failed", message)
with self.cond:
self.state["error"] = {"stage": sid, "message": message, "raw": raw}
def attempt(self, device):
# 1. this computer's network
self.stage("network", "active")
net = frame_network.current_network(self.last_fp)
self.reg.record_network(net)
net["name"] = self.reg.network_name(net)
ts = net.get("tailscale") or {}
self.publish(network=net)
bits = [net["name"]]
if net.get("local_ip"):
bits.append(f"this computer is {net['local_ip']}")
bits.append("Tailscale on" if ts.get("up") else "Tailscale off" if ts.get("installed") else "no Tailscale")
self.stage("network", "done" if net.get("gateway") or ts.get("up") else "failed", " · ".join(bits))
if not net.get("gateway") and not ts.get("up"):
with self.cond:
self.state["error"] = {"stage": "network", "raw": "",
"message": "This computer isn't connected to a network."}
# Keep going anyway: a headset on a direct link or loopback could still answer.
# 2. find the headset
self.stage("find", "active")
port = device.get("port") or 22
if device.get("transient") or not device["addresses"]:
host, port, user = ssh_g(device["alias"])
if user and not device.get("user"):
device["user"] = user
ranked = [({"host": host, "kind": frame_network.guess_kind(host), "label": "from ~/.ssh/config"},
"from ~/.ssh/config")]
else:
ranked = frame_devices.order_addresses(device["addresses"], net.get("id"), bool(ts.get("up")))
with self.cond:
self.state["probes"] = [{"host": a["host"], "kind": a["kind"], "label": a.get("label") or "",
"why": why, "state": "waiting", "detail": "Waiting", "ip": None,
"rtt_ms": None} for a, why in ranked]
self.stage("find", "active", f"Trying {len(ranked)} address{'es' * (len(ranked) != 1)} at once")
results = [None] * len(ranked)
done = threading.Condition()
def run_probe(i, host):
res = probe(host, port, update=lambda **f: self.probe_update(i, **f))
res.setdefault("ip", None)
res.setdefault("rtt_ms", None)
self.probe_update(i, **{k: res[k] for k in ("state", "detail", "ip", "rtt_ms")})
with done:
if results[i] is None: # not already given up on
results[i] = dict(res, t=time.monotonic())
done.notify_all()
for i, (a, _) in enumerate(ranked):
threading.Thread(target=run_probe, args=(i, a["host"]), daemon=True).start()
tried = set()
user = device.get("user") or "the headset's user"
deadline = time.monotonic() + PROBE_TIMEOUT + 1
while True:
pick = self.pick(results, tried, done, deadline)
if pick is None:
break
if tried: # another address may do better (a different device answered, or it dropped)
for sid in ("ssh", "identity", "login"):
self.stage(sid, "pending", "")
tried.add(pick)
a = ranked[pick][0]
self.stage("find", "done", f"{a['host']} answered in {results[pick]['rtt_ms']:g} ms")
outcome = self.handshake(device, a, results[pick], user)
if outcome == "ok":
via = {"host": a["host"], "kind": a["kind"], "ip": results[pick]["ip"],
"rtt_ms": results[pick]["rtt_ms"], "why": ranked[pick][1], "network": net.get("id"),
"network_name": net["name"]}
self.publish(via=via)
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
return True
with self.cond:
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
self.probe_update(pick, state="sshfailed", detail=why_not)
if outcome != "next":
return False
if tried:
return False # the last handshake already said why
# Nothing answered: explain with the most useful failure.
with self.cond:
for row in self.state["probes"]:
if row["state"] in ("waiting", "resolving", "trying"):
row.update(state="timeout", detail="No answer in time")
states = [r["state"] for r in results if r]
worst = next((s for s in ("refused", "timeout", "unreachable", "unresolved") if s in states), "timeout")
i = states.index(worst) if worst in states else 0
raw = probe_raw(ranked[i][0]["host"], port, results[i] or {"state": worst})
message = self.explain(raw) or "The Frame isn't answering."
self.fail("find", message, raw)
return False
@staticmethod
def pick(results, tried, done, deadline=None):
"""The next address to use: the best-ranked answer once every better-ranked
address has failed, or once it has waited PREFER seconds for them. None when
nothing (else) answered. Probes still going at `deadline` (a name lookup can
take longer than the connect timeout) count as no answer."""
deadline = deadline or time.monotonic() + PROBE_TIMEOUT + 1
with done:
while True:
if time.monotonic() >= deadline:
for i, r in enumerate(results):
if r is None:
results[i] = {"state": "timeout", "detail": "No answer", "ip": None, "rtt_ms": None,
"t": time.monotonic()}
answered = [i for i, r in enumerate(results) if r and r["state"] == "answered" and i not in tried]
pending = [i for i, r in enumerate(results) if r is None]
if answered:
best = answered[0]
better = [i for i in pending if i < best]
waited = time.monotonic() - results[best]["t"]
if not better or waited >= PREFER:
return best
done.wait(PREFER - waited)
elif not pending:
return None
else:
done.wait(min(0.5, max(0.01, deadline - time.monotonic())))
def learn(self, device, host, net, rtt):
if device.get("transient"):
return
self.reg.record_success(device["id"], host, net.get("id"), rtt)
# Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too.
try:
if frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"],
port=device["port"]):
self.config_mtime = frame_devices.ssh_config().stat().st_mtime
self.reg.set_config_host(device["id"], host)
except OSError:
pass # not fatal: the app itself doesn't need the file
self.devices_changed()
# ---- SSH ----
def check(self, opts, alias=None):
"""Is a master connection up for these options? (`ssh -O check`)"""
if not self.control:
return False
try:
return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def close_master(self):
proc, self.master = self.master, None
if self.control and self.alias:
try:
subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5)
except (OSError, subprocess.TimeoutExpired):
pass
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(5)
except subprocess.TimeoutExpired:
proc.kill()
def handshake(self, device, a, found, user):
"""SSH to one address, following ssh -v through stages 3-5.
-> "ok", "next" (try another address) or "stop"."""
opts = self.host_opts(device, a["host"])
alias = device["alias"]
self.alias, self.opts = alias, opts
self.apply(alias, opts)
target = f"{a['host']}" + (f" ({found['ip']})" if found.get("ip") and found["ip"] != a["host"] else "")
self.stage("ssh", "active", f"Opening SSH to {target}")
if self.control and self.check(opts, alias):
for sid in ("ssh", "identity", "login"):
self.stage(sid, "done", "Reusing the SSH connection that's already open")
return "ok"
extra = []
if not device.get("transient"):
if frame_devices.pinned(device["id"]):
extra = ["-o", "StrictHostKeyChecking=yes"]
else:
# First connection since this headset was added: trust what it shows
# (as Set Up Connection does), and pin it from now on.
extra = ["-o", "StrictHostKeyChecking=accept-new"]
if self.control:
# No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket.
argv = [*self.mux_base, *opts, *extra, "-v", "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2", "-N", alias]
else:
argv = [*self.mux_base, *opts, *extra, "-v", "-o", "ConnectTimeout=10", alias, "true"]
try:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, **frame_host.DETACHED)
except OSError as e:
self.fail("ssh", f"Couldn't run ssh: {e}", str(e))
return "stop"
lines = queue.Queue()
collecting = [True]
def read():
for raw in iter(proc.stderr.readline, b""):
if collecting[0]:
lines.put(raw.decode("utf-8", "replace").rstrip())
lines.put(None)
proc.stderr.close()
threading.Thread(target=read, daemon=True).start()
step, said, authed = "ssh", [], False
deadline = time.monotonic() + HANDSHAKE_TIMEOUT
mismatch = False
while True:
left = deadline - time.monotonic()
if left <= 0:
proc.kill()
self.fail(step, self.explain(f"Timed out talking to {alias}") or "The headset took too long to answer.",
f"Timed out talking to {alias}")
return "next" if step in ("ssh", "identity") else "stop"
try:
line = lines.get(timeout=min(left, 0.25))
except queue.Empty:
line = ""
if authed and self.control and self.check(opts, alias):
break
if proc.poll() is not None and lines.empty():
line = None
else:
continue
if line is None: # ssh exited
proc.wait()
if not self.control and proc.returncode == 0:
break
if authed and self.control and self.check(opts, alias):
break
return self.failed(step, said, mismatch, alias)
if not line.startswith("debug"):
said.append(line)
m = CONNECTING.search(line)
if m:
self.stage("ssh", "active", f"Opening SSH to {a['host']}" +
(f" ({m.group(2)})" if m.group(2) != a["host"] else "") + f", port {m.group(3)}")
elif ESTABLISHED.search(line):
self.stage("ssh", "done", f"Connected to {target}")
step = "identity"
self.stage("identity", "active", "Waiting for the headset's host key")
elif HOSTKEY.search(line):
m = HOSTKEY.search(line)
self.stage("identity", "active", f"It shows {m.group(1)} key {m.group(2)[:20]}…")
elif KNOWN.search(line):
self.stage("identity", "done", "Matches the identity saved for this headset")
step = "login"
self.stage("login", "active", f"Logging in as {user}")
elif ADDED.search(line):
self.stage("identity", "done", "First connection: saved this headset's identity")
step = "login"
self.stage("login", "active", f"Logging in as {user}")
elif (CHANGED.search(line) or UNKNOWN.search(line)) and not device.get("transient"):
mismatch = True # a bare alias keeps ssh's per-address check, and its wording
elif AUTH_START.search(line) and step != "login":
self.stage("identity", "done")
step = "login"
self.stage("login", "active", f"Logging in as {user}")
elif AUTHED.search(line):
authed = True
if step != "login":
self.stage("identity", "done")
self.stage("login", "done", f"Logged in as {user}")
step = "connected"
collecting[0] = False # the master keeps printing mux debug lines: drop them
for sid in ("ssh", "identity", "login"):
with self.cond:
pending = any(s["id"] == sid and s["state"] != "done" for s in self.state["stages"])
if pending:
self.stage(sid, "done")
if self.control:
self.master = proc
return "ok"
def failed(self, step, said, mismatch, alias):
text = "\n".join(said).strip()
if mismatch:
self.fail("identity", "This address answered as a different headset (its SSH identity doesn't match). "
"If SteamOS was reinstalled, use Forget Identity on the Devices tab.", text)
return "next"
if step == "login" or re.search(r"Permission denied", text):
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key.", text)
return "stop"
if step == "connected":
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
return "stop"
self.fail(step, self.explain(text) or (text.splitlines()[-1] if text else "ssh stopped"), text)
return "next"
# ---- Test now ----
def test(self, device_id):
"""Probe every address of a headset and check SSH on the ones that answer,
without touching the live connection. Results stream into state["tests"]."""
device = self.reg.get(device_id)
started = now()
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
def put(**fields):
with self.cond:
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
self.version += 1
self.cond.notify_all()
put()
net = self.state["network"] or {}
def one(i, a):
res = probe(a["host"], device["port"], update=lambda **f: (rows[i].update(f), put()))
rows[i].update({k: res.get(k) for k in ("state", "detail", "ip", "rtt_ms")})
put()
if res["state"] != "answered":
return
rows[i]["ssh"] = "checking"
put()
argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8",
*self.host_opts(device, a["host"]), "-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
try:
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20)
err = r.stderr.strip()
if r.returncode == 0:
rows[i].update(ssh="ok", detail=f"Answered in {res['rtt_ms']:g} ms · SSH works")
self.reg.record_success(device_id, a["host"], net.get("id"), res["rtt_ms"])
elif UNKNOWN.search(err):
rows[i].update(ssh="unpinned", detail=f"Answered in {res['rtt_ms']:g} ms · identity not saved yet")
elif CHANGED.search(err):
rows[i].update(ssh="wrong", detail="Answered as a different headset")
elif DENIED.search(err):
rows[i].update(ssh="denied", detail="Answered, but refused this computer's key")
else:
rows[i].update(ssh="failed", detail=self.explain(err) or (err.splitlines() or ["SSH failed"])[-1])
except (OSError, subprocess.TimeoutExpired):
rows[i].update(ssh="failed", detail="SSH took too long")
put()
threads = [threading.Thread(target=one, args=(i, a), daemon=True) for i, a in enumerate(device["addresses"])]
for t in threads:
t.start()
for t in threads:
t.join(40)
put(done=True, finished=now())
self.devices_changed()
# ---- the page's API: /api/devices -------------------------------------------------
def devices_view(link):
"""Every headset with its addresses, the networks they worked on, and the current network."""
snap = link.reg.snapshot()
active = link.active_device()
names = {nid: link.reg.network_name(dict(n, id=nid)) for nid, n in snap["networks"].items()}
devices = []
if active.get("transient"):
devices.append(dict(link.public_device(active), active=True, addresses=[], managed=False, pinned=False))
for d in snap["devices"]:
view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")}
view["active"] = d["id"] == active["id"]
view["pinned"] = frame_devices.pinned(d["id"])
view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]])
for a in d["addresses"]]
devices.append(view)
return {"devices": devices, "active": active["id"], "network": link.state["network"],
"networks": [dict(n, id=nid, display=names[nid]) for nid, n in snap["networks"].items()],
"kinds": frame_devices.KIND_LABEL}
def devices_action(link, body, open_setup):
"""POST /api/devices {"action": ..., "id": device id, ...}. -> {"message", ...devices_view}."""
reg = link.reg
action = body.get("action")
did = body.get("id")
active = link.active_device()
is_active = did == active["id"]
if action == "use":
d = reg.get(did)
link.use(did)
msg = f"Switched to {d['name']}"
elif action == "update":
d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port"))
try:
frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"])
except OSError as e:
raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}")
if is_active:
link.kick("switch")
msg = f"Saved {d['name']}"
elif action == "remove":
d = reg.remove_device(did)
frame_devices.forget_pin(did)
removed = False
if body.get("config"):
try:
removed = frame_devices.remove_block(d["alias"])
except OSError as e:
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
if is_active:
link.override = None
link.kick("switch")
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
elif action == "address-add":
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
if is_active and link.state["phase"] == "failed":
link.kick("retry")
msg = f"Added {a['host']}"
elif action == "address-update":
a = reg.update_address(did, body.get("host"), new_host=body.get("newHost"), kind=body.get("kind"),
label=body.get("label"))
msg = f"Saved {a['host']}"
elif action == "address-remove":
reg.remove_address(did, body.get("host"))
msg = f"Removed {body.get('host')}"
elif action == "address-move":
delta = body.get("delta")
if delta not in (-1, 1):
raise frame_devices.DeviceError("delta must be -1 or 1")
reg.move_address(did, body.get("host"), delta)
msg = "Moved"
elif action == "test":
d = reg.get(did)
if not d["addresses"]:
raise frame_devices.DeviceError("This headset has no addresses to test yet")
threading.Thread(target=link.test, args=(did,), daemon=True).start()
msg = f"Testing {len(d['addresses'])} address{'es' * (len(d['addresses']) != 1)}"
elif action == "forget-identity":
d = reg.get(did)
frame_devices.forget_pin(did)
if is_active:
link.kick("switch")
msg = f"Forgot {d['name']}'s SSH identity; the next connection saves the one it shows"
elif action == "name-network":
reg.name_network(body.get("network"), body.get("name"))
if link.state["network"]:
link.refresh_network()
msg = "Saved the network's name"
elif action == "setup":
alias = frame_devices.check_alias(body.get("alias"))
host = frame_devices.check_host(body["host"]) if body.get("host") else None
link.reg.undismiss(alias)
where = open_setup(alias, host)
msg = f"Opened Set Up Connection for '{alias}' in {where}"
elif action == "retry":
link.kick("retry")
msg = "Connecting…"
else:
raise frame_devices.DeviceError("unknown action")
link.devices_changed()
return dict(devices_view(link), message=msg)
def next_alias(link):
taken = {d["alias"] for d in link.reg.devices()} | {b["alias"] for b in frame_devices.parse_blocks(
frame_devices.read_config())}
if "frame" not in taken:
return "frame"
n = 2
while f"frame-{n}" in taken:
n += 1
return f"frame-{n}"
LIKELY = re.compile(r"frame|steam", re.I)
def tailscale_find(link, device_id=None):
"""Tailscale peers that could be a headset, likely ones first, for "Find on Tailscale"."""
ts = frame_network.tailscale_status()
device = link.reg.get(device_id) if device_id else link.active_device()
known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []}
if not ts.get("installed"):
return {"up": False, "peers": [], "message": "Tailscale isn't installed on this computer."}
if not ts.get("up"):
return {"up": False, "peers": [], "message": "Tailscale isn't running on this computer. Start it, then look again."}
peers = []
for p in ts["peers"]:
ip = next((i for i in p["ips"] if "." in i), p["ips"][0] if p["ips"] else None)
likely = p["os"] == "linux" and (LIKELY.search(p["name"]) or p["name"].lower() in (
device["alias"].lower(), (device.get("name") or "").lower()))
peers.append({"name": p["name"], "dns": p["dns"], "ip": ip, "os": p["os"], "online": p["online"],
"likely": bool(likely), "added": bool({p["dns"].lower(), (ip or "").lower()} & known)})
peers.sort(key=lambda p: (not p["likely"], p["os"] != "linux", not p["online"], p["name"].lower()))
return {"up": True, "peers": peers, "tailnet": ts.get("tailnet"), "message": None}
def mdns_find(link, device_id=None):
"""Headsets on this network: SteamOS devkit services (mDNS) and <alias>.local."""
device = link.reg.get(device_id) if device_id else link.active_device()
known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []}
try:
import frame_connect
found = frame_connect.discover_devkit()
except (ImportError, SystemExit, OSError):
found = []
names = list(dict.fromkeys([h.rstrip(".") for h in found] + [f"{device['alias']}.local", "frame.local"]))
rows = [None] * len(names)
def check(i, host):
res = probe(host, 22, timeout=3)
rows[i] = {"host": host, "state": res["state"], "ip": res.get("ip"), "rtt_ms": res.get("rtt_ms"),
"detail": res["detail"], "advertised": host in [h.rstrip(".") for h in found],
"added": host.lower() in known or (res.get("ip") or "").lower() in known}
threads = [threading.Thread(target=check, args=(i, h), daemon=True) for i, h in enumerate(names)
if frame_devices.HOST_RE.fullmatch(h)]
for t in threads:
t.start()
for t in threads:
t.join(8)
hosts = [r for r in rows if r and (r["advertised"] or r["state"] in ("answered", "refused"))]
return {"hosts": hosts, "tool": bool(frame_host.which("dns-sd") or frame_host.which("avahi-browse"))}
+310
View File
@@ -0,0 +1,310 @@
"""Which network this computer is on, and whether Tailscale is up.
Frame Control remembers which of a headset's addresses worked on which network,
so it needs a stable name for "this network". The Wi-Fi name (SSID) is the
friendly one, but macOS 14+ hides it from apps without Location permission, and
wired networks have none. So every network is identified by a fingerprint of
its default gateway: the router's IP and MAC address, which stay the same for a
given home or office network. The user can give a fingerprint a name.
Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe
is a short command with a timeout, and each parser has fixtures in
tests/test_network.py.
"""
import hashlib
import ipaddress
import json
import os
import re
import socket
import subprocess
import time
import frame_host
TIMEOUT = 3
def run(argv, timeout=TIMEOUT):
"""A command's stdout, or "" if it's missing, fails or takes too long."""
try:
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout,
**({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {}))
except (OSError, subprocess.TimeoutExpired):
return ""
return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""
def valid_ip(text):
try:
ipaddress.ip_address(text)
return True
except ValueError:
return False
def norm_mac(text):
""""b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC."""
parts = re.split(r"[:-]", (text or "").strip())
if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts):
return None
mac = ":".join(p.lower().zfill(2) for p in parts)
return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac
# ---- default gateway -------------------------------------------------------
def parse_route_macos(text):
"""`route -n get default` -> (gateway, interface)."""
gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M)
iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M)
gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None
return gateway, iface.group(1) if iface else None
def parse_route_linux(text):
"""`ip -4 route show default` -> (gateway, interface) of the lowest-metric route."""
best = None
for line in text.splitlines():
m = re.search(r"^default via (\S+) dev (\S+)", line.strip())
if not m or not valid_ip(m.group(1)):
continue
metric = re.search(r"\bmetric (\d+)", line)
key = int(metric.group(1)) if metric else 0
if best is None or key < best[0]:
best = (key, m.group(1), m.group(2))
return (best[1], best[2]) if best else (None, None)
def parse_route_windows(text):
"""`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins."""
best = None
for line in text.splitlines():
f = line.split()
if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit():
if best is None or int(f[4]) < best[0]:
best = (int(f[4]), f[2], f[3])
return (best[1], best[2]) if best else (None, None)
# ---- the gateway's MAC address ----------------------------------------------
def parse_arp_macos(text, ip):
"""`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]")."""
m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text)
return norm_mac(m.group(1)) if m else None
def parse_neigh_linux(text, ip):
"""`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE")."""
for line in text.splitlines():
f = line.split()
if f and f[0] == ip and "lladdr" in f:
return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None
return None
def parse_arp_windows(text, ip):
"""`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic")."""
for line in text.splitlines():
f = line.split()
if len(f) >= 2 and f[0] == ip:
return norm_mac(f[1])
return None
# ---- Wi-Fi name --------------------------------------------------------------
def parse_summary_macos(text):
"""`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "<redacted>" without Location permission."""
kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
name = ssid.group(1) if ssid else None
if name in ("<redacted>", ""):
name = None
return name, (kind.group(1).lower() == "wifi") if kind else None
def parse_nmcli(text):
"""`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:)."""
for line in text.splitlines():
if line.startswith("yes:"):
return line[4:].replace("\\:", ":") or None
return None
def parse_netsh(text):
"""`netsh wlan show interfaces` -> the connected SSID (not the BSSID line)."""
state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
if not ssid or (state and state.group(1).lower() != "connected"):
return None
return ssid.group(1)
# ---- Tailscale -----------------------------------------------------------------
def tailscale_cli():
extra = []
if frame_host.MAC:
extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
elif frame_host.WINDOWS:
for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
if base:
extra.append(os.path.join(base, "Tailscale", "tailscale.exe"))
return frame_host.which("tailscale", *extra)
def parse_tailscale(text):
"""`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}.
Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}.
"""
try:
data = json.loads(text)
except ValueError:
return {"up": False, "peers": []}
if not isinstance(data, dict):
return {"up": False, "peers": []}
me = data.get("Self") or {}
tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None
out = {"up": data.get("BackendState") == "Running",
"ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None),
"name": (me.get("DNSName") or "").rstrip(".") or None,
"tailnet": tailnet, "peers": []}
for p in (data.get("Peer") or {}).values():
if not isinstance(p, dict):
continue
out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."),
"ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)],
"os": p.get("OS") or "", "online": bool(p.get("Online"))})
return out
def tailscale_status():
cli = tailscale_cli()
if not cli:
return {"up": False, "installed": False, "peers": []}
out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}")
out["installed"] = True
return out
TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10")
TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
def is_tailscale(host):
host = host.lower().rstrip(".")
if host.endswith(".ts.net"):
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6)
def guess_kind(host):
"""What sort of address a host is: mdns, tailscale, lan or manual."""
h = host.lower().rstrip(".")
if h.endswith(".local"):
return "mdns"
if is_tailscale(h):
return "tailscale"
try:
ip = ipaddress.ip_address(h.split("%")[0])
if ip.is_private or ip.is_link_local:
return "lan"
except ValueError:
pass
return "manual"
# ---- putting it together ----------------------------------------------------------
def network_id(gateway, mac):
"""A short, stable id for a network: its gateway's IP and MAC. None until both are known."""
if not gateway or not mac:
return None
return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10]
def local_ip(towards="192.0.2.1"):
"""This computer's address on the default route (UDP connect sends nothing)."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect((towards, 9))
return s.getsockname()[0]
except OSError:
return None
def gateway():
"""(gateway IP, interface) of the default route."""
if frame_host.MAC:
return parse_route_macos(run(["route", "-n", "get", "default"]))
if frame_host.WINDOWS:
return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"]))
return parse_route_linux(run(["ip", "-4", "route", "show", "default"]))
def gateway_mac(ip):
if frame_host.MAC:
return parse_arp_macos(run(["arp", "-n", ip]), ip)
if frame_host.WINDOWS:
return parse_arp_windows(run(["arp", "-a", ip]), ip)
return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip)
def poke(ip):
"""Make the system look up the gateway's MAC (an ARP entry can expire)."""
try:
with socket.create_connection((ip, 53), timeout=0.3):
pass
except OSError:
pass
def wifi(interface):
"""(ssid or None, is_wifi or None) for the default route's interface."""
if frame_host.MAC:
if interface:
ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface]))
if ssid or is_wifi is False:
return ssid, is_wifi
m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface]))
return (m.group(1).strip() if m else None), is_wifi
return None, None
if frame_host.WINDOWS:
ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"]))
return ssid, True if ssid else None
if frame_host.which("nmcli"):
ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]))
else:
ssid = run(["iwgetid", "-r"]).strip() or None
return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None)
def fingerprint():
"""The cheap part, polled every few seconds: (gateway, interface, gateway MAC)."""
gw, iface = gateway()
mac = None
if gw:
mac = gateway_mac(gw)
if not mac:
poke(gw)
mac = gateway_mac(gw)
return gw, iface, mac
def current_network(fp=None, with_tailscale=True):
"""Everything the connection status shows about this computer's network."""
gw, iface, mac = fp or fingerprint()
ssid, is_wifi = wifi(iface) if gw else (None, None)
net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface,
"ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()}
if with_tailscale:
ts = tailscale_status()
net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")}
return net
+547 -1
View File
@@ -86,6 +86,58 @@
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.dlg::backdrop { background: rgba(0,0,0,.55); }
.dlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
.dlg h3, [data-page=devices] h3 { margin: 16px 0 6px; font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--muted); font-weight: 700; }
.dlg label, .dev-form label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
.dlg label input, .dev-form label input { margin-top: 5px; }
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; font-size: 13px; }
.facts dt { color: var(--muted); } .facts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
.stages { list-style: none; margin: 0; padding: 0; }
.stages li { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 8px; padding: 6px 0; border-top: 1px solid rgba(255,255,255,.05); }
.stages li:first-child { border-top: 0; }
.stages .ic { width: 16px; height: 16px; border-radius: 50%; margin-top: 2px; display: grid; place-items: center; font-size: 11px; font-weight: 700; }
.stages .done .ic { background: rgba(89,191,64,.2); color: var(--green-hi); }
.stages .failed .ic { background: rgba(217,65,38,.25); color: #ff8a73; }
.stages .pending .ic { border: 1.5px solid var(--dim); }
.stages .active .ic { border: 2px solid rgba(255,255,255,.15); border-top-color: var(--blue); animation: spin .8s linear infinite; }
.stages .lb { color: var(--bright); font-size: 13.5px; } .stages .pending .lb { color: var(--muted); }
.stages .dt { grid-column: 2 / 4; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; }
.stages .failed .dt { color: #ff8a73; }
.stages .tm { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
.probes { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.probes td { padding: 5px 8px 5px 0; border-top: 1px solid rgba(255,255,255,.05); vertical-align: top; }
.probes td:first-child { color: var(--bright); min-width: 170px; overflow-wrap: anywhere; }
.res-answered, .res-ok { color: var(--green-hi); } .res-refused, .res-sshfailed, .res-wrong, .res-denied { color: #ff8a73; }
.res-timeout, .res-unresolved, .res-unreachable, .res-unpinned { color: var(--warn); }
.res-trying, .res-resolving, .res-waiting, .res-checking { color: var(--link); }
.dev-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.2fr); gap: 22px; align-items: start; }
@media (max-width: 1000px) { .dev-grid { grid-template-columns: 1fr; } }
.dev-item { cursor: pointer; border-radius: 3px; padding: 10px !important; margin: 0 -10px; }
.dev-item:hover { background: rgba(255,255,255,.04); }
.dev-item.sel { background: rgba(26,159,255,.12); }
.dev-form { display: grid; grid-template-columns: 2fr 1.3fr 1fr .8fr; gap: 0 10px; align-items: end; }
.dev-form input[readonly] { color: var(--muted); }
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
.addr .s { white-space: normal; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
.found { margin-top: 10px; }
/* ---- drop anywhere ---- */
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
@@ -379,9 +431,11 @@
<a href="#games" title="Steam games and sideloaded titles (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6h-6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4z"/><path d="M7 11v3M5.5 12.5h3"/><circle cx="16" cy="11.5" r=".6"/><circle cx="18" cy="13.5" r=".6"/></svg>Games<kbd>2</kbd></a>
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
</nav>
<div class="spacer"></div>
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
<span class="chip" id="battChip" title="Battery">—</span>
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
@@ -393,6 +447,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
<div class="s" id="offDetail">Retrying every few seconds. Everything fills in when it answers.</div></div>
<button class="small desk-only" id="offDetails" hidden>Details</button>
<button class="small" id="offSetup" hidden>Set Up Connection…</button>
<button class="action small" id="offRetry">Retry now</button>
</div>
@@ -644,6 +699,25 @@
</section>
</div>
</div>
<div class="page" data-page="devices">
<div class="dev-grid">
<section class="panel">
<div class="shelf-head"><h2>Headsets</h2><span class="count" id="devCount"></span><span class="spacer"></span>
<button class="action small" id="devAdd">+ Add a headset…</button></div>
<div class="list" id="devList"><div class="sub">Loading…</div></div>
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
it tries them all at once and uses the best one that answers on the network you're on.</div>
<h3 style="margin-top:22px">This computer's network</h3>
<div id="netNow" class="sub">Checking…</div>
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
<input type="text" id="netName" maxlength="60" placeholder="Name this network, e.g. Home Wi-Fi">
<button class="small" id="netSave">Save</button></div>
<div class="hint">Networks are told apart by their router (its IP and hardware address), so this works on wired
networks and when your computer won't share the Wi-Fi name.</div>
</section>
<section class="panel dev-panel" id="devDetail"><div class="sub">Pick a headset.</div></section>
</div>
</div>
</main>
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
@@ -731,6 +805,45 @@
<button type="submit" class="action small" id="pwGo">Restart</button></div>
</form>
</dialog>
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
<h2 id="connTitle">Connection</h2>
<div class="sub" id="connWhy"></div>
<h3>This computer</h3>
<dl class="facts" id="connNet"></dl>
<h3>Steps</h3>
<ol class="stages" id="connStages"></ol>
<h3>Addresses</h3>
<table class="probes"><tbody id="connProbes"></tbody></table>
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
<button type="button" class="small desk-only" id="connDevices">Manage headsets</button>
<button type="button" class="small desk-only" id="connSetup">Set Up Connection…</button>
<button type="button" class="small action" id="connRetryBtn">Retry now</button>
<button type="button" class="small" id="connClose">Close</button></div>
</dialog>
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
<form method="dialog" id="addDevForm">
<h2 id="addDevTitle">Add a headset</h2>
<div class="sub">This opens Set Up Connection in a terminal window. On the headset, first turn on Steam Settings →
System → Enable Developer Mode, then Developer → Set User Password. Setup finds the headset, adds a key and
asks for that password once (or for a tap in the headset under Developer → Pair new host).</div>
<label>SSH alias (how Terminal and the scripts reach it: <code>ssh NAME</code>)<input type="text" id="addAlias" maxlength="64" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]*"></label>
<label>Address (optional: an IP or host name; left empty, setup looks for it)<input type="text" id="addHost" maxlength="253" placeholder="e.g. 192.168.1.40 or frame.local"></label>
<div class="row rep-actions"><span class="sub" id="addMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="addCancel">Cancel</button>
<button type="submit" class="action small" id="addGo">Set Up…</button></div>
</form>
</dialog>
<dialog id="rmDevDlg" class="dlg" aria-labelledby="rmDevTitle">
<form method="dialog" id="rmDevForm">
<h2 id="rmDevTitle">Remove this headset?</h2>
<div class="sub" id="rmDevText"></div>
<label style="display:flex; gap:8px; align-items:center; color:var(--text)"><input type="checkbox" id="rmDevConfig">
<span id="rmDevConfigText"></span></label>
<div class="row rep-actions"><span class="sub" id="rmMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="rmCancel">Cancel</button>
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
</form>
</dialog>
<div class="toast" id="toast"></div>
<script>
@@ -844,6 +957,7 @@ async function act(label, fn, btn) {
}
function setConn(ok, text) {
if (window.connPill && window.connPill()) return; // the connector's pill (below) says more
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
}
@@ -859,6 +973,7 @@ function setOnline(ok, why) {
setConn(false, "Offline");
$("battChip").hidden = true;
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
} else if (was === false) {
log("Connected to the Frame again", "ok");
reloadAll();
@@ -2298,5 +2413,436 @@ setView("headset");
refresh().then(loadShots); // after status, so app names resolve
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
// ---- the connection and the Devices tab (ui/frame_link.py, ui/frame_devices.py, docs/devices.md) ----
// The server streams the connection's state (server-sent events, read with fetch so the
// X-Frame-UI header goes along) every time it changes: which headset, this computer's
// network, each stage of connecting, and what every address answered.
const link = { s: null, offset: 0, live: false, phase: null, device: null, rev: -1, lastData: 0, reload: false };
const dv = { list: [], sel: null, data: null, found: null, editing: null };
const STAGE_ICON = { done: "✓", failed: "✕", pending: "", active: "", skipped: "–" };
const KIND_TAG = { lan: "LAN", mdns: "mDNS", tailscale: "Tailscale", manual: "Other" };
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const serverNow = () => Date.now() / 1000 - link.offset;
function ago(t) {
if (!t) return "never";
const s = Math.max(0, serverNow() - t);
return s < 60 ? "just now" : s < 3600 ? `${Math.round(s / 60)} min ago` : s < 86400 ? `${Math.round(s / 3600)} h ago`
: `${Math.round(s / 86400)} d ago`;
}
function secs(a, b) { return a && b ? `${Math.max(0, b - a).toFixed(1)} s` : a ? `${Math.max(0, serverNow() - a).toFixed(0)} s` : ""; }
const activeStage = s => (s.stages || []).find(x => x.state === "active") || (s.stages || []).filter(x => x.state === "failed").pop();
function netLabel(s) {
const n = s.network;
if (s.via && s.via.kind === "tailscale") return "Tailscale";
return n ? n.name : "Checking the network";
}
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
// The pill in the header: always there, and says what's happening right now.
function renderPill() {
const s = link.s;
if (!link.live || !s) return false;
const dev = s.device ? s.device.name : "Frame";
let dot = "wait", line;
if (s.phase === "connected") {
dot = "on";
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
} else if (s.phase === "failed") {
dot = "off";
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
} else {
const st = activeStage(s);
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
line = st ? st.label + (st.id === "find" && probing ? `: ${probing.host}` : st.detail && st.id !== "network" ? `: ${st.detail}` : "…")
: "Connecting…";
}
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
$("conn").title = `${dev}: ${line}. Click for details.`;
return true;
}
window.connPill = renderPill;
// The banner (when the headset can't be reached) repeats the reason and the countdown.
function renderBanner() {
const s = link.s;
if (!link.live || !s) return;
$("offDetails").hidden = false;
if (s.phase === "failed" && s.error) {
$("offline").hidden = false;
$("offMsg").textContent = s.error.message;
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).join("; ");
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
(left != null ? `Trying again in ${left} s.` : "");
} else if (s.phase === "connecting" && online === false) {
const st = activeStage(s);
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
}
}
window.connBanner = renderBanner;
function renderConnDlg() {
const s = link.s;
if (!s || !$("connDlg").open) return;
$("connTitle").textContent = s.device ? `${s.device.name} (ssh ${s.device.alias})` : "Connection";
$("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via && s.via.why ? " (" + s.via.why + ")" : ""}`
: s.phase === "failed" ? (s.error ? s.error.message : "Not connected")
: `${s.reason || "Connecting"}…`;
const n = s.network || {}, ts = n.tailscale || {};
const facts = [["Network", n.name || "—"], ["Wi-Fi", n.ssid || (n.wifi ? "Wi-Fi (name hidden by the system)" : n.wifi === false ? "Wired" : "—")],
["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"],
["This computer", n.local_ip || "—"],
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
$("connNet").innerHTML = facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
<span class="lb">${esc(st.id === "login" && s.device && s.device.user ? "Logging in as " + s.device.user : st.label)}</span>
<span class="tm">${esc(st.state === "pending" ? "" : secs(st.started, st.ended))}</span>
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
$("connProbes").innerHTML = (s.probes || []).map(p => `<tr><td>${esc(p.host)}${p.label ? `<div class="sub">${esc(p.label)}</div>` : ""}</td>
<td><span class="tag">${esc(KIND_TAG[p.kind] || p.kind)}</span></td><td class="sub">${esc(p.why || "")}</td>
<td class="res-${esc(p.state)}">${esc(p.detail)}${p.ip && p.ip !== p.host ? ` <span class="sub">${esc(p.ip)}</span>` : ""}</td></tr>`).join("")
|| `<tr><td class="sub">No addresses tried yet.</td></tr>`;
const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
$("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : "";
}
function onConnection(s) {
const prev = link.s;
link.s = s; link.live = !s.local; link.lastData = Date.now();
link.offset = Date.now() / 1000 - s.now;
if (!link.live) return;
renderPill(); renderConnDlg();
const devId = s.device && s.device.id;
if (link.device !== null && devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
state = null;
online = null;
link.reload = true; // everything on the page was the other headset's
$("battChip").hidden = true;
}
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase === "connected") {
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
$("offline").hidden = true;
const reload = link.reload;
link.reload = false;
refresh().then(() => { if (reload) reloadAll(); });
} else if (s.phase === "failed" && s.error) {
setOnline(false, s.error.message);
} else if (s.phase === "connecting" && prev && prev.phase === "connected") {
log(`${s.reason || "Reconnecting"}; reconnecting…`);
}
}
renderBanner();
if (s.devices_rev !== link.rev || devId !== link.device) { link.rev = s.devices_rev; loadDevices(); }
link.phase = s.phase; link.device = devId;
if (page === "devices") renderTests();
}
// Read the event stream; fall back to polling if it can't be streamed.
async function watchConnection() {
for (;;) {
const ctl = new AbortController();
const watchdog = setInterval(() => { if (Date.now() - link.lastData > 40000) ctl.abort(); }, 5000);
try {
link.lastData = Date.now();
const r = await fetch("/api/connection/events", { headers: { "X-Frame-UI": UI_KEY }, signal: ctl.signal });
if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
const reader = r.body.getReader(), dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
link.lastData = Date.now();
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
if (chunk.startsWith("data: ")) onConnection(JSON.parse(chunk.slice(6)));
}
}
} catch {
try { onConnection(await api("/api/connection")); } catch {}
} finally { clearInterval(watchdog); }
if (link.s && link.s.local) return;
await sleep(2000);
}
}
setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { renderPill(); renderBanner(); renderConnDlg(); } }, 1000);
$("conn").onclick = () => {
if (!link.live) { if (online === false) refresh(); return; }
$("connDlg").showModal(); renderConnDlg();
};
$("offDetails").onclick = () => $("conn").click();
$("connClose").onclick = () => $("connDlg").close();
$("connDevices").onclick = () => { $("connDlg").close(); location.hash = "devices"; };
async function retryNow() {
try { await api("/api/devices", { action: "retry" }); } catch (e) { toast(e.message, true); }
}
$("connRetryBtn").onclick = retryNow;
$("offRetry").onclick = () => { if (link.live) retryNow(); else refresh(); };
async function setUpHeadset(alias, host) {
return act(`Set Up Connection for ${alias}`, () => api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) }));
}
function setUpActive() {
const s = link.s;
if (HOST.mobile && window.frameApp && window.frameApp.setUpConnection) return window.frameApp.setUpConnection();
setUpHeadset(s && s.device ? s.device.alias : "frame");
}
$("connSetup").onclick = setUpActive;
// ---- Devices tab ----
PAGES.push("devices");
async function loadDevices() {
if (!link.live) return;
try { dv.data = await api("/api/devices"); } catch (e) { return failed($("devList"), e); }
dv.list = dv.data.devices;
if (!dv.list.some(d => d.id === dv.sel)) dv.sel = dv.data.active;
const sel = $("devSel");
sel.hidden = dv.list.length < 2;
sel.innerHTML = dv.list.map(d => `<option value="${esc(d.id)}"${d.active ? " selected" : ""}>${esc(d.name)}</option>`).join("");
if (window.frameApp && window.frameApp.devicesChanged) {
window.frameApp.devicesChanged(dv.list.map(d => ({ id: d.id, name: d.name, alias: d.alias, active: !!d.active })));
}
renderDevices();
}
$("devSel").onchange = e => useDevice(e.target.value);
async function useDevice(id) {
const d = dv.list.find(x => x.id === id);
if (!d || d.active) return;
await act(`Switch to ${d.name}`, () => api("/api/devices", { action: "use", id }));
}
async function devAction(label, body, btn) {
const res = await act(label, () => api("/api/devices", body), btn);
if (res && res.devices) { dv.data = res; dv.list = res.devices; renderDevices(); }
return res;
}
function deviceStatus(d) {
const s = link.s;
if (d.active && s) {
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
}
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
}
function renderDevices() {
if (!dv.data) return;
$("devCount").textContent = dv.list.length;
$("devList").innerHTML = dv.list.map(d => {
const [dot, text] = deviceStatus(d);
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
if (e.target.closest("[data-use]")) return useDevice(e.target.closest("[data-use]").dataset.use);
dv.sel = el.dataset.dev; dv.found = null; dv.editing = null; renderDevices();
});
const n = dv.data.network;
$("netNow").textContent = n ? `${n.name}${n.gateway && !n.name.includes(n.gateway) ? ` (router ${n.gateway})` : ""}${n.tailscale && n.tailscale.up ? " · Tailscale on" : ""}` : "Checking…";
if (document.activeElement !== $("netName")) {
const known = n && dv.data.networks.find(x => x.id === n.id);
$("netName").value = known ? known.name || "" : "";
$("netName").disabled = $("netSave").disabled = !(n && n.id);
}
renderDetail();
}
$("netSave").onclick = e => {
const n = dv.data && dv.data.network;
if (n && n.id) devAction("Name this network", { action: "name-network", network: n.id, name: $("netName").value }, e.currentTarget);
};
function renderDetail() {
const d = dv.list.find(x => x.id === dv.sel), el = $("devDetail");
if (!d) { el.innerHTML = `<div class="sub">Pick a headset.</div>`; return; }
if (el.contains(document.activeElement) && document.activeElement.matches("input, select") && el.dataset.dev === d.id) {
renderTests(); return; // don't rebuild the form under someone typing
}
el.dataset.dev = d.id;
if (d.transient) {
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2></div>
<div class="sub">This is the <code>${esc(d.alias)}</code> SSH alias, which Set Up Connection hasn't configured, so
ssh's own settings decide where it goes. Run Set Up Connection to manage its addresses here.</div>
<div class="row" style="margin-top:14px"><button class="action small" id="dvSetup">Set Up Connection…</button></div>`;
$("dvSetup").onclick = () => setUpHeadset(d.alias);
return;
}
const kinds = Object.entries(dv.data.kinds);
const kindSel = (id, v) => `<select id="${id}">${kinds.map(([k, label]) => `<option value="${k}"${k === v ? " selected" : ""}>${esc(label)}</option>`).join("")}</select>`;
const rows = d.addresses.map((a, i) => dv.editing === a.host ? `<div class="item addr"><div class="addr-edit">
<input type="text" id="edHost" value="${esc(a.host)}" maxlength="253" aria-label="Address">${kindSel("edKind", a.kind)}
<input type="text" id="edLabel" value="${esc(a.label)}" maxlength="60" placeholder="Label" aria-label="Label">
<button class="small action" id="edSave">Save</button><button class="small" id="edCancel">Cancel</button></div></div>`
: `<div class="item addr" data-host="${esc(a.host)}">
<div class="grow"><div class="t">${esc(a.host)} <span class="tag">${esc(KIND_TAG[a.kind] || a.kind)}</span>${a.label ? ` <span class="sub">${esc(a.label)}</span>` : ""}</div>
<div class="s">${a.network_names.length ? "Worked on " + esc(a.network_names.join(", ")) : "Hasn't worked on any network yet"}
· last OK ${esc(ago(a.last_ok))}${a.last_rtt_ms != null ? ` (${esc(a.last_rtt_ms)} ms)` : ""}</div>
<div class="s test" data-test="${esc(a.host)}"></div></div>
<button class="small" data-mv="-1" title="Try earlier"${i ? "" : " disabled"}>↑</button>
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
<form class="dev-form" id="dvForm">
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
</form>
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
<span class="sub">User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
<h3>Addresses <span class="sub" style="text-transform:none; letter-spacing:0; font-weight:400">tried all at once; this order breaks ties</span></h3>
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
<form class="addr-add" id="dvAdd">
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
${kindSel("adKind", "")}
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
<button class="small" type="submit">Add</button></form>
<div class="row" style="margin-top:14px">
<button class="small action" id="dvTest"${d.addresses.length ? "" : " disabled"}>Test now</button>
<button class="small" id="dvTs">Find on Tailscale</button>
<button class="small" id="dvMdns">Find on this network</button>
<span class="spacer"></span>
<button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
<button class="small danger" id="dvRemove">Remove…</button></div>
<div class="found" id="dvFound"></div>
<div class="hint">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
$("adKind").value = "";
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
$("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget);
$("dvAdd").onsubmit = e => {
e.preventDefault();
const host = $("adHost").value.trim();
if (!host) return $("adHost").focus();
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value, label: $("adLabel").value });
};
el.querySelectorAll("[data-host]").forEach(row => {
const host = row.dataset.host;
row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () =>
devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b));
row.querySelector("[data-ed]").onclick = () => { dv.editing = host; el.dataset.dev = ""; renderDetail(); $("edHost").focus(); };
row.querySelector("[data-rm]").onclick = e => devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget);
});
if ($("edSave")) {
const host = dv.editing;
$("edSave").onclick = async e => {
dv.editing = null;
await devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost: $("edHost").value.trim(),
kind: $("edKind").value, label: $("edLabel").value }, e.currentTarget);
};
$("edCancel").onclick = () => { dv.editing = null; el.dataset.dev = ""; renderDetail(); };
}
$("dvTest").onclick = e => devAction(`Test ${d.name}'s addresses`, { action: "test", id: d.id }, e.currentTarget);
$("dvTs").onclick = e => findOn("tailscale", d, e.currentTarget);
$("dvMdns").onclick = e => findOn("mdns", d, e.currentTarget);
$("dvForget").onclick = e => devAction(`Forget ${d.name}'s identity`, { action: "forget-identity", id: d.id }, e.currentTarget);
$("dvRemove").onclick = () => askRemove(d);
renderFound(d);
renderTests();
}
function renderTests() {
const d = dv.list.find(x => x.id === dv.sel), t = d && link.s && (link.s.tests || {})[d.id];
document.querySelectorAll("#dvAddrs [data-test]").forEach(el => {
const row = t && t.rows.find(r => r.host === el.dataset.test);
el.className = "s test" + (row ? ` res-${row.ssh || row.state}` : "");
el.textContent = row ? `Test: ${row.detail}${row.ssh === "checking" ? " · checking SSH…" : ""}` : "";
});
}
async function findOn(where, d, btn) {
btn.disabled = true;
$("dvFound").innerHTML = `<div class="wait">${where === "tailscale" ? "Asking Tailscale for its devices…" : "Looking for headsets on this network…"}</div>`;
try {
dv.found = { where, dev: d.id, data: await api(`/api/devices/${where}?id=${encodeURIComponent(d.id)}`) };
} catch (e) { dv.found = { where, dev: d.id, error: e.message }; }
finally { btn.disabled = false; }
renderFound(d);
}
function renderFound(d) {
const f = dv.found, el = $("dvFound");
if (!f || f.dev !== d.id) { el.innerHTML = ""; return; }
if (f.error) { el.innerHTML = `<div class="sub">${esc(f.error)}</div>`; return; }
const add = (host, kind, label, done) => done ? `<span class="sub">Added</span>`
: `<button class="small" data-add="${esc(host)}" data-kind="${kind}" data-label="${esc(label)}">Add ${esc(host)}</button>`;
let rows;
if (f.where === "tailscale") {
if (!f.data.up) { el.innerHTML = `<div class="sub">${esc(f.data.message)}</div>`; return; }
rows = f.data.peers.slice(0, 20).map(p => `<div class="item"><span class="dot ${p.online ? "on" : ""}"></span>
<div class="grow"><div class="t">${esc(p.name)} ${p.likely ? '<span class="tag">Likely</span>' : ""}</div>
<div class="s">${esc(p.dns)} · ${esc(p.ip || "")} · ${esc(p.os || "?")} · ${p.online ? "online" : "offline"}</div></div>
${p.added ? '<span class="sub">Added</span>' : add(p.dns || p.ip, "tailscale", "Tailscale", false) + (p.ip && p.dns ? add(p.ip, "tailscale", "Tailscale IP", false) : "")}</div>`);
} else {
rows = f.data.hosts.map(h => `<div class="item"><span class="dot ${h.state === "answered" ? "on" : ""}"></span>
<div class="grow"><div class="t">${esc(h.host)} ${h.advertised ? '<span class="tag">SteamOS devkit</span>' : ""}</div>
<div class="s res-${esc(h.state)}">${esc(h.detail)}${h.ip ? " · " + esc(h.ip) : ""}</div></div>
${add(h.host, "mdns", "mDNS", h.added)}${h.ip && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
if (!f.data.tool) rows.push(`<div class="sub">Install Bonjour (Windows) or avahi (Linux) to search for SteamOS devkit services.</div>`);
}
el.innerHTML = `<h3>${f.where === "tailscale" ? "On your tailnet" : "On this network"}</h3>` +
(rows.length ? `<div class="list">${rows.join("")}</div>` : `<div class="sub">Nothing found.</div>`);
el.querySelectorAll("[data-add]").forEach(b => b.onclick = async () => {
const res = await devAction(`Add ${b.dataset.add}`, { action: "address-add", id: d.id, host: b.dataset.add,
kind: b.dataset.kind, label: b.dataset.label }, b);
if (res) { b.replaceWith(Object.assign(document.createElement("span"), { className: "sub", textContent: "Added" })); }
});
}
function askRemove(d) {
$("rmDevTitle").textContent = `Remove ${d.name}?`;
$("rmDevText").textContent = `Frame Control forgets this headset, its ${d.addresses.length} address${d.addresses.length === 1 ? "" : "es"} and its saved identity. Its SSH keys stay.`;
$("rmDevConfigText").textContent = `Also remove the '${d.alias}' entry from ~/.ssh/config (Terminal's ssh ${d.alias} stops working)`;
$("rmDevConfig").checked = false; $("rmMsg").textContent = "";
$("rmDevConfig").parentElement.hidden = !d.managed;
$("rmCancel").onclick = () => $("rmDevDlg").close();
$("rmDevForm").onsubmit = async e => {
e.preventDefault();
const res = await devAction(`Remove ${d.name}`, { action: "remove", id: d.id, config: $("rmDevConfig").checked }, $("rmGo"));
if (res) $("rmDevDlg").close();
};
$("rmDevDlg").showModal();
}
$("devAdd").onclick = () => {
$("addAlias").value = (dv.data && dv.data.nextAlias) || "frame-2";
$("addHost").value = ""; $("addMsg").textContent = "";
$("addCancel").onclick = () => $("addDevDlg").close();
$("addDevDlg").showModal();
$("addAlias").select();
};
$("addDevForm").onsubmit = async e => {
e.preventDefault();
const alias = $("addAlias").value.trim(), host = $("addHost").value.trim();
$("addGo").disabled = true;
try {
const res = await api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) });
log(res.message, "ok"); toast(res.message);
$("addDevDlg").close();
} catch (err) { $("addMsg").textContent = err.message; }
finally { $("addGo").disabled = false; }
};
// Electron's Frame menu can switch headsets and open this tab.
if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(id => useDevice(id));
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
showPage(); // #devices is a page now
api("/api/host").then(h => {
if (h.mobile) return;
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
watchConnection();
}).catch(() => watchConnection());
</script>
</body>
</html>
+110 -40
View File
@@ -3,7 +3,8 @@
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
up by scripts/connect.sh or ui/frame_connect.py.
up by scripts/connect.sh or ui/frame_connect.py, or another headset picked on the
Devices tab: frame_link.py finds it at one of its addresses (frame_devices.py).
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
Env: FRAME_ALIAS (default frame)
@@ -39,7 +40,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_android # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_store # noqa: E402
import frame_titles # noqa: E402
import frame_webinstall # noqa: E402
@@ -56,18 +59,38 @@ if LOCAL:
UI_KEY = os.environ.get("FRAME_UI_KEY") or "1"
DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
FRAME = os.environ.get("FRAME_ALIAS", "frame")
FRAME_FROM_ENV = "FRAME_ALIAS" in os.environ
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
# supports it (not on Windows: there every command connects on its own).
CONTROL = None if LOCAL else frame_host.control_path()
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
MUX_BASE = list(MUX)
# Commands use the master when it's up and connect directly when it isn't.
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
SSH_TAIL = [*(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
SSH = [*MUX, *SSH_TAIL]
# The address the connector picked (-o HostName=... and friends), in every ssh command.
HOST_OPTS = []
# Android helpers share the multiplexed connection when it's up.
frame_android.SSH_OPTS = SSH[1:]
def route(alias, host_opts):
"""Point every ssh, scp and rsync at `alias` with `host_opts` (frame_link calls this
when it picks a headset and an address). The lists change in place, so code holding
them follows; frame_titles reads frame_android.SSH_OPTS at call time."""
global FRAME, HOST_OPTS
FRAME = frame_android.FRAME = alias
HOST_OPTS = list(host_opts)
MUX[:] = [*MUX_BASE, *HOST_OPTS]
SSH[:] = [*MUX, *SSH_TAIL]
frame_android.SSH_OPTS = SSH[1:]
LINK = None # the connector (frame_link.Link); None on the Frame itself
APPID = re.compile(r"^\d{1,10}$")
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
MAX_UPLOAD = 8 * 1024**3
@@ -181,39 +204,10 @@ def job_status(query):
return snapshot
_master_lock = threading.Lock()
_master = None
def ensure_master():
"""Start the shared SSH connection if it isn't up (one attempt at a time).
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
"""
global _master
if not CONTROL:
return
def up():
try:
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
timeout=5).returncode == 0
except subprocess.TimeoutExpired:
return False
with _master_lock:
if up() or (_master and _master.poll() is None):
return
# Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s,
# so the next request starts a fresh master.
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2", "-N", FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
for _ in range(60):
if up() or _master.poll() is not None:
return
time.sleep(0.05)
"""Make sure the connection to the headset is up, or being tried (frame_link.Link.ensure)."""
if LINK:
LINK.ensure()
def ssh(remote, *, stdin=None, timeout=30, text=True):
@@ -228,6 +222,8 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
raise Failure(f"Timed out talking to {FRAME}")
if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
if r.returncode == 255 and LINK and unreachable(err):
LINK.lost(err) # ssh itself failed: the connector reconnects
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
raise failure
@@ -813,7 +809,7 @@ class AdbTunnel:
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
# Its own connection (ControlPath=none), so killing it drops the forwards.
self.proc = _proc = subprocess.Popen(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none",
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none", *HOST_OPTS,
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
_live_tunnels.add(_proc)
@@ -1230,7 +1226,48 @@ def _sweep_one(prefix, d):
POST = {"/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/webinstall/cancel": webinstall_cancel, "/api/devices": lambda body: devices_post(body)}
# ---- headsets and the connection (frame_devices.py, frame_link.py) ----------
def open_setup(alias, host=None):
"""Set Up Connection for `alias` in a terminal window, as the app's menu does."""
if frame_host.MAC:
argv = ["env", f"FRAME_ALIAS={alias}", "zsh", str(HERE.parent / "scripts" / "connect.sh")]
else:
argv = [sys.executable, str(HERE / "frame_connect.py"), "--alias", alias]
return terminal(argv + ([host] if host else []))
def devices_post(body):
if not LINK:
raise Failure("Headsets are managed from the computer app", 400)
try:
return frame_link.devices_action(LINK, body, open_setup)
except frame_devices.DeviceError as e:
raise Failure(str(e), 400)
def devices_get(path, query):
if not LINK:
raise Failure("Headsets are managed from the computer app", 400)
q = parse_qs(query)
device = (q.get("id") or [None])[0]
try:
if path == "/api/devices":
return dict(frame_link.devices_view(LINK), nextAlias=frame_link.next_alias(LINK))
if path == "/api/devices/tailscale":
return frame_link.tailscale_find(LINK, device)
return frame_link.mdns_find(LINK, device)
except frame_devices.DeviceError as e:
raise Failure(str(e), 400)
def connection_state():
if not LINK: # on the Frame itself there's nothing to find
return {"local": True, "phase": "connected", "version": 0}
return LINK.snapshot()
# ---- HTTP ------------------------------------------------------------------
@@ -1335,6 +1372,12 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/connection":
self.send_json(connection_state())
elif path == "/api/connection/events":
self.connection_events()
elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"):
self.send_json(devices_get(path, url.query))
elif path == "/api/apk-versions":
self.send_json(apk_versions(url.query))
elif path == "/api/android":
@@ -1410,6 +1453,30 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
def connection_events(self):
"""Server-sent events: the connection state each time it changes, until the page goes.
(The page reads it with fetch, which can send the X-Frame-UI header; EventSource can't.)"""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.end_headers()
self.close_connection = True
version = -1
try:
while True:
snap = connection_state() if version < 0 else (LINK.wait(version, 15) if LINK else None)
if snap is None:
if not LINK and version >= 0:
time.sleep(15)
self.wfile.write(b": still here\n\n") # keeps proxies and the page's watchdog happy
else:
version = max(snap["version"], 0)
self.wfile.write(b"data: " + json.dumps(snap).encode() + b"\n\n")
self.wfile.flush()
except OSError:
pass # the page went away
def stream_video(self, query):
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
global _stream_proc
@@ -1536,6 +1603,11 @@ def main():
args = ap.parse_args()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
sweep_tmp()
global LINK
if not LOCAL:
LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None,
mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable)
LINK.start()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
@@ -1556,10 +1628,8 @@ def main():
signal.signal(signal.SIGTERM, signal.SIG_IGN)
webinstall_shutdown()
# The master was started with -N, so it stays up until told to exit.
if CONTROL:
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
if _master and _master.poll() is None:
_master.terminate()
if LINK:
LINK.stop()
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
if proc.poll() is None:
proc.terminate()