diff --git a/app/main.js b/app/main.js
index bf6ff66..aac8d35 100644
--- a/app/main.js
+++ b/app/main.js
@@ -245,6 +245,23 @@ function fromUi(e) {
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
+// The page reports the headsets it knows (the server's Devices tab), so the Frame
+// menu can switch between them. Only plain names and ids go into the menu.
+const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
+let devices = [];
+ipcMain.on("devices:changed", (e, list) => {
+ if (!fromUi(e) || !Array.isArray(list)) return;
+ const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
+ .map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
+ if (JSON.stringify(next) === JSON.stringify(devices)) return;
+ devices = next;
+ buildMenu();
+});
+const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
+function showDevices() {
+ if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
+}
+
// frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch),
// so they wait here until the page asks for them. The page checks the link with
@@ -326,13 +343,14 @@ async function runInTerminal(argv) {
}
}
-async function setUpConnection() {
- const alias = `FRAME_ALIAS=${FRAME}`;
+// Set Up Connection for the headset in use (or another alias, from the Devices tab).
+async function setUpConnection(name = activeAlias()) {
+ if (!ALIAS_RE.test(name)) return;
+ const alias = `FRAME_ALIAS=${name}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
- const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
- // A new console inherits our environment on Windows; Linux terminals may not.
- runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
+ // --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
+ runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
}
function buildMenu() {
@@ -343,8 +361,15 @@ function buildMenu() {
{
label: "Frame",
submenu: [
- { label: "Set Up Connection…", click: setUpConnection },
- { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
+ { label: "Set Up Connection…", click: () => setUpConnection() },
+ { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", activeAlias()]) },
+ { type: "separator" },
+ ...(devices.length > 1 ? [{
+ label: "Headset",
+ submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
+ click: () => { if (win) win.webContents.send("use-device", d.id); } })),
+ }] : []),
+ { label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
diff --git a/app/preload.js b/app/preload.js
index 43bc34d..9ff2567 100644
--- a/app/preload.js
+++ b/app/preload.js
@@ -2,7 +2,8 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
-// It can open Set Up Connection when the headset can't be reached.
+// It can open Set Up Connection when the headset can't be reached, and keeps the
+// Frame menu's list of headsets up to date.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
const { contextBridge, ipcRenderer, webUtils } = require("electron");
@@ -10,6 +11,12 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
+ // The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
+ devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
+ onUseDevice: (cb) => {
+ ipcRenderer.removeAllListeners("use-device");
+ ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
+ },
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
diff --git a/docs/devices.md b/docs/devices.md
new file mode 100644
index 0000000..4f01b69
--- /dev/null
+++ b/docs/devices.md
@@ -0,0 +1,167 @@
+# Headsets, addresses and the connection
+
+Frame Control can manage more than one Steam Frame, and each headset can be
+reached at more than one address: a LAN IP at home, another at the office, its
+mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
+tab (key 5) lists them, and the connection pill in the header shows what the
+app is doing to reach the one in use, step by step, as it happens.
+
+The code is in three modules, all stdlib-only Python on your computer:
+
+| Module | What it does |
+|---|---|
+| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
+| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
+| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
+
+## Headsets
+
+Each headset keeps its own SSH alias, as Set Up Connection has always written
+it: the first is `frame`, the next `frame-2`, and so on. Terminal's
+`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
+work for each one.
+
+- **Nothing to migrate by hand.** On first start, the app imports every
+ `# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
+ the block's HostName as its first address. It also copies the host key your
+ `known_hosts` already trusts for that address into the app's own
+ `~/.ssh/frame-control_known_hosts`, so nobody is asked to trust it again.
+- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
+ `ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
+ When it writes its block, the app picks the headset up by itself. If Set Up
+ Connection runs again and finds a headset somewhere new, that address is added
+ at the top of its list.
+- **Use this headset** (or the switcher in the header, or the app's
+ **Frame → Headset** menu) moves the whole app to another headset; every panel
+ reloads from it.
+- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
+ the box; either way it isn't imported again unless Set Up Connection changes it.
+- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
+ app shows it as not set up and lets ssh's own config decide where it goes.
+
+## Addresses
+
+Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
+and changeable), an optional label, the networks it has worked on, and when it
+last worked with its round-trip time.
+
+When connecting, the app **tries all addresses at once** (TCP to the SSH port)
+and ranks them:
+
+1. addresses that worked on the network this computer is on now;
+2. mDNS names;
+3. Tailscale addresses, if Tailscale is running here;
+4. addresses not tried on this network yet;
+5. addresses that only ever worked on other networks;
+6. Tailscale addresses while Tailscale is off.
+
+Your order on the Devices tab breaks ties. The best-ranked address that answers
+wins; one that answers first waits up to 0.35 s for a better-ranked one that is
+still trying. If SSH to the winner fails in a way another address could fix
+(a different device answered there, or the link dropped), the next one that
+answered is tried. Every success records the network on that address, so next
+time on that network it's tried first.
+
+**Test now** probes every address and tries SSH on each one that answers, without
+disturbing the connection in use: "SSH works", "answered as a different
+headset", "refused this computer's key", or why it didn't answer. **Find on
+Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
+status --json`, including the Mac app's own CLI) with buttons to add their
+MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
+services and checks `ALIAS.local` and `frame.local`.
+
+## Networks
+
+A network is told apart by its default gateway: the router's IP address plus its
+hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
+(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
+on macOS 14 and later, which hides the Wi-Fi name from apps without Location
+permission. Where the system does share the Wi-Fi name, it's shown, and you can
+name any network yourself ("Home Wi-Fi") on the Devices tab.
+
+The app rereads the gateway every 5 seconds and Tailscale's state every
+30 seconds. Changing networks reconnects.
+
+## The connection, stage by stage
+
+The connector runs in the server (`frame_link.Link`) and moves through:
+
+1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
+2. **Finding the headset**: each address resolving, trying, answered in N ms,
+ no answer, refused, or can't be found.
+3. **Opening SSH** to the address that answered.
+4. **Checking the headset's identity**: the host key must match the one pinned
+ for this headset.
+5. **Logging in** as the headset's user.
+6. **Connected** via network N, address A, round trip T; or **failed** at a stage
+ with the reason in plain words and a countdown to the next try (5, 10, 20,
+ then every 30 seconds). Retry now skips the wait.
+
+Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
+connection is an SSH ControlMaster that every command shares; when it dies (the
+headset slept or left the network) the connector notices and starts again. On
+Windows, where OpenSSH can't share a connection, the same handshake runs once
+and each command then connects on its own; a command that can't reach the
+headset makes the connector start again.
+
+Once connected, every `ssh`, `scp` and `rsync` the app runs gets
+`-o HostName=
-o HostKeyAlias=frame-control-
+-o UserKnownHostsFile=~/.ssh/frame-control_known_hosts -o User=… -o Port=…`. The
+alias's block in `~/.ssh/config` is also updated to the last address that
+worked (and to the user and port you set), so Terminal's `ssh frame` and the
+scripts follow.
+
+**Host keys are pinned per headset, not per address.** Your own `known_hosts`
+is keyed by address, so a different device answering at a remembered IP (a DHCP
+lease that moved) would look like a new host there. The app keys its own
+known_hosts by headset instead: a different device answering at one of its
+addresses is refused, and the pill says so. A headset's first connection trusts
+the key it shows, as Set Up Connection does. After reinstalling SteamOS the
+headset has a new key; **Forget identity** on the Devices tab lets the next
+connection save the new one.
+
+## API
+
+All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
+
+| Request | Returns |
+|---|---|
+| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
+| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
+| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
+| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
+| `GET /api/devices/mdns?id=` | Headsets found on this network |
+| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
+
+Every host, alias and user is checked against strict patterns before it's
+stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
+through a shell.
+
+## The registry file
+
+`devices.json` in the app's data folder (`~/Library/Application Support/Frame
+Control` on macOS, `%APPDATA%\Frame Control` on Windows,
+`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
+share the format later (it still connects to one host; see
+[iphone.md](iphone.md)):
+
+```json
+{"version": 1, "active": "f67f8b7e",
+ "devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
+ "identity_files": ["~/.ssh/id_ed25519_frame"],
+ "addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
+ "networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
+ "networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
+ "gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
+```
+
+A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
+
+## Tests
+
+`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
+with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
+prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
+computer for the addresses, and temporary folders for `~/.ssh`
+(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
+never touch yours.
diff --git a/docs/frame-control.md b/docs/frame-control.md
index 299c57c..f73c484 100644
--- a/docs/frame-control.md
+++ b/docs/frame-control.md
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
-The window has four tabs: **Home** (headset view, status, screenshots),
+The window has five tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
-the catalogue, display settings, reports) and **Tools** (sending files and text,
-Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
-anywhere in the window. When the Frame can't be reached, one banner says why in
-plain words and the app retries every few seconds, filling everything in once it
-answers. Flatpak and Android installs run in the background; the bottom bar
+the catalogue, display settings, reports), **Tools** (sending files and text,
+Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
+Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
+connection pill in the header always shows which headset, which network this
+computer is on, the address in use or being tried, and each step of connecting
+as it happens; click it for the whole timeline. When the Frame can't be
+reached, a banner says why in plain words, what was tried, and counts down to
+the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
@@ -72,6 +75,11 @@ counts them while they run.
Frame clipboard.
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
+- **Devices**: several headsets, each with several addresses (LAN IPs per
+ network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
+ tries them all at once and learns which worked on which network. Add, edit,
+ reorder and test addresses, find a headset on Tailscale or on this network,
+ name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Sleep, restart and shut down open a terminal window because SteamOS
@@ -92,7 +100,9 @@ trusts root certificates already in the Windows store.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
header, so other websites can't drive it or read captures. Everything reaches
-the headset through the `frame` SSH alias. On macOS and Linux it keeps one
+the headset through its SSH alias (`frame` for the first one), pointed at the
+address that answered with `-o HostName=` (`ui/frame_link.py`, described in
+[devices.md](devices.md)). On macOS and Linux it keeps one
multiplexed SSH connection open, so status and each capture take about 0.3 s.
Windows' OpenSSH can't share a connection, so there each request connects on
its own and the app is a little slower. What differs between the three
diff --git a/tests/fakessh/ssh b/tests/fakessh/ssh
new file mode 100755
index 0000000..af0ebb4
--- /dev/null
+++ b/tests/fakessh/ssh
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at
+each step of a connection, and plays a ControlMaster. What each HostName does comes
+from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key) or "denied");
+every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
+import json
+import os
+import signal
+import sys
+import time
+
+args = sys.argv[1:]
+with open(os.environ["FAKESSH_LOG"], "a") as f:
+ f.write(json.dumps(args) + "\n")
+hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
+opts = {}
+i = 0
+while i < len(args) and args[i].startswith("-"):
+ if args[i] in ("-o", "-O", "-p", "-l"):
+ key = args[i]
+ val = args[i + 1]
+ if key == "-o":
+ k, _, v = val.partition("=")
+ opts[k.lower()] = v
+ else:
+ opts[key] = val
+ i += 2
+ else:
+ opts[args[i]] = True
+ i += 1
+alias = args[i] if i < len(args) else ""
+host = opts.get("hostname", alias).replace("%%", "%")
+marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
+say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
+
+if "-G" in opts:
+ print(f"hostname {alias}\nport 22\nuser tester")
+ sys.exit(0)
+if opts.get("-O") == "check":
+ sys.exit(0 if os.path.exists(marker) else 255)
+if opts.get("-O") == "exit":
+ if os.path.exists(marker):
+ os.unlink(marker)
+ sys.exit(0)
+
+what = hosts.get(host)
+if what is None:
+ say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
+ sys.exit(255)
+say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.")
+say("debug1: Connection established.")
+say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
+say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
+if what == "wrong":
+ say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
+ say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
+ say("Host key verification failed.")
+ sys.exit(255)
+say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.")
+say("debug1: Next authentication method: publickey")
+if what == "denied":
+ say(f"tester@{host}: Permission denied (publickey).")
+ sys.exit(255)
+say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
+if opts.get("controlmaster") == "yes":
+ open(marker, "w").close()
+ def bye(*_):
+ if os.path.exists(marker):
+ os.unlink(marker)
+ sys.exit(0)
+ signal.signal(signal.SIGTERM, bye)
+ while True:
+ time.sleep(0.2)
+ if not os.path.exists(marker):
+ sys.exit(0)
+sys.exit(0)
diff --git a/tests/sandbox.py b/tests/sandbox.py
new file mode 100644
index 0000000..5d2a3f0
--- /dev/null
+++ b/tests/sandbox.py
@@ -0,0 +1,16 @@
+"""Imported first by every test module: nothing a test does reaches this person's
+app data, their telemetry, or the shared compatibility database.
+
+Must run before any ui module is imported, since those read these at import time.
+"""
+import atexit
+import os
+import shutil
+import tempfile
+
+_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
+atexit.register(shutil.rmtree, _dir, ignore_errors=True)
+os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
+os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
+# A maintainer's machine holds the database key; send anything that slips through nowhere.
+os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
diff --git a/tests/test_compat_db.py b/tests/test_compat_db.py
index 7081d79..37208d5 100644
--- a/tests/test_compat_db.py
+++ b/tests/test_compat_db.py
@@ -2,6 +2,7 @@
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import sys
import tempfile
diff --git a/tests/test_connect.py b/tests/test_connect.py
index b3366bd..0e8972a 100644
--- a/tests/test_connect.py
+++ b/tests/test_connect.py
@@ -3,6 +3,7 @@ steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers.
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import socket
import sys
diff --git a/tests/test_devices.py b/tests/test_devices.py
new file mode 100644
index 0000000..83c187c
--- /dev/null
+++ b/tests/test_devices.py
@@ -0,0 +1,273 @@
+"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned
+host keys and input checks. Everything works in temporary folders.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_devices as fd # noqa: E402
+
+CONFIG = """Host lxso1
+ HostName 192.168.1.109
+
+# >>> steam-frame (frame) >>>
+Host frame
+ HostName frame.tail1234.ts.net
+ User steamos
+ IdentityFile ~/.ssh/id_ed25519_frame
+ IdentityFile ~/.ssh/id_rsa_frame_devkit
+ IdentitiesOnly yes
+ ServerAliveInterval 30
+Host *
+# <<< steam-frame (frame) <<<
+# >>> steam-frame (frame-2) >>>
+Host frame-2
+ HostName 192.168.1.60
+ Port 2222
+ User deck
+ IdentityFile ~/.ssh/id_ed25519_frame
+Host *
+# <<< steam-frame (frame-2) <<<
+Host *
+ ServerAliveInterval 60
+"""
+KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE"
+
+
+class Base(unittest.TestCase):
+ def setUp(self):
+ self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-"))
+ self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
+ self.ssh = self.dir / "ssh"
+ self.ssh.mkdir()
+ (self.ssh / "config").write_text(CONFIG)
+ old = os.environ.get("FRAME_CONTROL_SSH_DIR")
+ os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh)
+ self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old
+ else os.environ.pop("FRAME_CONTROL_SSH_DIR", None))
+ self.reg = fd.Registry(self.dir / "devices.json")
+
+
+class Validation(unittest.TestCase):
+ def test_hosts(self):
+ for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::5928:ae55", "fe80::1%en0", "frame-2.tail1234.ts.net"):
+ self.assertEqual(fd.check_host(good), good)
+ for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)",
+ "frame%en0", "x" * 300, None, 5, "frame/../x"):
+ with self.assertRaises(fd.DeviceError, msg=repr(bad)):
+ fd.check_host(bad)
+
+ def test_names(self):
+ self.assertEqual(fd.check_alias("frame-2"), "frame-2")
+ for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None):
+ with self.assertRaises(fd.DeviceError):
+ fd.check_alias(bad)
+ with self.assertRaises(fd.DeviceError):
+ fd.check_user(bad)
+ for bad in ("0", "65536", "x", None, "22; id"):
+ with self.assertRaises(fd.DeviceError):
+ fd.check_port(bad)
+ self.assertEqual(fd.check_port("2222"), 2222)
+ with self.assertRaises(fd.DeviceError):
+ fd.check_text("line\nbreak", "label")
+ with self.assertRaises(fd.DeviceError):
+ fd.check_kind("wifi")
+
+ def test_ipv6_zone_is_escaped_for_ssh(self):
+ self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0")
+
+
+class Migration(Base):
+ def test_blocks_are_parsed(self):
+ blocks = fd.parse_blocks(CONFIG)
+ self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"])
+ self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net")
+ self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"])
+ self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck"))
+
+ def test_existing_headsets_are_imported_once(self):
+ self.assertTrue(self.reg.sync_from_config(seed=False))
+ devices = self.reg.devices()
+ self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"])
+ frame, second = devices
+ self.assertEqual(frame["name"], "Steam Frame")
+ self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net")
+ self.assertEqual(frame["addresses"][0]["kind"], "tailscale")
+ self.assertEqual((second["user"], second["port"]), ("deck", 2222))
+ self.assertEqual(self.reg.active(), frame["id"])
+ self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new
+ # It's all on disk, in the documented shape.
+ data = json.loads((self.dir / "devices.json").read_text())
+ self.assertEqual(data["version"], 1)
+ self.assertEqual(len(data["devices"]), 2)
+ self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices())
+
+ def test_setup_finding_a_new_address_adds_it(self):
+ self.reg.sync_from_config(seed=False)
+ (self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237"))
+ self.assertTrue(self.reg.sync_from_config(seed=False))
+ hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]]
+ self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first
+
+ def test_removed_headset_stays_removed_until_setup_changes_it(self):
+ self.reg.sync_from_config(seed=False)
+ second = self.reg.by_alias("frame-2")
+ self.reg.remove_device(second["id"])
+ self.reg.sync_from_config(seed=False)
+ self.assertIsNone(self.reg.by_alias("frame-2"))
+ self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab
+ self.reg.sync_from_config(seed=False)
+ self.assertIsNotNone(self.reg.by_alias("frame-2"))
+
+ def test_corrupt_registry_is_ignored(self):
+ (self.dir / "bad.json").write_text("{not json")
+ self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), [])
+ (self.dir / "evil.json").write_text(json.dumps({"devices": [
+ {"id": "x1", "alias": "-oProxyCommand=id", "addresses": []},
+ {"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]}))
+ devices = fd.Registry(self.dir / "evil.json").devices()
+ self.assertEqual([d["alias"] for d in devices], ["ok"])
+ self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"])
+
+
+class ConfigRewrite(Base):
+ def test_hostname_user_and_port_change_only_inside_the_block(self):
+ cfg = self.ssh / "config"
+ self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237"))
+ text = cfg.read_text()
+ self.assertIn(" HostName 192.168.1.237\n", text)
+ self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved
+ self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write
+ self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck"))
+ block = fd.parse_blocks(cfg.read_text())[0]
+ self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237"))
+ self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: the line goes
+ self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22)
+ self.assertNotIn("Port 22\n", cfg.read_text())
+ self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched
+ if os.name != "nt":
+ self.assertEqual(cfg.stat().st_mode & 0o777, 0o600)
+
+ def test_zone_is_escaped_and_read_back(self):
+ fd.rewrite_block("frame", hostname="fe80::1%en0")
+ self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
+ self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0")
+
+ def test_missing_block_is_left_alone(self):
+ self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1"))
+ self.assertFalse(fd.remove_block("frame-9"))
+ self.assertTrue(fd.remove_block("frame-2"))
+ self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"])
+
+
+@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen")
+class Pins(Base):
+ def test_seed_copies_the_trusted_key_under_the_device_alias(self):
+ (self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n")
+ self.assertFalse(fd.pinned("d1"))
+ self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
+ self.assertTrue(fd.pinned("d1"))
+ self.assertEqual(fd.known_hosts().read_text(), f"frame-control-d1 {KEY}\n")
+ self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
+ self.assertEqual(fd.known_hosts().read_text().count("\n"), 1)
+ self.assertFalse(fd.seed_pin("d2", ["never-seen.example"]))
+ self.assertTrue(fd.forget_pin("d1"))
+ self.assertFalse(fd.pinned("d1"))
+
+ def test_hashed_and_non_default_port_entries(self):
+ kh = self.ssh / "known_hosts"
+ kh.write_text(f"[frame.local]:2222 {KEY}\n")
+ subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
+ self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
+ self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
+ self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts().read_text())
+
+ def test_known_hosts_option_uses_the_override(self):
+ self.assertEqual(fd.known_hosts_opt(), str(self.ssh / "frame-control_known_hosts"))
+
+
+class Registry(Base):
+ def test_address_editing(self):
+ d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
+ a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
+ self.assertEqual(a["kind"], "mdns")
+ self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale")
+ with self.assertRaises(fd.DeviceError):
+ self.reg.add_address(d["id"], "frame-3.local") # already there
+ with self.assertRaises(fd.DeviceError):
+ self.reg.add_address(d["id"], "frame-3.local; id")
+ self.reg.move_address(d["id"], "100.100.1.1", -1)
+ self.reg.move_address(d["id"], "100.100.1.1", -1)
+ self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays
+ hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]]
+ self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"])
+ self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2)
+ self.reg.update_address(d["id"], "192.168.1.40", label="Home")
+ self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned
+ self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41")
+ moved = self.reg.get(d["id"])["addresses"][1]
+ self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None))
+ self.reg.remove_address(d["id"], "192.168.1.41")
+ self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"])
+ with self.assertRaises(fd.DeviceError):
+ self.reg.remove_address(d["id"], "nope")
+
+ def test_devices(self):
+ a = self.reg.add_device("frame")
+ b = self.reg.add_device("frame-2", name="Office")
+ self.assertEqual(self.reg.active(), a["id"])
+ with self.assertRaises(fd.DeviceError):
+ self.reg.add_device("frame")
+ self.reg.set_active(b["id"])
+ self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222)
+ with self.assertRaises(fd.DeviceError):
+ self.reg.update_device(b["id"], user="bad user")
+ self.reg.remove_device(b["id"])
+ self.assertEqual(self.reg.active(), a["id"])
+ with self.assertRaises(fd.DeviceError):
+ self.reg.get(b["id"])
+
+ def test_networks_get_names(self):
+ net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True}
+ self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1")
+ self.reg.record_network(net)
+ self.reg.name_network("n-1", "Home Wi-Fi")
+ self.assertEqual(self.reg.network_name(net), "Home Wi-Fi")
+ self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe")
+ self.assertEqual(self.reg.network_name(None), "No network")
+ with self.assertRaises(fd.DeviceError):
+ self.reg.name_network("n-unknown", "x")
+
+
+class Order(unittest.TestCase):
+ def addr(self, host, kind, networks=()):
+ return {"host": host, "kind": kind, "networks": list(networks)}
+
+ def test_known_here_then_mdns_then_tailscale_then_the_rest(self):
+ addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"),
+ self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"),
+ self.addr("192.168.1.237", "lan", ["n-home"])]
+ order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
+ self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"])
+ # Tailscale off: its addresses go last.
+ order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)]
+ self.assertEqual(order[-1], "100.64.1.2")
+ # On an unknown network nothing has worked yet; the user's order breaks ties.
+ ranked = fd.order_addresses(addrs, None, True)
+ self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"])
+ self.assertEqual(ranked[0][1], "mDNS name")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py
index 1af14f9..ebe7b0b 100644
--- a/tests/test_frame_apk.py
+++ b/tests/test_frame_apk.py
@@ -1,4 +1,5 @@
"""frame_apk against a small APK built here: binary manifest plus resource table."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import os
import struct
diff --git a/tests/test_frame_apk_versions.py b/tests/test_frame_apk_versions.py
index 8cf7701..ae19175 100644
--- a/tests/test_frame_apk_versions.py
+++ b/tests/test_frame_apk_versions.py
@@ -1,4 +1,5 @@
"""Offline version lookup with small index-v2 fixtures."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
diff --git a/tests/test_frame_titles.py b/tests/test_frame_titles.py
index 55d080d..acb9454 100644
--- a/tests/test_frame_titles.py
+++ b/tests/test_frame_titles.py
@@ -1,4 +1,5 @@
"""frame_titles without a headset: executable headers, launch targets, zips, runtimes."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import shutil
diff --git a/tests/test_link.py b/tests/test_link.py
new file mode 100644
index 0000000..a2575b4
--- /dev/null
+++ b/tests/test_link.py
@@ -0,0 +1,337 @@
+"""frame_link: finding a headset among its addresses and following each stage of
+connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer.
+Also the server's /api/connection, its event stream, and /api/devices.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import http.client
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from pathlib import Path
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_devices as fd # noqa: E402
+import frame_link as fl # noqa: E402
+import frame_network as fn # noqa: E402
+
+FAKESSH = ROOT / "tests" / "fakessh"
+NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0",
+ "ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}}
+
+
+def explain(msg):
+ """A cut-down server.unreachable, so this needs no server import."""
+ if "Could not resolve" in msg:
+ return "Can't find the Frame on the network."
+ if "refused" in msg:
+ return "The Frame refused the connection."
+ if "timed out" in msg.lower():
+ return "The Frame isn't answering."
+ if "Permission denied" in msg:
+ return "The Frame didn't accept this computer's SSH key."
+ return None
+
+
+class Probe(unittest.TestCase):
+ def test_answers_refusals_and_unknown_names(self):
+ with socket.socket() as srv:
+ srv.bind(("127.0.0.1", 0))
+ srv.listen(4)
+ port = srv.getsockname()[1]
+ seen = []
+ res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"]))
+ self.assertEqual(res["state"], "answered")
+ self.assertEqual(res["ip"], "127.0.0.1")
+ self.assertIsInstance(res["rtt_ms"], float)
+ self.assertEqual(seen, ["resolving", "trying"])
+ self.assertEqual(fl.probe("127.0.0.1", port, timeout=2)["state"], "refused") # closed now
+ self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved")
+
+ def test_failed_probes_read_like_ssh(self):
+ # So the server's UNREACHABLE table words them like any other ssh failure.
+ self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"}))
+ self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"}))
+ self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"}))
+
+
+class Pick(unittest.TestCase):
+ def pick(self, results, tried=()):
+ return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5)
+
+ def test_best_ranked_answer_wins(self):
+ now = time.monotonic()
+ ok = lambda t=now: {"state": "answered", "t": t}
+ no = {"state": "timeout", "t": now}
+ self.assertEqual(self.pick([no, ok(), ok()]), 1)
+ self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2)
+ self.assertIsNone(self.pick([no, no]))
+ # A worse-ranked answer waits PREFER for a better one still trying, then goes.
+ t0 = time.monotonic()
+ self.assertEqual(self.pick([None, ok(time.monotonic())]), 1)
+ self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05)
+
+ def test_gives_up_on_slow_lookups_at_the_deadline(self):
+ t0 = time.monotonic()
+ results = [None]
+ self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3))
+ self.assertLess(time.monotonic() - t0, 2)
+ self.assertEqual(results[0]["state"], "timeout")
+
+
+@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
+class Connecting(unittest.TestCase):
+ def setUp(self):
+ self.dir = Path(tempfile.mkdtemp(prefix="frame-link-"))
+ self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
+ (self.dir / "ssh").mkdir()
+ self.log = self.dir / "calls.jsonl"
+ env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log),
+ "FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
+ patcher = mock.patch.dict(os.environ, env)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))):
+ p = mock.patch.object(fn, name, value)
+ p.start()
+ self.addCleanup(p.stop)
+ self.srv = socket.socket()
+ self.srv.bind(("127.0.0.1", 0))
+ self.srv.listen(16)
+ self.addCleanup(self.srv.close)
+ self.port = self.srv.getsockname()[1]
+ self.reg = fd.Registry(self.dir / "devices.json")
+ self.routes = []
+ self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"],
+ control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))),
+ explain=explain)
+ self.addCleanup(self.link.stop)
+
+ def hosts(self, mapping):
+ os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
+
+ def calls(self):
+ return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
+
+ def device(self, *hosts):
+ d = self.reg.add_device("frame-t", port=self.port, hosts=[])
+ for h in hosts:
+ self.reg.add_address(d["id"], h, kind="lan")
+ return d
+
+ def test_falls_through_to_the_address_that_is_really_the_headset(self):
+ # Tried in this order: a name that doesn't resolve, a different device, the headset.
+ d = self.device("nothing.invalid", "127.0.0.1", "localhost")
+ self.hosts({"127.0.0.1": "wrong", "localhost": "ok"})
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertEqual(s["via"]["host"], "localhost")
+ self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
+ rows = {p["host"]: p for p in s["probes"]}
+ self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
+ self.assertEqual(rows["127.0.0.1"]["state"], "sshfailed")
+ self.assertIn("different headset", rows["127.0.0.1"]["detail"])
+ # Every ssh command was pointed at the winner, with the host key pinned per device.
+ alias, opts = self.routes[-1]
+ self.assertEqual(alias, "frame-t")
+ self.assertIn(f"HostName=localhost", opts)
+ self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
+ self.assertIn(f"Port={self.port}", opts)
+ master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
+ self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet
+ # It learned: localhost works on this network.
+ learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
+ self.assertEqual(learned["localhost"]["networks"], ["n-test"])
+ self.assertEqual(learned["127.0.0.1"]["networks"], [])
+ self.assertTrue(self.link.alive())
+ self.link.close_master()
+ self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir()))
+
+ def test_stages_are_published_as_they_happen(self):
+ self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ steps, versions = [], []
+ real = self.link.stage
+
+ def stage(sid, state, detail=None):
+ real(sid, state, detail)
+ steps.append((sid, state))
+ versions.append(self.link.snapshot()["version"])
+ self.link.stage = stage
+ before = self.link.snapshot()["version"]
+ self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet
+ self.link.connect(["start"])
+ started = [sid for sid, state in steps if state == "active"]
+ self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"])
+ self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"])
+ self.assertEqual(versions, sorted(versions)) # every step is a new version for the page
+ self.assertEqual(self.link.wait(before, 1)["phase"], "connected")
+
+ def test_nothing_answers(self):
+ self.device("nothing.invalid", "also-nothing.invalid")
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "failed")
+ self.assertEqual(s["error"]["stage"], "find")
+ self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.")
+ self.assertGreater(s["retry_at"], time.time())
+ self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"])
+
+ def test_refused_key_stops_at_login(self):
+ self.device("localhost")
+ self.hosts({"localhost": "denied"})
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
+ self.assertIn("SSH key", s["error"]["message"])
+
+ def test_pinned_identity_is_checked_strictly(self):
+ d = self.device("localhost")
+ (self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
+ self.assertIn("StrictHostKeyChecking=yes", master)
+
+ def test_a_bare_alias_lets_ssh_config_decide(self):
+ self.link.override = "frame-bare"
+ self.hosts({"frame-bare": "ok"}) # the stand-in ssh has no config: the alias is the host
+ with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester")):
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertTrue(s["device"]["transient"])
+ self.assertEqual(self.routes[-1], ("frame-bare", [])) # no HostName override, ssh's own known_hosts
+
+ def test_test_now_checks_every_address_without_touching_the_connection(self):
+ d = self.device("127.0.0.1", "localhost", "nothing.invalid")
+ (self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n")
+ self.hosts({"127.0.0.1": "wrong", "localhost": "ok"})
+ self.link.test(d["id"])
+ rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
+ self.assertEqual(rows["localhost"]["ssh"], "ok")
+ self.assertEqual(rows["127.0.0.1"]["ssh"], "wrong")
+ self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
+ self.assertEqual(self.routes, [])
+ self.assertTrue(all("ControlPath=none" in c for c in self.calls()))
+
+ def test_devices_api_checks_everything(self):
+ d = self.device("localhost")
+ bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"},
+ {"action": "address-add", "id": d["id"], "host": "a\nHost *"},
+ {"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"},
+ {"action": "update", "id": d["id"], "user": "root; id"},
+ {"action": "update", "id": d["id"], "port": 0},
+ {"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5},
+ {"action": "setup", "alias": "-F/etc/passwd"},
+ {"action": "setup", "alias": "frame-9", "host": "$(id)"},
+ {"action": "use", "id": "nope"},
+ {"action": "explode"}]
+ for body in bad:
+ with self.assertRaises(fd.DeviceError, msg=body):
+ fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran"))
+ opened = []
+ out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"},
+ open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal")
+ self.assertEqual(opened, [("frame-9", "192.168.1.50")])
+ self.assertIn("frame-9", out["message"])
+ self.assertEqual(out["active"], d["id"])
+ self.assertEqual(fl.next_alias(self.link), "frame")
+
+
+@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
+class ServerConnection(unittest.TestCase):
+ """The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-"))
+ ssh_dir = cls.dir / "ssh"
+ ssh_dir.mkdir()
+ cls.srv = socket.socket() # the "headset's" port 22
+ cls.srv.bind(("127.0.0.1", 0))
+ cls.srv.listen(16)
+ (ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n"
+ f" Port {cls.srv.getsockname()[1]}\n"
+ " User steamos\nHost *\n# <<< steam-frame (frame) <<<\n")
+ env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir),
+ "FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"),
+ "FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok"}),
+ "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
+ env.pop("FRAME_ALIAS", None)
+ cls.log = tempfile.TemporaryFile()
+ cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
+ stdout=subprocess.PIPE, stderr=cls.log, text=True)
+ cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0])
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.proc.terminate()
+ cls.proc.wait(timeout=15)
+ cls.proc.stdout.close()
+ cls.log.close()
+ cls.srv.close()
+ shutil.rmtree(cls.dir, ignore_errors=True)
+
+ def request(self, method, path, body=None, key="1"):
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
+ conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
+ headers={"X-Frame-UI": key, "Content-Type": "application/json"})
+ r = conn.getresponse()
+ data = json.loads(r.read() or b"{}")
+ conn.close()
+ return r.status, data
+
+ def wait_connected(self):
+ for _ in range(100):
+ status, s = self.request("GET", "/api/connection")
+ if s.get("phase") in ("connected", "failed"):
+ return s
+ time.sleep(0.1)
+ self.fail(f"never connected: {s}")
+
+ def test_imports_the_headset_and_connects_through_its_port(self):
+ s = self.wait_connected()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertEqual(s["via"]["host"], "localhost")
+ self.assertEqual(s["device"]["alias"], "frame")
+ self.assertEqual(s["device"]["name"], "Steam Frame")
+ self.assertEqual(s["probes"][0]["host"], "localhost")
+ status, devices = self.request("GET", "/api/devices")
+ self.assertEqual(status, 200)
+ self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"])
+ self.assertEqual(devices["nextAlias"], "frame-2")
+
+ def test_events_stream_the_state(self):
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
+ conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"})
+ r = conn.getresponse()
+ self.assertEqual(r.status, 200)
+ self.assertEqual(r.getheader("Content-Type"), "text/event-stream")
+ line = r.fp.readline()
+ self.assertTrue(line.startswith(b"data: "), line)
+ self.assertIn("stages", json.loads(line[6:]))
+ conn.close()
+
+ def test_guards_and_validation(self):
+ self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403)
+ self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403)
+ self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400)
+ self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400)
+ self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_network.py b/tests/test_network.py
new file mode 100644
index 0000000..7a14419
--- /dev/null
+++ b/tests/test_network.py
@@ -0,0 +1,147 @@
+"""frame_network's parsers, with what macOS, Linux and Windows print.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import json
+import sys
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_network as fn # noqa: E402
+
+MAC_ROUTE = """ route to: default
+destination: default
+ mask: default
+ gateway: 192.168.1.1
+ interface: en0
+ flags:
+"""
+MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n"
+MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n"
+MAC_SUMMARY = """ {
+ BSSID :
+ ConnectionID : 1
+ InterfaceType : WiFi
+ LinkStatusActive : TRUE
+ NetworkID :
+ SSID :
+ Security : WPA2_PSK
+}"""
+MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : \n Security", "SSID : Home Net\n Security")
+MAC_SUMMARY_WIRED = " {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}"
+
+LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600
+default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100
+"""
+LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n"
+NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n"
+
+WIN_ROUTE = """===========================================================================
+Interface List
+ 12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz
+===========================================================================
+
+IPv4 Route Table
+===========================================================================
+Active Routes:
+Network Destination Netmask Gateway Interface Metric
+ 0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50
+ 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35
+===========================================================================
+Persistent Routes:
+ None
+"""
+WIN_ARP = """
+Interface: 192.168.1.50 --- 0xc
+ Internet Address Physical Address Type
+ 192.168.1.1 b4-fb-e4-b5-67-55 dynamic
+"""
+NETSH = """
+There is 1 interface on the system:
+
+ Name : Wi-Fi
+ Description : Intel(R) Wi-Fi 6 AX201 160MHz
+ State : connected
+ SSID : Office 5G
+ BSSID : 12:34:56:78:9a:bc
+ Network type : Infrastructure
+"""
+NETSH_OFF = NETSH.replace("State : connected", "State : disconnected")
+
+TAILSCALE = json.dumps({
+ "BackendState": "Running",
+ "CurrentTailnet": {"Name": "example.github"},
+ "Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]},
+ "Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True,
+ "TailscaleIPs": ["100.113.174.84", "fd7a:115c:a1e0::5928:ae55"]},
+ "nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False,
+ "TailscaleIPs": ["100.77.1.2"]}},
+})
+
+
+class Parsers(unittest.TestCase):
+ def test_macos(self):
+ self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0"))
+ self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None))
+ self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded
+ self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1"))
+ self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10"))
+ self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission
+ self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True))
+ self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False))
+
+ def test_linux(self):
+ self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric
+ self.assertEqual(fn.parse_route_linux(""), (None, None))
+ self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc")
+ self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1"))
+ self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
+ self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
+
+ def test_windows(self):
+ self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
+ self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
+ self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID
+ self.assertIsNone(fn.parse_netsh(NETSH_OFF))
+
+ def test_mac_addresses(self):
+ self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55")
+ for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"):
+ self.assertIsNone(fn.norm_mac(bad), bad)
+
+ def test_network_id_is_stable_and_needs_both_parts(self):
+ a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")
+ self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55"))
+ self.assertTrue(a.startswith("n-"))
+ self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router
+ self.assertIsNone(fn.network_id("192.168.1.1", None))
+ self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55"))
+
+ def test_tailscale(self):
+ ts = fn.parse_tailscale(TAILSCALE)
+ self.assertTrue(ts["up"])
+ self.assertEqual(ts["ip"], "100.101.102.103")
+ self.assertEqual(ts["name"], "laptop.tail1234.ts.net")
+ self.assertEqual(ts["tailnet"], "example.github")
+ frame = ts["peers"][0]
+ self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]),
+ ("frame", "frame.tail1234.ts.net", "linux", True))
+ self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"])
+ self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []})
+ self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []})
+
+ def test_address_kinds(self):
+ cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale",
+ "100.113.174.84": "tailscale", "fd7a:115c:a1e0::5928:ae55": "tailscale",
+ "192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan",
+ "frame.example.com": "manual", "8.8.8.8": "manual"}
+ for host, kind in cases.items():
+ self.assertEqual(fn.guess_kind(host), kind, host)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_server.py b/tests/test_server.py
index ec389bd..31167d3 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -5,6 +5,7 @@ request guards and input validation, which all run before any SSH call.
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import io
import json
@@ -33,7 +34,9 @@ class ServerGuards(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.port = free_port()
- env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
+ cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up
+ env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
+ "FRAME_CONTROL_SSH_DIR": cls.ssh_dir}
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
diff --git a/tests/test_steam.py b/tests/test_steam.py
index d29d975..615bf1e 100644
--- a/tests/test_steam.py
+++ b/tests/test_steam.py
@@ -2,6 +2,7 @@
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import subprocess
import sys
diff --git a/tests/test_webinstall.py b/tests/test_webinstall.py
index 1858297..e197b49 100644
--- a/tests/test_webinstall.py
+++ b/tests/test_webinstall.py
@@ -4,6 +4,7 @@ the localhost-testing rule allows.
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import hashlib
import json
import os
diff --git a/ui/frame_connect.py b/ui/frame_connect.py
index 9b75ded..6aefc4b 100644
--- a/ui/frame_connect.py
+++ b/ui/frame_connect.py
@@ -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:
diff --git a/ui/frame_devices.py b/ui/frame_devices.py
new file mode 100644
index 0000000..3b5a517
--- /dev/null
+++ b/ui/frame_devices.py
@@ -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": "",
+ "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": {"": {"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-` 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
diff --git a/ui/frame_host.py b/ui/frame_host.py
index a60b82c..a3842f3 100644
--- a/ui/frame_host.py
+++ b/ui/frame_host.py
@@ -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"
diff --git a/ui/frame_link.py b/ui/frame_link.py
new file mode 100644
index 0000000..79bc722
--- /dev/null
+++ b/ui/frame_link.py
@@ -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=` 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 .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"))}
diff --git a/ui/frame_network.py b/ui/frame_network.py
new file mode 100644
index 0000000..252be4f
--- /dev/null
+++ b/ui/frame_network.py
@@ -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 "" 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 ("", ""):
+ 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
diff --git a/ui/index.html b/ui/index.html
index 8ac1265..e5b977c 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -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 @@
Games2Android3Tools4
+ Devices5
- Connecting…
+
+
—