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