Merge pull request #45 from saphid/devices

Several headsets, several addresses each, a Devices tab, and live connection status
This commit is contained in:
Alex Southwell authored and GitHub committed 2026-09-29 12:53:28 +10:00
commit 7cc4abaffa
23 files changed
+5042 -117

No files matched your search

+80 -16
View File
@@ -159,10 +159,16 @@ async function startServer() {
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
// Resolves once it has exited (or after 20 s), so a replacement can take the server
// lock: server.py allows one per user.
function endServer(child) {
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
: new Promise((resolve) => child.once("exit", resolve));
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
}
function stopServer() {
@@ -183,27 +189,37 @@ function serverDied(why) {
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
async function restartServer() {
const old = server;
server = null;
url = null;
if (old) endServer(old);
await load();
// Restarts that overlap share one: two could each start a server, and the one
// that lost the lock would leave the app pointing at nothing.
let restarting = null;
function restartServer() {
if (!restarting) {
restarting = (async () => {
const old = server;
server = null;
url = null;
if (old) await endServer(old);
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
await load();
})().finally(() => { restarting = null; });
}
return restarting;
}
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
`;
// Restart Server can start a new load while an older one is still waiting for
// its server; only the newest load may touch the window.
let loadGen = 0;
let starting = null; // loads that overlap share one server start
async function load() {
const gen = ++loadGen;
try {
if (!url) await startServer();
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
@@ -254,6 +270,46 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// 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;
// SSH through the server, so it goes to the headset and address the app is using
// (with its own pinned identity), and refuses when there's none.
function openSsh() {
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
const body = JSON.stringify({ what: "terminal" });
const req = http.request(new URL("/api/open", url), {
method: "POST", timeout: 15000,
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
}, (res) => {
let data = "";
res.on("data", (c) => { data += c; });
res.on("end", () => {
if (res.statusCode === 200) return;
let why = `HTTP ${res.statusCode}`;
try { why = JSON.parse(data).error || why; } catch {}
dialog.showErrorBox("Couldn't open SSH", why);
});
});
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(body);
}
function showDevices() {
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
}
ipcMain.handle("comfort:notify", (e, message) => {
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
@@ -425,13 +481,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() {
@@ -446,8 +503,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: openSsh },
{ 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
View File
@@ -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.
// And it passes update state both ways: see app/updater.js.
@@ -12,6 +13,12 @@ contextBridge.exposeInMainWorld("frameApp", {
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
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)));
},
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
+180
View File
@@ -0,0 +1,180 @@
# 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 headset's own
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, 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. From that moment no command goes to the previous headset, even
if the new one never answers. It waits while an install is running, since an
install reads the SSH settings step by step.
- **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-hosts/<id> -o HashKnownHosts=no -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. Edits to `~/.ssh/config` take a lock file
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
write over a change someone else made since the app last read the file.
**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 keeps one known_hosts
file per headset instead, so saving or forgetting one headset's key never touches
another's: 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.
## One server at a time
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
data folder). Two would each connect, reconnect and edit the headsets on their own, and
one could move the other's install to a different headset. A second one, say
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
## 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
View File
@@ -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,
@@ -78,6 +81,11 @@ counts them while they run.
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
- **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
@@ -101,7 +109,9 @@ Frame for the keyboard and trackpad.
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
+16 -3
View File
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
fi
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
# running app and this script never write over each other's change.
write_config() {
local lockfd="" rc
zmodload zsh/system 2>/dev/null
touch "$CONFIG.frame-control.lock" 2>/dev/null
zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
write_config_locked; rc=$?
[[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
return $rc
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
write_config_locked() {
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
local tmp
local tmp new="$CONFIG.frame-control.$$"
tmp=$(mktemp) || return 1
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
# value it sees per option, so this block must precede any other "Host frame"
@@ -126,7 +138,8 @@ write_config() {
print -r -- "Host *"
print -r -- "$END_MARK"
cat "$tmp"
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
} > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
|| { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
rm -f "$tmp"
}
+4 -2
View File
@@ -15,11 +15,13 @@
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
# Frame Control passes the headset it has chosen: its address and pinned identity.
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
HERE=${0:A:h}
cmd=${1:-status}
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
# written the way Steam's settings page does (steamui module exporting the
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
# ("Interactive authentication required") but allows one from a user unit.
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
import json, os, subprocess, sys
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
from frame_steam import Page
+3 -1
View File
@@ -21,6 +21,8 @@
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
# Frame Control passes the headset it has chosen: its address and pinned identity.
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
id="" name=""
@@ -109,4 +111,4 @@ EOF
)
b64=$(print -rn -- "$remote" | base64)
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
+79
View File
@@ -0,0 +1,79 @@
#!/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), "denied" or
"slow" (hangs after connecting);
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.")
if what == "slow":
time.sleep(30)
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)
+23 -2
View File
@@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase):
with mock.patch.object(server.frame_host, 'MUX', True), \
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
# Per headset (its tag), and per process for a private server.
self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C')
self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C')
class ComputerState(unittest.TestCase):
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class ScriptsFollowTheHeadset(unittest.TestCase):
"""keep_awake and panel tools run scripts that ssh on their own: they must reach the
headset the server is routed to, not whatever `frame` means in ~/.ssh/config."""
@unittest.skipUnless(shutil.which('zsh'), 'needs zsh')
def test_scripts_get_the_routed_alias_and_options(self):
import shlex
fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui',
SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab'])
with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run:
agent.run_script(fake, 'keep-awake.sh', ['status'])
env = run.call_args.kwargs['env']
self.assertEqual(env['FRAME_ALIAS'], 'frame-2')
self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:])
# and the script turns that back into the same argv
out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'],
env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True)
self.assertEqual(out.stdout.splitlines(), fake.SSH[1:])
+400
View File
@@ -0,0 +1,400 @@
"""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::1234:5678", "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_first_import_keeps_using_frame(self):
# Set Up Connection puts each new block first; the app used `frame` before.
blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>")
head, first = blocks[0].split("# >>> steam-frame (frame) >>>")
second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<")
(self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n"
+ "# >>> steam-frame (frame) >>>" + first + tail)
self.reg.sync_from_config(seed=False)
self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"])
self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"])
@unittest.skipUnless(shutil.which("ssh"), "needs ssh")
def test_a_port_inherited_from_another_host_entry_is_kept(self):
(self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222"))
self.reg.sync_from_config(seed=False)
self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use
self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line
# Saving 22 must then say so in the block, or ssh would go on inheriting 2222.
self.assertTrue(fd.rewrite_block("frame", port=22))
self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22)
self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once
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_setup_changing_the_login_updates_the_headset(self):
self.reg.sync_from_config(seed=False)
(self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1))
self.assertTrue(self.reg.sync_from_config(seed=False))
d = self.reg.by_alias("frame")
self.assertEqual((d["user"], d["port"]), ("deck", 2200))
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 SharedFile(Base):
"""The desktop app and a standalone server can share devices.json."""
def test_one_server_never_saves_over_anothers_change(self):
self.reg.sync_from_config(seed=False)
other = fd.Registry(self.dir / "devices.json") # a second server, loaded now
d = self.reg.by_alias("frame")
self.reg.add_address(d["id"], "100.101.1.2", "tailscale")
other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy
hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]]
self.assertIn("100.101.1.2", hosts)
self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it
def test_another_servers_choice_of_headset_doesnt_move_this_one(self):
self.reg.sync_from_config(seed=False)
other = fd.Registry(self.dir / "devices.json")
mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"]
self.reg.set_active(mine)
other.set_active(theirs)
self.assertEqual(self.reg.active(), mine) # after a refresh
self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file
self.assertEqual(self.reg.active(), mine)
self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start
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: said explicitly
self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22)
self.assertIn(" 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_concurrent_edits_all_land(self):
import threading
def edit(alias, prefix):
for n in range(15):
fd.rewrite_block(alias, hostname=f"{prefix}.{n}")
threads = [threading.Thread(target=edit, args=("frame", "10.0.0")),
threading.Thread(target=edit, args=("frame-2", "10.0.1"))]
for t in threads:
t.start()
for t in threads:
t.join()
blocks = fd.parse_blocks((self.ssh / "config").read_text())
self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"])
self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left
def test_learning_skips_a_block_someone_changed(self):
# The connector learned an address, but Set Up Connection moved the block meanwhile.
self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "old.example", "user": None, "port": None}))
self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text())
self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22}))
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("d1").read_text(), f"frame-control-d1 {KEY}\n")
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
self.assertEqual(fd.known_hosts("d1").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"))
self.assertFalse(fd.forget_pin("d1"))
def test_each_headset_has_its_own_file(self):
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
fd.seed_pin("da", ["a.local"])
fd.seed_pin("db", ["b.local"])
fd.forget_pin("da")
self.assertTrue(fd.pinned("db")) # forgetting one can't touch another
self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db"))
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("d3").read_text())
def test_hashed_pins_are_found_and_forgotten(self):
target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n")
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4"))
self.assertFalse(fd.pinned("d4"))
def test_known_hosts_option_uses_the_override(self):
self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5"))
os.environ.pop("FRAME_CONTROL_SSH_DIR")
self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on
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
with self.assertRaises(fd.DeviceError):
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel")
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged
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")
with self.assertRaises(fd.DeviceError):
self.reg.update_device(b["id"], user="steam", port="bad")
self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing
self.reg.remove_device(b["id"])
self.assertEqual(self.reg.active(), a["id"])
self.assertFalse(self.reg.emptied())
self.reg.remove_device(a["id"])
self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all
self.reg.add_device("frame-4")
self.assertFalse(self.reg.emptied())
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()
class RoutingLongLivedSsh(unittest.TestCase):
"""The keyboard agent and the Mac view keep their own ssh open: switching headset
must end or retarget them, or input would go on reaching the old headset."""
def test_switching_headset_stops_input_and_retargets_the_mac_view(self):
import server
from unittest import mock
before = (server.FRAME, list(server.HOST_OPTS))
self.addCleanup(lambda: server.route(*before))
with mock.patch.object(server._input, "stop") as stop, \
mock.patch.object(server.macview, "retarget") as retarget:
server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address
stop.assert_not_called()
server.route("frame-2", ["-o", "HostName=192.0.2.2"])
stop.assert_called_once()
retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"])
+695
View File
@@ -0,0 +1,695 @@
"""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"])
# Closed now. Windows retries a refused connect for about 2 s before saying so.
self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused")
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 test_start_routes_to_the_saved_headset_before_serving(self):
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
b = self.reg.add_device("frame-2", hosts=["192.0.2.2"])
self.reg.set_active(b["id"])
with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies
self.link.start()
self.assertEqual(self.routes[0][0], "frame-2")
self.assertIn("HostName=192.0.2.2", self.routes[0][1])
self.assertNotEqual(a["id"], b["id"])
def test_headset_removed_elsewhere_mid_install_reaches_nothing(self):
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
self.reg.add_device("frame-2", hosts=["192.0.2.2"])
self.reg.set_active(a["id"])
other = fd.Registry(self.dir / "devices.json") # another Frame Control server
other.remove_device(a["id"])
self.link.work = lambda: 1
self.assertTrue(self.link.active_device().get("none"))
self.link.work = lambda: 0
self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on
def test_nothing_elsewhere_moves_a_running_install(self):
"""A bare alias in use, then another server sets up and picks a headset."""
self.link.override = None
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
started = self.routes[-1]
other = fd.Registry(self.dir / "devices.json")
other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"])
self.link.work = lambda: 1
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.close_master()
self.link.connect(["dropped"])
self.assertEqual(self.routes[-1][0], started[0])
self.assertTrue(self.link.deferred)
def listen6(self):
"""A "different device": the same port on IPv6 loopback."""
try:
six = socket.socket(socket.AF_INET6)
self.addCleanup(six.close)
six.bind(("::1", self.port))
six.listen(4)
except OSError:
self.skipTest("no IPv6 loopback")
def pin(self, device_id):
fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True)
fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n")
def hosts(self, mapping):
# An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1.
if "localhost" in mapping:
mapping = dict({"127.0.0.1": mapping["localhost"]}, **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.
self.listen6()
d = self.device("nothing.invalid", "::1", "127.0.0.1")
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["host"], "127.0.0.1")
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["::1"]["state"], "sshfailed")
self.assertIn("different headset", rows["::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("HostName=127.0.0.1", 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
# ssh takes an option's first value: accept-new must come before the commands' own "yes".
self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes"))
self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key
self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts
# It learned: 127.0.0.1 works on this network.
learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"])
self.assertEqual(learned["::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.pin(d["id"])
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_ssh_goes_to_the_ipv4_address_that_answered(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1"))
self.assertIn("HostName=127.0.0.1", self.routes[-1][1])
def test_no_headset_after_removing_them_all(self):
d = self.device("localhost")
self.reg.remove_device(d["id"])
self.link.connect(["switch"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None))
self.assertIn("No headset", s["error"]["message"])
self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"]))
def test_a_headset_without_addresses_reaches_nothing(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None)
self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry
self.link.connect(["switch"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["retry_at"]), ("failed", None))
self.assertIn("no addresses", s["error"]["message"])
def test_switching_back_to_the_frame_alias_the_server_started_with(self):
self.link.override = self.link.session_alias = "frame-bare"
other = self.device("localhost")
ids = [d["id"] for d in fl.devices_view(self.link)["devices"]]
self.assertEqual(ids, ["alias-frame-bare", other["id"]])
fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None)
self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there
fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None)
self.assertEqual(self.link.active_device()["alias"], "frame-bare")
self.assertEqual(self.routes[-1][0], "frame-bare")
def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self):
d = self.device("localhost")
self.link.override = self.link.session_alias = "frame-t"
fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None)
self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]])
def test_setup_changing_the_login_waits_for_installs(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
cfg = self.dir / "ssh" / "config"
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n"
f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n")
self.link.watch_config() # the block's login is recorded
routes = len(self.routes)
cfg.write_text(cfg.read_text().replace("User steamos", "User deck"))
running = [1]
self.link.work = lambda: running[0]
self.link.config_mtime = None
self.link.watch_config()
self.assertEqual(len(self.routes), routes) # an install is running: not yet
self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with
self.assertIn("User=steamos", self.routes[-1][1])
running[0] = 0
self.link.watch_config()
self.assertIn("User=deck", self.routes[-1][1])
def test_a_rename_leaves_the_login_in_the_config_alone(self):
d = self.device("localhost")
cfg = self.dir / "ssh" / "config"
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n"
"Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
self.assertIn("User deck", cfg.read_text())
out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None)
self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is
self.assertIn("left as it is", out["message"])
def test_a_late_failure_from_the_last_headset_is_ignored(self):
self.link.state["phase"] = "connected"
self.link.gen = 3
self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch
self.assertEqual(self.link.kicks, [])
self.link.lost("ssh: connect to host b port 22: Operation timed out", 3)
self.assertEqual(len(self.link.kicks), 1)
def test_a_jump_hosts_login_isnt_the_headsets(self):
opts = ["-o", "HostName=10.0.0.5"]
self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame"))
self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame"))
self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".',
["-o", "HostName=FRAME.LOCAL"], "frame"))
def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self):
said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".",
"channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"]
self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""}
for i, _ in fl.STAGES]
self.assertEqual(self.link.failed("login", said, False, "frame"), "next")
self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"),
"stop")
def test_a_reconnect_being_started_isnt_a_live_connection(self):
self.link.state["phase"] = "connected"
self.assertTrue(self.link.alive())
self.link.busy = True # the loop took a Retry off the queue and is about to reconnect
self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it
def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self):
self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}])
self.link.probe_update(0, 1, state="answered", ip="10.0.0.2")
self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"})
def test_a_bare_alias_lets_ssh_config_decide(self):
self.link.override = "frame-bare"
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertTrue(s["device"]["transient"])
# Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts).
alias, opts = self.routes[-1]
self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}",
"-o", "User=tester"]))
self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))])
def test_each_headset_has_its_own_shared_connection(self):
"""ssh's %C hashes only address, user and port: two headsets at one address
(one moved) must still never share a ControlMaster."""
a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222"))
pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b))
self.assertNotEqual(pa, pb)
self.assertIn("aaaa1111", pa)
long_alias = fl.Link.bare("x" * 64)
path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath="))
self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path
def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self):
self.link.override = "frame-bare"
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
pinned = self.routes[-1][1]
# ~/.ssh/config now sends the alias elsewhere, but an install is running.
self.link.work = lambda: 1
with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)):
self.link.close_master()
self.link.connect(["dropped"])
self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too
def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self):
d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly
self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"])
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset
def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self):
self.link.override = "frame-jump"
self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there
with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["why"], "through a jump host")
def test_changing_the_port_reroutes_even_if_the_attempt_fails(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
self.reg.update_device(d["id"], port=1) # nothing listens there
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "failed")
self.assertIn("Port=1", self.routes[-1][1])
def test_test_now_checks_every_address_without_touching_the_connection(self):
self.listen6()
d = self.device("::1", "127.0.0.1", "nothing.invalid")
self.pin(d["id"])
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
self.link.test(d["id"])
rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
self.assertEqual(rows["127.0.0.1"]["ssh"], "ok")
self.assertEqual(rows["::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() if "-G" not in c))
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
other = self.reg.add_device("frame-other", port=self.port)
self.reg.add_address(other["id"], "nothing.invalid")
self.link.use(other["id"])
self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt
self.assertEqual(self.link.snapshot()["phase"], "connecting")
self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "failed")
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-other")
self.assertIn("HostName=nothing.invalid", opts)
self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts)
def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
other = self.reg.add_device("frame-other", port=self.port)
self.reg.add_address(other["id"], "nothing.invalid")
pick = fl.Link.pick
def switch_then_pick(*args):
if not getattr(self, "switched", False):
self.switched = True
self.link.use(other["id"]) # the user switches while A is being found
return pick(*args)
with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)):
self.link.connect(["start"])
self.assertEqual(self.routes[-1][0], "frame-other")
self.assertEqual(self.link.snapshot()["phase"], "connecting")
self.assertFalse(self.link.alive())
self.assertIsNone(self.link.master)
def test_no_switching_while_something_is_installing(self):
d = self.device("localhost")
other = self.reg.add_device("frame-other")
for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]},
{"action": "update", "id": d["id"], "port": 2222},
{"action": "address-remove", "id": d["id"], "host": "localhost"},
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
{"action": "forget-identity", "id": d["id"]}):
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
# Renaming, or changing another headset, is fine.
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1)
fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1)
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
def test_no_reconnecting_under_a_running_install(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1)
fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs
def test_terminals_get_the_address_by_name(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
alias, opts = self.link.named_route()
self.assertEqual(alias, "frame-t")
self.assertIn("HostName=localhost", opts)
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
def test_renaming_during_an_install_is_fine(self):
d = self.device("localhost")
# The page sends the user and port along with the name, unchanged.
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos",
"port": str(self.port)}, None, busy=lambda: 1)
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
def test_a_rename_shows_at_once(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
self.assertEqual(self.link.snapshot()["device"]["name"], "Desk")
def test_stopping_mid_handshake_leaves_no_ssh_behind(self):
self.device("localhost")
self.hosts({"localhost": "slow"})
t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True)
t.start()
for _ in range(100):
if self.link.pending:
break
time.sleep(0.05)
proc = self.link.pending
self.assertIsNotNone(proc)
self.link.stop()
t.join(10)
self.assertFalse(t.is_alive())
self.assertIsNotNone(proc.poll())
self.assertIsNone(self.link.master)
def test_test_now_goes_through_a_jump_host(self):
d = self.device("10.99.99.99")
self.pin(d["id"])
self.hosts({"10.99.99.99": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
self.link.test(d["id"])
row = self.link.snapshot()["tests"][d["id"]]["rows"][0]
self.assertEqual(row["ssh"], "ok", row)
self.assertIn("jump host", row["detail"])
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"))
# Removing every headset leaves none in use, rather than falling back to the `frame` alias.
spare = self.reg.add_device("frame-spare")
fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None)
# The only headset, whose ssh alias would stay: not without removing that too.
(self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n"
"Host *\n# <<< steam-frame (frame-t) <<<\n")
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None)
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")
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
self.assertEqual(fl.next_alias(self.link), "frame-2")
@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", "127.0.0.1": "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_changes_meant_for_another_headset_are_refused(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
conn.request("POST", "/api/launch", body=b'{"appid": "620"}',
headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"})
r = conn.getresponse()
self.assertEqual(r.status, 409)
self.assertIn("switched headsets", json.loads(r.read())["error"])
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()
+38
View File
@@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests
import base64
import http.client
import json
import io
import os
import shutil
import socket
@@ -42,6 +43,43 @@ class Helpers(unittest.TestCase):
self.assertEqual(h, 1080)
self.assertAlmostEqual(w / h, 0.5, places=2)
def test_the_tunnel_follows_the_headset(self):
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
class Tunnel:
ended = False
def poll(self): return None
def terminate(self): Tunnel.ended = True
mv.tunnel, mv.remote_port = Tunnel(), 47999
mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it
self.assertFalse(Tunnel.ended)
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel
self.assertTrue(Tunnel.ended)
self.assertIsNone(mv.tunnel)
self.assertEqual(mv.frame, "frame-2")
def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self):
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
mv.port = 47000
ended = []
class Proc:
def poll(self): return None
def terminate(self): ended.append(self)
def wait(self): return 0
stderr = io.StringIO("")
def probe(port):
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now
return True
with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \
mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe):
self.assertFalse(mv._open_tunnel([], [47001]))
self.assertIsNone(mv.tunnel)
self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed
@unittest.skipUnless(shutil.which("bash"), "needs bash")
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
def test_launch_script_parses(self):
+147
View File
@@ -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.101.102.103", "fd7a:115c:a1e0::1234:5678"]},
"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.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "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()
+43 -1
View File
@@ -34,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)
@@ -238,6 +240,46 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.post("/api/nope", {})[0], 404)
class OneServer(unittest.TestCase):
"""Two servers for one user would each connect and edit headsets on their own."""
def start(self, env):
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close()))
return proc
def test_a_second_server_is_refused_until_the_first_exits(self):
data = tempfile.mkdtemp(prefix="frame-one-server-")
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
"FRAME_CONTROL_SERVER_WAIT": "1"}
first = self.start(env)
self.assertIn("Frame Control on", first.stdout.readline())
second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
capture_output=True, text=True, timeout=60)
self.assertEqual(second.returncode, 1)
self.assertIn("already running", second.stderr)
first.terminate()
first.wait(10)
self.assertIn("Frame Control on", self.start(env).stdout.readline())
def test_a_private_server_runs_alongside_but_cant_change_headsets(self):
"""The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs."""
data = tempfile.mkdtemp(prefix="frame-one-server-")
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
"FRAME_CONTROL_SERVER_WAIT": "1"}
self.assertIn("Frame Control on", self.start(env).stdout.readline())
private = self.start({**env, "FRAME_PRIVATE_SSH": "1"})
line = private.stdout.readline()
self.assertIn("Frame Control on", line)
port = int(line.split("http://127.0.0.1:")[1].split()[0])
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}),
headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"})
r = conn.getresponse()
self.assertEqual(r.status, 403, r.read())
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
+6 -1
View File
@@ -1,7 +1,9 @@
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
import hashlib
import os
from pathlib import Path
import secrets
import shlex
import shutil
import subprocess
import threading
@@ -129,7 +131,10 @@ def run_script(server, name, args):
script = server.HERE.parent / 'scripts' / name
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
raise ValueError(name + ' requires a computer with zsh and the matching script installed')
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60)
# The headset the server is routed to, not whatever `frame` means in ~/.ssh/config.
env = {**os.environ, 'FRAME_ALIAS': server.FRAME,
'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])}
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env)
if result.returncode:
raise ValueError(result.stderr.strip() or 'Script failed')
return {'message': result.stdout.strip()}
+48 -4
View File
@@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of
scripts/connect.sh (which the Mac app uses); same config block, so either can
re-run over the other. Idempotent.
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for
terminals that don't pass the environment on, like Windows' `start`)
"""
import base64
import json
@@ -283,10 +284,40 @@ def config_block(host, port=22, user=FRAME_USER):
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
class config_lock:
"""The lock Frame Control takes to edit ~/.ssh/config (frame_devices.file_lock), so a
running app and this setup never write over each other's change."""
def __enter__(self):
self.fh = open(SSH_DIR / "config.frame-control.lock", "a+")
for _ in range(300):
try:
if os.name == "nt":
import msvcrt
self.fh.seek(0)
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.lockf(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
return self
except OSError:
time.sleep(0.1)
return self # 30 s: go ahead rather than fail the setup
def __exit__(self, *exc):
self.fh.close() # closing releases the lock
return False
def write_config(host, port=22, user=FRAME_USER):
make_ssh_dir()
with config_lock():
_write_config(host, port, user)
def _write_config(host, port, user):
"""Replace our managed block and put it first: ssh uses the first value it sees per
option. The trailing "Host *" returns the rest of the file to global scope."""
make_ssh_dir()
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
kept, skip = [], False
for line in old.splitlines():
@@ -297,7 +328,7 @@ def write_config(host, port=22, user=FRAME_USER):
elif not skip:
kept.append(line)
block = config_block(host, port, user)
tmp = CONFIG.with_name("config.frame-control.tmp")
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
if os.name != "nt":
tmp.chmod(0o600)
@@ -367,9 +398,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:
+802
View File
@@ -0,0 +1,802 @@
"""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 the headset's own
(~/.ssh/frame-control-hosts/<id>), so a different device answering at a
remembered IP is caught.
Python stdlib only.
"""
import contextlib
import copy
import json
import os
import re
import secrets
import subprocess
import tempfile
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"
PIN_DIR = "frame-control-hosts"
def known_hosts(device_id):
"""The headset's own known_hosts file: one per headset, so saving or forgetting one
headset's key (by ssh or by the app) can never touch another's."""
return ssh_dir() / PIN_DIR / device_id
def known_hosts_opt(device_id):
"""How ssh is told about it. `~` 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."""
if os.environ.get("FRAME_CONTROL_SSH_DIR"):
return str(known_hosts(device_id))
return f"~/.ssh/{PIN_DIR}/{device_id}"
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, "port_set": False,
"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"], cur["port_set"] = int(value), True
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 ""
# One edit of ~/.ssh/config at a time: between this app's threads (_config_lock) and
# with Set Up Connection (frame_connect.py and scripts/connect.sh take the same lock
# file). _edit_config also notices any other program writing in between.
_config_lock = threading.Lock()
LOCK_NAME = "config.frame-control.lock"
@contextlib.contextmanager
def file_lock(path, timeout=30):
"""An exclusive lock on `path` (created if need be) shared with other processes:
POSIX record locks (what zsh's `zsystem flock` takes), or msvcrt on Windows."""
path.parent.mkdir(parents=True, exist_ok=True)
fh = open(path, "a+")
try:
deadline = time.monotonic() + timeout
while True:
try:
if frame_host.WINDOWS:
import msvcrt
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.lockf(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except OSError:
if time.monotonic() > deadline:
raise OSError(f"{path} stayed locked (is Set Up Connection running?)")
time.sleep(0.1)
yield
finally:
try:
if frame_host.WINDOWS:
import msvcrt
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.lockf(fh, fcntl.LOCK_UN)
except OSError:
pass
fh.close()
def _write_config(path, text, expected):
"""Swap the file in whole (as frame_connect.write_config does), keeping it private.
Returns False, writing nothing, if the file no longer holds `expected`."""
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
tmp = Path(tmp)
try:
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write(text)
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
if read_config(path) != expected:
return False
try:
os.replace(tmp, path)
return True
except PermissionError:
time.sleep(0.25)
raise OSError(f"{path} stayed locked by another program")
finally:
if tmp.exists():
tmp.unlink()
def _edit_config(path, change):
"""Apply change(lines) -> new lines or None to the file, retrying if another program
wrote it meanwhile. -> True if the file changed."""
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
for _ in range(5):
text = read_config(path)
new = change(text.splitlines())
if new is None:
return False
if _write_config(path, "\n".join(new) + "\n", text):
return True
raise OSError(f"{path} kept changing while Frame Control tried to update it")
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=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, or
if `expect` ({"hostname", "user", "port"}; None values match anything) no longer
describes the block, checked under the lock: someone else changed it meanwhile."""
def change(lines):
if expect:
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
# A port the block doesn't set is inherited from elsewhere in the file: not compared.
if not block or any(v is not None and block[k] != v and (k != "port" or block["port_set"])
for k, v in expect.items()):
return None
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
# Port 22 needs no line, unless the block would otherwise inherit another port
# from a later Host entry (which ssh would use).
force = bool(port) and block is not None and not block["port_set"] and \
effective_port(alias, config_path) != int(port)
return _rewritten(lines, alias, hostname, user, port, force)
config_path = Path(path or ssh_config())
return _edit_config(config_path, change)
def _rewritten(lines, alias, hostname, user, port, force_port=False):
begin, end = begin_mark(alias), end_mark(alias)
if begin not in lines or end not in lines:
return None
i, j = lines.index(begin), lines.index(end)
if j < i:
return None
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)
# An existing Port line is kept, even for 22: dropping it could let a later
# `Host *` Port apply to Terminal but not to the app.
out.append(f" {f[0]} {want[key]}")
else:
out.append(line)
if want["port"] and (want["port"] != "22" or force_port) 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:]
return None if new == lines else new
def remove_block(alias, path=None):
def change(lines):
begin, end = begin_mark(alias), end_mark(alias)
if begin not in lines or end not in lines or lines.index(end) < lines.index(begin):
return None
return lines[:lines.index(begin)] + lines[lines.index(end) + 1:]
return _edit_config(Path(path or ssh_config()), change)
def effective_port(alias, config):
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
try:
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return 22
m = re.search(r"^port (\d+)$", out, re.M)
port = int(m.group(1)) if m else 22
return port if 1 <= port <= 65535 else 22
# ---- pinned host keys -------------------------------------------------------------
def _keygen(*args):
try:
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
except (OSError, subprocess.TimeoutExpired):
return None
def pinned(device_id):
"""Whether a key is saved for the headset. Entries are plain text (ssh gets
HashKnownHosts=no), but ask ssh-keygen too in case one was hashed."""
target = known_hosts(device_id)
try:
lines = target.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return False
name = host_key_alias(device_id)
if any(line.split(None, 1)[0].split(",").count(name) for line in lines
if line.strip() and not line.startswith("#")):
return True
r = _keygen("-F", name, "-f", str(target))
return bool(r and r.returncode == 0 and r.stdout.strip())
def seed_pin(device_id, hosts, port=22, sources=None):
"""Copy the host keys ssh already trusts for one of `hosts` into the headset's file
under its alias, so moving to per-headset pinning asks nobody to trust anything again.
-> True if a key is pinned."""
if pinned(device_id):
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
r = _keygen("-F", wanted, "-f", str(src))
for line in (r.stdout if r else "").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 = known_hosts(device_id)
target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
os.replace(tmp, target) # whole file at once: ssh never sees half of it
return True
return False
def forget_pin(device_id):
"""Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
trusts whatever key the headset shows, as a first connection does."""
try:
known_hosts(device_id).unlink()
return True
except FileNotFoundError:
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._depth = 0 # nested _changing() calls
self._mtime = None # devices.json as last loaded or saved
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
self.load()
# -- storage --
def load(self):
with self.lock:
try:
self._mtime = self.path.stat().st_mtime_ns
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)]
# The headset in use is this server's own choice: another server picking a
# different one mustn't move commands (an install, say) under it. The file's
# choice is only where a server starts.
# Kept even if another server removed it, so the connector can see that
# (and not quietly move to another headset in the middle of an install).
mine = self.data.get("active")
if mine:
data["active"] = mine
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)
self._mtime = self.path.stat().st_mtime_ns
def _refresh(self):
"""Pick up what another Frame Control server saved (the app and a standalone
server can share devices.json)."""
with self.lock:
try:
if self.path.stat().st_mtime_ns != self._mtime:
self.load()
except OSError:
pass
@contextlib.contextmanager
def _changing(self):
"""Every change holds this registry's lock and a lock file shared with other
processes, and starts from what's on disk, so no server saves over another's
change. Nested calls (sync_from_config adding a device) share the outer one."""
with self.lock:
if self._depth:
self._depth += 1
try:
yield
finally:
self._depth -= 1
return
with file_lock(self.path.with_name(self.path.name + ".lock")):
self.load()
self._depth = 1
try:
yield
finally:
self._depth = 0
def snapshot(self):
self._refresh()
with self.lock:
return copy.deepcopy(self.data)
# -- lookups --
def devices(self):
self._refresh()
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):
self._refresh()
with self.lock:
return copy.deepcopy(self._find(device_id))
def by_alias(self, alias):
self._refresh()
with self.lock:
return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None)
def active(self):
self._refresh()
with self.lock:
return self.data.get("active")
def emptied(self):
self._refresh()
with self.lock:
return bool(self.data.get("emptied")) and not self.data["devices"]
def set_active(self, device_id):
with self._changing():
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._changing():
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)
self.data.pop("emptied", None)
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._changing():
d = self._find(device_id)
# Check everything first: a rejected edit changes nothing.
name = None if name is None else (check_text(name, "name") or d["alias"])
user = None if user is None else check_user(user)
port = None if port is None else check_port(port)
d.update({k: v for k, v in (("name", name), ("user", user), ("port", port)) if v is not None})
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._changing():
d = self._find(device_id)
self.data["devices"].remove(d)
self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or ""
if not self.data["devices"]:
self.data["emptied"] = True # removed on purpose: don't fall back to the `frame` alias
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._changing():
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._changing():
d = self._find(device_id)
a = self._addr(d, host)
# Check everything first: a rejected edit changes nothing.
moved = new_host is not None and new_host != host
if moved:
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")
kind = None if kind is None else check_kind(kind)
label = None if label is None else check_text(label, "label")
if moved:
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"] = kind
if label is not None:
a["label"] = label
self.save()
return copy.deepcopy(a)
def remove_address(self, device_id, host):
with self._changing():
d = self._find(device_id)
d["addresses"].remove(self._addr(d, host))
self.save()
def move_address(self, device_id, host, delta):
with self._changing():
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._changing():
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._changing():
if self.data.get("dismissed", {}).pop(alias, None) is not None:
self.save()
def set_config_host(self, device_id, host):
with self._changing():
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._changing():
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._changing():
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"
self._refresh()
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))
for b in blocks:
if not b["port_set"]:
# No Port in the block: another Host entry may give one (ssh uses the first).
b["port"] = effective_port(b["alias"], self.config or ssh_config())
changed = False
with self._changing():
first = not self.data["devices"] and not self.data.get("active")
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.get("config_login") != [user, b["port"]]:
# Set Up Connection (or an edit) changed who to log in as, or the port.
if d.get("config_login") is not None and [d["user"], d["port"]] != [user, b["port"]]:
d["user"], d["port"] = user, b["port"] if 1 <= b["port"] <= 65535 else d["port"]
d["config_login"] = [user, 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 first:
# First import: the headset the app used before is `frame`, even if Set Up
# Connection put another block above it.
frame = next((d for d in self.data["devices"] if d["alias"] == "frame"), None)
if frame and self.data.get("active") != frame["id"]:
self.data["active"] = frame["id"]
changed = True
if changed:
self.save()
return changed
+11 -5
View File
@@ -56,13 +56,19 @@ def cache_dir(*parts):
return base.joinpath(*parts)
def control_path(*, private=False):
def control_path(tag="x", *, private=None):
"""ssh ControlPath for the shared connection, or None where it isn't supported.
`tag` names the headset: ssh's %C hashes only the address, user and port, so two
headsets reached at the same address (one of them moved) would otherwise share a
connection, and one's commands would run on the other.
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
"""
# A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1.
if private is None:
private = os.environ.get("FRAME_PRIVATE_SSH") == "1"
suffix = f"-{os.getpid()}" if private else ""
return f"/tmp/frame-ui-{os.getuid()}{suffix}-%C" if MUX else None
return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None
def which(name, *extra):
@@ -258,9 +264,9 @@ def open_steam_link():
return "Steam Link isn't installed; opened its download page"
def open_rdp(alias):
"""Remote desktop to the Frame's xrdp (user steamos)."""
host = ssh_hostname(alias)
def open_rdp(alias, host=None):
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
host = host or ssh_hostname(alias)
if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
return "Opened Windows App"
+1232
View File
File diff suppressed because it is too large. Load diff
+37 -3
View File
@@ -120,6 +120,10 @@ class MacView:
self.tunnel_ssh = list(tunnel_ssh)
self.run = run
self.frame = frame
self.host_opts = [] # the headset in use and how to reach it (see retarget)
# Short, never held across ssh: retarget() and publishing a new tunnel check and
# change the headset together (self.lock is held while a tunnel is being opened).
self.route_lock = threading.Lock()
self.track = track or (lambda proc: None) # the server ends these on exit
self.lock = threading.Lock()
self.token = secrets.token_urlsafe(24)
@@ -239,13 +243,37 @@ class MacView:
last = self._last_tunnel_error
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
def retarget(self, alias, host_opts):
"""The server now reaches the headset as `alias` with `host_opts` (another address,
or another headset). Only the short route_lock, never self.lock: this runs while
the server routes, which a tunnel being opened may be waiting on."""
# host_opts is "-o", "Name=value" pairs; the tunnel keeps its own connection,
# not the shared master.
opts = [x for flag, value in zip(host_opts[::2], host_opts[1::2])
if not value.startswith("ControlPath=") for x in (flag, value)]
with self.route_lock:
moved = alias != self.frame
self.frame, self.host_opts = alias, opts
tunnel = self.tunnel
if moved and tunnel is not None:
# Another headset: its viewers can't be the old one's. The supervisor
# reopens a tunnel to the new one if anything is being shown.
self.tunnel, self.remote_port = None, None
if moved and tunnel is not None and tunnel.poll() is None:
tunnel.terminate()
def _open_tunnel(self, via, ports):
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
last = ""
for port in ports:
proc = subprocess.Popen([*self.tunnel_ssh, *via, "-o", "ExitOnForwardFailure=yes",
with self.route_lock:
target = (self.frame, self.host_opts) # retarget() may change these meanwhile
# `via` first: ssh keeps the first value of an option, so USB-C's HostName wins
# while the headset's pinned identity (in host_opts) still checks it.
proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1],
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame],
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, text=True)
# A taken port makes ssh exit once it's connected; a working
@@ -259,7 +287,11 @@ class MacView:
ok = True
break
if ok:
self.tunnel, self.remote_port = proc, port
with self.route_lock: # checked and published together, so a switch can't slip between
ok = target[0] == self.frame # else the app switched headset while this connected
if ok:
self.tunnel, self.remote_port = proc, port
if ok:
self.track(proc)
self._supervise()
return True
@@ -294,6 +326,8 @@ class MacView:
socket.create_connection((ip, 22), timeout=1).close()
except OSError:
return [] # not plugged into this Mac
if any(o.startswith("HostKeyAlias=") for o in self.host_opts):
return ["-o", f"HostName={ip}"] # checked against the headset's own pinned key
alias = self.frame
try:
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
+310
View File
@@ -0,0 +1,310 @@
"""Which network this computer is on, and whether Tailscale is up.
Frame Control remembers which of a headset's addresses worked on which network,
so it needs a stable name for "this network". The Wi-Fi name (SSID) is the
friendly one, but macOS 14+ hides it from apps without Location permission, and
wired networks have none. So every network is identified by a fingerprint of
its default gateway: the router's IP and MAC address, which stay the same for a
given home or office network. The user can give a fingerprint a name.
Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe
is a short command with a timeout, and each parser has fixtures in
tests/test_network.py.
"""
import hashlib
import ipaddress
import json
import os
import re
import socket
import subprocess
import time
import frame_host
TIMEOUT = 3
def run(argv, timeout=TIMEOUT):
"""A command's stdout, or "" if it's missing, fails or takes too long."""
try:
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout,
**({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {}))
except (OSError, subprocess.TimeoutExpired):
return ""
return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""
def valid_ip(text):
try:
ipaddress.ip_address(text)
return True
except ValueError:
return False
def norm_mac(text):
""""b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC."""
parts = re.split(r"[:-]", (text or "").strip())
if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts):
return None
mac = ":".join(p.lower().zfill(2) for p in parts)
return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac
# ---- default gateway -------------------------------------------------------
def parse_route_macos(text):
"""`route -n get default` -> (gateway, interface)."""
gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M)
iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M)
gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None
return gateway, iface.group(1) if iface else None
def parse_route_linux(text):
"""`ip -4 route show default` -> (gateway, interface) of the lowest-metric route."""
best = None
for line in text.splitlines():
m = re.search(r"^default via (\S+) dev (\S+)", line.strip())
if not m or not valid_ip(m.group(1)):
continue
metric = re.search(r"\bmetric (\d+)", line)
key = int(metric.group(1)) if metric else 0
if best is None or key < best[0]:
best = (key, m.group(1), m.group(2))
return (best[1], best[2]) if best else (None, None)
def parse_route_windows(text):
"""`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins."""
best = None
for line in text.splitlines():
f = line.split()
if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit():
if best is None or int(f[4]) < best[0]:
best = (int(f[4]), f[2], f[3])
return (best[1], best[2]) if best else (None, None)
# ---- the gateway's MAC address ----------------------------------------------
def parse_arp_macos(text, ip):
"""`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]")."""
m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text)
return norm_mac(m.group(1)) if m else None
def parse_neigh_linux(text, ip):
"""`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE")."""
for line in text.splitlines():
f = line.split()
if f and f[0] == ip and "lladdr" in f:
return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None
return None
def parse_arp_windows(text, ip):
"""`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic")."""
for line in text.splitlines():
f = line.split()
if len(f) >= 2 and f[0] == ip:
return norm_mac(f[1])
return None
# ---- Wi-Fi name --------------------------------------------------------------
def parse_summary_macos(text):
"""`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "<redacted>" without Location permission."""
kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
name = ssid.group(1) if ssid else None
if name in ("<redacted>", ""):
name = None
return name, (kind.group(1).lower() == "wifi") if kind else None
def parse_nmcli(text):
"""`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:)."""
for line in text.splitlines():
if line.startswith("yes:"):
return line[4:].replace("\\:", ":") or None
return None
def parse_netsh(text):
"""`netsh wlan show interfaces` -> the connected SSID (not the BSSID line)."""
state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
if not ssid or (state and state.group(1).lower() != "connected"):
return None
return ssid.group(1)
# ---- Tailscale -----------------------------------------------------------------
def tailscale_cli():
extra = []
if frame_host.MAC:
extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
elif frame_host.WINDOWS:
for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
if base:
extra.append(os.path.join(base, "Tailscale", "tailscale.exe"))
return frame_host.which("tailscale", *extra)
def parse_tailscale(text):
"""`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}.
Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}.
"""
try:
data = json.loads(text)
except ValueError:
return {"up": False, "peers": []}
if not isinstance(data, dict):
return {"up": False, "peers": []}
me = data.get("Self") or {}
tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None
out = {"up": data.get("BackendState") == "Running",
"ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None),
"name": (me.get("DNSName") or "").rstrip(".") or None,
"tailnet": tailnet, "peers": []}
for p in (data.get("Peer") or {}).values():
if not isinstance(p, dict):
continue
out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."),
"ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)],
"os": p.get("OS") or "", "online": bool(p.get("Online"))})
return out
def tailscale_status():
cli = tailscale_cli()
if not cli:
return {"up": False, "installed": False, "peers": []}
out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}")
out["installed"] = True
return out
TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10")
TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
def is_tailscale(host):
host = host.lower().rstrip(".")
if host.endswith(".ts.net"):
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6)
def guess_kind(host):
"""What sort of address a host is: mdns, tailscale, lan or manual."""
h = host.lower().rstrip(".")
if h.endswith(".local"):
return "mdns"
if is_tailscale(h):
return "tailscale"
try:
ip = ipaddress.ip_address(h.split("%")[0])
if ip.is_private or ip.is_link_local:
return "lan"
except ValueError:
pass
return "manual"
# ---- putting it together ----------------------------------------------------------
def network_id(gateway, mac):
"""A short, stable id for a network: its gateway's IP and MAC. None until both are known."""
if not gateway or not mac:
return None
return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10]
def local_ip(towards="192.0.2.1"):
"""This computer's address on the default route (UDP connect sends nothing)."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect((towards, 9))
return s.getsockname()[0]
except OSError:
return None
def gateway():
"""(gateway IP, interface) of the default route."""
if frame_host.MAC:
return parse_route_macos(run(["route", "-n", "get", "default"]))
if frame_host.WINDOWS:
return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"]))
return parse_route_linux(run(["ip", "-4", "route", "show", "default"]))
def gateway_mac(ip):
if frame_host.MAC:
return parse_arp_macos(run(["arp", "-n", ip]), ip)
if frame_host.WINDOWS:
return parse_arp_windows(run(["arp", "-a", ip]), ip)
return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip)
def poke(ip):
"""Make the system look up the gateway's MAC (an ARP entry can expire)."""
try:
with socket.create_connection((ip, 53), timeout=0.3):
pass
except OSError:
pass
def wifi(interface):
"""(ssid or None, is_wifi or None) for the default route's interface."""
if frame_host.MAC:
if interface:
ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface]))
if ssid or is_wifi is False:
return ssid, is_wifi
m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface]))
return (m.group(1).strip() if m else None), is_wifi
return None, None
if frame_host.WINDOWS:
ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"]))
return ssid, True if ssid else None
if frame_host.which("nmcli"):
ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]))
else:
ssid = run(["iwgetid", "-r"]).strip() or None
return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None)
def fingerprint():
"""The cheap part, polled every few seconds: (gateway, interface, gateway MAC)."""
gw, iface = gateway()
mac = None
if gw:
mac = gateway_mac(gw)
if not mac:
poke(gw)
mac = gateway_mac(gw)
return gw, iface, mac
def current_network(fp=None, with_tailscale=True):
"""Everything the connection status shows about this computer's network."""
gw, iface, mac = fp or fingerprint()
ssid, is_wifi = wifi(iface) if gw else (None, None)
net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface,
"ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()}
if with_tailscale:
ts = tailscale_status()
net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")}
return net
+637 -18
View File
@@ -86,6 +86,58 @@
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.dlg::backdrop { background: rgba(0,0,0,.55); }
.dlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
.dlg h3, [data-page=devices] h3 { margin: 16px 0 6px; font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--muted); font-weight: 700; }
.dlg label, .dev-form label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
.dlg label input, .dev-form label input { margin-top: 5px; }
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; font-size: 13px; }
.facts dt { color: var(--muted); } .facts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
.stages { list-style: none; margin: 0; padding: 0; }
.stages li { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 8px; padding: 6px 0; border-top: 1px solid rgba(255,255,255,.05); }
.stages li:first-child { border-top: 0; }
.stages .ic { width: 16px; height: 16px; border-radius: 50%; margin-top: 2px; display: grid; place-items: center; font-size: 11px; font-weight: 700; }
.stages .done .ic { background: rgba(89,191,64,.2); color: var(--green-hi); }
.stages .failed .ic { background: rgba(217,65,38,.25); color: #ff8a73; }
.stages .pending .ic { border: 1.5px solid var(--dim); }
.stages .active .ic { border: 2px solid rgba(255,255,255,.15); border-top-color: var(--blue); animation: spin .8s linear infinite; }
.stages .lb { color: var(--bright); font-size: 13.5px; } .stages .pending .lb { color: var(--muted); }
.stages .dt { grid-column: 2 / 4; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; }
.stages .failed .dt { color: #ff8a73; }
.stages .tm { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
.probes { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.probes td { padding: 5px 8px 5px 0; border-top: 1px solid rgba(255,255,255,.05); vertical-align: top; }
.probes td:first-child { color: var(--bright); min-width: 170px; overflow-wrap: anywhere; }
.res-answered, .res-ok { color: var(--green-hi); } .res-refused, .res-sshfailed, .res-wrong, .res-denied { color: #ff8a73; }
.res-timeout, .res-unresolved, .res-unreachable, .res-unpinned { color: var(--warn); }
.res-trying, .res-resolving, .res-waiting, .res-checking { color: var(--link); }
.dev-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.2fr); gap: 22px; align-items: start; }
@media (max-width: 1000px) { .dev-grid { grid-template-columns: 1fr; } }
.dev-item { cursor: pointer; border-radius: 3px; padding: 10px !important; margin: 0 -10px; }
.dev-item:hover { background: rgba(255,255,255,.04); }
.dev-item.sel { background: rgba(26,159,255,.12); }
.dev-form { display: grid; grid-template-columns: 2fr 1.3fr 1fr .8fr; gap: 0 10px; align-items: end; }
.dev-form input[readonly] { color: var(--muted); }
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
.addr .s { white-space: normal; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
.found { margin-top: 10px; }
/* ---- drop anywhere ---- */
#media select { min-width: 0; max-width: 100%; flex: 1; }
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
@@ -591,9 +643,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="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
@@ -624,6 +678,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>
@@ -1079,6 +1134,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;
@@ -1209,6 +1283,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>
@@ -1285,9 +1398,18 @@ async function saveToDevice(items) {
}
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
async function api(path, body) {
// Bumped when the app moves to another headset: answers to requests made before
// then are about the old one, so they're dropped rather than shown.
let devGen = 0;
// Answers that don't depend on the headset (this computer's data, the catalogue, jobs).
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
async function api(path, body, device = window.connDevice) {
const gen = devGen;
// Changes name the headset the page was showing, so the server refuses one meant
// for a headset it has since switched away from.
const dev = device ? { "X-Frame-Device": device } : {};
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY}, body: JSON.stringify(body) };
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY, ...dev}, body: JSON.stringify(body) };
let r;
try { r = await fetch(path, opts); }
catch {
@@ -1295,6 +1417,11 @@ async function api(path, body) {
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
}
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
if (gen !== devGen && !HEADSET_FREE.test(path)) {
const err = new Error("Switched headsets");
err.offline = err.stale = true; // failed() leaves the panel alone: the new headset's answer fills it
throw err;
}
if (!r.ok) {
const err = new Error(data.error || `HTTP ${r.status}`);
err.offline = !!data.offline;
@@ -1306,6 +1433,7 @@ async function api(path, body) {
// What a panel shows when its data couldn't be read: a quiet placeholder while
// the Frame is unreachable (the banner explains), the real error otherwise.
function failed(el, e) {
if (e.stale) return; // about the previous headset: leave the panel to the new one
el.innerHTML = e.offline ? `<div class="wait">Waiting for the Frame</div>` : `<div class="sub">${esc(e.message)}</div>`;
}
async function act(label, fn, btn) {
@@ -1322,6 +1450,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>`;
}
@@ -1337,6 +1466,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();
@@ -2409,8 +2539,9 @@ function setVolume(pct, muted) {
$("vol").oninput = () => {
setVolume(+$("vol").value);
clearTimeout(volTimer);
const dev = window.connDevice; // the headset whose slider this was, even 250 ms later
volTimer = setTimeout(async () => {
try { await api("/api/volume", { level: $("vol").value / 100 }); } catch (e) { toast(e.message, true); }
try { await api("/api/volume", { level: $("vol").value / 100 }, dev); } catch (e) { toast(e.message, true); }
volTimer = null;
}, 250);
};
@@ -2426,9 +2557,10 @@ $("clipSend").onclick = () => {
// In the app, Electron reads the clipboard; in a browser, the server does.
async function sendComputerClipboard() {
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
const device = window.connDevice; // the headset it was sent to, not one switched to while reading
const text = await window.frameApp.readClipboard();
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
return api("/api/clipboard", { text });
return api("/api/clipboard", { text }, device);
}
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
@@ -2461,21 +2593,28 @@ window.addEventListener("drop", e => {
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
sendFiles([...e.dataTransfer.files], dirs);
});
function upload(file, mode) {
function upload(file, mode, dev = window.connDevice) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
if (dev) xhr.setRequestHeader("X-Frame-Device", dev);
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
xhr.setRequestHeader("X-Mode", mode);
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
const bar = $("prog").firstElementChild;
$("prog").style.display = "block"; bar.style.width = "0";
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
const gen = devGen;
xhr.onload = () => {
$("prog").style.display = "none";
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk);
if (gen !== devGen) { // the app switched headsets meanwhile: nothing to offer about this one here
const err = new Error(data.error || "Switched headsets");
err.stale = true;
return xhr.status < 300 ? resolve(data) : reject(err);
}
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk, dev);
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
};
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
@@ -2519,7 +2658,7 @@ $("mediaStop").onclick = () => act("Stop media", async () => {
}, $("mediaStop"));
let apkLookup = 0;
async function checkApkAlternatives(apk) {
async function checkApkAlternatives(apk, dev = window.connDevice) {
const lookup = ++apkLookup;
$("apkAltReason").textContent = apk.blocker;
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
@@ -2530,12 +2669,12 @@ async function checkApkAlternatives(apk) {
if (apk.version_code != null) query.set("code", apk.version_code);
try {
const result = await api(`/api/apk-versions?${query}`);
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result);
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result, dev);
} catch (e) {
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
}
}
function showApkAlternatives(reason, result) {
function showApkAlternatives(reason, result, dev = window.connDevice) {
$("apkAltReason").textContent = reason;
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
$("apkAltErrors").textContent = result.errors.join(" · ");
@@ -2550,9 +2689,10 @@ function showApkAlternatives(reason, result) {
const v = result.versions[+b.dataset.version];
if (installing.has(result.package)) return;
b.disabled = true; b.textContent = "Installing…";
// For the headset the APK was checked against: refused (409) if the app has switched since.
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
action: "install", package: result.package, url: v.url
}));
}, dev));
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
await loadAndroid();
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
@@ -2579,7 +2719,12 @@ $("aboutClose").onclick = () => $("aboutDlg").close();
const TITLE_EXT = /\.(zip|exe)$/i;
async function sendFiles(files, dirs = new Set()) {
const dev = window.connDevice; // the whole batch is for the headset it was dropped on
for (const [i, f] of files.entries()) {
if (window.connDevice !== dev) {
toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true);
break;
}
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
if (dirs.has(i)) {
if (path) await sideload(f, path, true);
@@ -2589,7 +2734,7 @@ async function sendFiles(files, dirs = new Set()) {
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push", dev));
if (apk && res && res.app) await offerTest(res.app);
}
$("fileInput").value = "";
@@ -2614,20 +2759,24 @@ function candLabel(c) {
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
// One at a time, so a second drop doesn't take over the dialog of the first.
let sideloadQueue = Promise.resolve();
function sideload(...args) {
const run = sideloadQueue.then(() => sideloadOne(...args));
function sideload(file, path, isDir = false) {
const dev = window.connDevice; // for the headset it was dropped on, even if it waits its turn
const run = sideloadQueue.then(() => {
if (window.connDevice !== dev) return toast(`Switched headsets: didn't add ${file.name}`, true);
return sideloadOne(file, path, isDir, dev);
});
sideloadQueue = run.catch(() => {});
return run;
}
async function sideloadOne(file, path, isDir = false) {
async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
const canPush = !isDir && file.size > 0;
log(`Reading ${file.name}…`);
let r;
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title"); }
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title", dev); }
catch (e) {
log(`${file.name}: ${e.message}`, "e");
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
return;
}
// Its own fresh list, so the dialog can say whether this replaces an installed title.
@@ -2636,7 +2785,7 @@ async function sideloadOne(file, path, isDir = false) {
const choice = await confirmTitle(r.plan, canPush, installed);
if (choice === "push") {
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
return;
}
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
@@ -4031,6 +4180,476 @@ 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;
window.connDevice = devId || null;
if (link.device !== null && devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
state = null;
online = null;
devGen++; // answers still on their way are about the other headset
refreshing = null; // and the next status check must be a new one
if (live) toggleLive(false); // the other headset's video or captures
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
viewGen++; // a capture still on its way is the other headset's too
lastImg = null; lastShot = null;
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
$("saveBtn").disabled = true;
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
// before anyone clicks one, until the new headset's arrive.
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
if ($(id)) $(id).innerHTML = `<div class="wait">Waiting for ${esc(s.device.name)}</div>`;
});
disp.list = []; disp.port = null;
// The lists behind those panels too, so filters can't bring the old ones back.
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
androidApps = []; shots.list = [];
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
// Confirmations still open were checked against the other headset.
["titleDlg", "apkAltDlg", "pwDlg", "repDlg"].forEach(id => { if ($(id) && $(id).open) $(id).close(); });
if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close();
$("dispSel").innerHTML = "<option>Loading…</option>"; $("dispSel").disabled = true; $("dispCtl").hidden = true;
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();
const netId = s.network ? s.network.id || s.network.gateway || "" : null;
if (s.devices_rev !== link.rev || devId !== link.device || (netId !== null && netId !== link.net)) {
link.rev = s.devices_rev; link.net = netId; // also when only the network changed: the Devices tab shows it
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");
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
async function loadDevices() {
if (!link.live) return;
const seq = ++devicesSeq;
let data;
try { data = await api("/api/devices"); } catch (e) { if (seq === devicesSeq) failed($("devList"), e); return; }
if (seq !== devicesSeq) return;
dv.data = data;
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;
const res = await act(`Switch to ${d.name}`, () => api("/api/devices", { action: "use", id }));
if (!res) { // refused (an install is running): show the headset still in use
const current = dv.list.find(x => x.active);
if (current) $("devSel").value = current.id;
}
}
async function devAction(label, body, btn) {
const res = await act(label, () => api("/api/devices", body), btn);
if (res) loadDevices(); // in order with every other load, so a late answer can't undo a newer one
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; }
if (dv.sel === d.id) renderFound(d); // another headset may be showing by now
}
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)`;
// The only headset: its entry has to go too, or the app would go on using that alias.
$("rmDevConfig").checked = dv.list.filter(x => !x.transient).length === 1; $("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>
<script src="/artwork-settings.js"></script>
</body>
</html>
+226 -53
View File
@@ -3,7 +3,8 @@
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
up by scripts/connect.sh or ui/frame_connect.py.
up by scripts/connect.sh or ui/frame_connect.py, or another headset picked on the
Devices tab: frame_link.py finds it at one of its addresses (frame_devices.py).
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
Env: FRAME_ALIAS (default frame)
@@ -14,6 +15,7 @@ Env: FRAME_ALIAS (default frame)
"""
import argparse
import base64
import contextlib
import hashlib
import http.client
import json
@@ -45,9 +47,11 @@ import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
import frame_steamgriddb
import frame_comfort # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
import frame_media # noqa: E402
import frame_panels # noqa: E402
@@ -74,18 +78,92 @@ DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
# What the Frame's KDE Connect calls this device (keyboard and trackpad).
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
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(private=os.environ.get("FRAME_PRIVATE_SSH") == "1")
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
# A private server (the MCP adapter starts one per session) keeps its own SSH masters, and
# uses the headsets without editing them.
PRIVATE = os.environ.get("FRAME_PRIVATE_SSH") == "1"
CONTROL = None if LOCAL else frame_host.control_path(private=PRIVATE)
# The ControlPath itself is per headset: the connector puts it in HOST_OPTS.
MUX = ["ssh", "-o", "BatchMode=yes"]
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:]
_route_lock = threading.Lock()
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
with _route_lock:
moved = alias != FRAME
if moved:
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
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:]
# Long-lived ssh processes started on the old route (outside the lock: they take their own).
mv = globals().get("macview")
if mv:
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
agent = globals().get("_input")
if moved and agent:
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
LINK = None # the connector (frame_link.Link); None on the Frame itself
# Installs and other changes in progress. Switching headsets waits for them: they
# read the ssh settings step by step, so a switch could send the rest (or a failed
# install's clean-up) to the other headset.
_work_lock = threading.Lock()
_work = [0]
@contextlib.contextmanager
def working(meant=None):
"""Counts as running work. `meant`: the headset the page made this change for; if the
app has switched away from it, refuse (checked together with counting, so a switch
can't slip in between)."""
with _work_lock:
if LINK and meant and meant != LINK.active_device()["id"]:
raise Failure("Frame Control switched headsets; try again on this one", 409)
_work[0] += 1
try:
yield
finally:
with _work_lock:
_work[0] -= 1
def busy_thread(fn, *args):
"""A thread that counts as work from before it starts until it ends."""
with _work_lock:
_work[0] += 1
def run():
try:
fn(*args)
finally:
with _work_lock:
_work[0] -= 1
return threading.Thread(target=run, daemon=True)
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
@@ -228,7 +306,7 @@ def start_job(label, work, progress=False):
with _jobs_lock:
_jobs[job].update(fields, done=True, time=time.time())
threading.Thread(target=run, daemon=True).start()
busy_thread(run).start()
return {"message": f"{label}…", "job": job}
@@ -241,42 +319,14 @@ 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):
route_gen = LINK.gen if LINK else None # which headset this command is for
try:
ensure_master()
# Never let ssh inherit our stdin: under the app it's the pipe held open for
@@ -288,6 +338,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, route_gen) # 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
@@ -350,6 +402,12 @@ print(r.stdout, end='')
def headset_view():
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
# Counts as work: the copy and clean-up must reach the headset that took the capture.
with working():
return _headset_view()
def _headset_view():
# `timeout`: VR_Init can block if SteamVR is restarting.
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
@@ -1111,19 +1169,25 @@ def open_thing(body):
if what in ("reboot", "poweroff", "suspend"):
return power(what, body.get("password"))
raise Failure("open that from the app", 400)
# The headset and address in use, as every other command gets them (one snapshot).
with _route_lock:
alias, opts = LINK.named_route() if LINK else (FRAME, list(HOST_OPTS))
host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None)
if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"):
raise Failure("No headset address to use: add one on the Devices tab", 400)
try:
if what == "terminal":
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
return {"message": f"Opened an SSH session in {terminal(['ssh', *opts, alias])}"}
if what in ("reboot", "poweroff", "suspend"):
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
where = terminal(["ssh", "-t", *opts, alias, "sudo", "systemctl", what])
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
if what == "steamlink":
return {"message": frame_host.open_steam_link()}
if what == "rdp":
return {"message": frame_host.open_rdp(FRAME)}
return {"message": frame_host.open_rdp(alias, host)}
if what == "sftp":
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
return {"message": f"Opened an SFTP session in {terminal(['sftp', *opts, alias])}"}
if what == "shots":
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
frame_host.open_path(SHOTS_DIR)
@@ -1257,7 +1321,8 @@ def stage_title(path, temp_dir=None, name=None):
raise
token = secrets.token_hex(12)
with _titles_lock:
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time(),
"device": LINK.active_device()["id"] if LINK else None}
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
"token": token, "plan": frame_titles.public(plan)}
@@ -1302,13 +1367,15 @@ def titles(body):
if action == "discard":
_drop_staged(entry)
return {"message": "Discarded"}
if LINK and entry.get("device") != LINK.active_device()["id"]:
_drop_staged(entry) # it was checked against the other headset's titles
raise Failure("Frame Control switched headsets since this was read; drop the file again", 409)
with _titles_lock:
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
"message": None, "title": None, "time": time.time()}
ensure_master()
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
threading.Thread(target=_run_title_install, daemon=True,
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
return {"message": f"Installing {entry['plan']['source']}", "job": token}
if action not in ("launch", "remove", "refresh-art"):
raise Failure("unknown action", 400)
@@ -1421,7 +1488,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)
@@ -1696,7 +1763,7 @@ def webinstall_start(body):
_web_jobs.clear()
_web_jobs[pid] = job
# Started under the lock, so shutdown never sees a thread it can't join.
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
worker = busy_thread(_webinstall_run, plan, job)
_web_workers.add(worker)
worker.start()
return {"job": pid}
@@ -2084,7 +2151,55 @@ POST = {
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action}
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
"/api/devices": lambda body: devices_post(body)}
# ---- headsets and the connection (frame_devices.py, frame_link.py) ----------
def open_setup(alias, host=None):
"""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)
if PRIVATE:
raise Failure("Headsets are managed in the Frame Control app", 403)
try:
# Under the work lock: nothing can start on the old headset while it switches.
with _work_lock:
return frame_link.devices_action(LINK, body, open_setup, lambda: _work[0])
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 ------------------------------------------------------------------
@@ -2205,6 +2320,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.startswith("/source-image/"):
from apk_sources import _images
try:
@@ -2298,10 +2419,12 @@ class Handler(BaseHTTPRequestHandler):
if not self.local_request():
return
path = urlparse(self.path).path
meant = self.headers.get("X-Frame-Device")
body = None
try:
if path == "/api/upload":
self.send_json(self.upload())
with working(meant):
self.send_json(self.upload())
return
handler = POST.get(path)
if not handler:
@@ -2313,7 +2436,9 @@ class Handler(BaseHTTPRequestHandler):
body = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(body, dict):
raise Failure("request body must be a JSON object", 400)
self.send_json(handler(body))
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
result = handler(body)
self.send_json(result)
except Failure as e:
if e.status >= 500:
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
@@ -2327,6 +2452,30 @@ class Handler(BaseHTTPRequestHandler):
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", 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
@@ -2466,6 +2615,24 @@ class LoopbackServer(ThreadingHTTPServer):
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
_ONE_SERVER = None
def one_server():
"""Only one Frame Control server per user: two would each connect, reconnect and
edit the headsets on their own, and could move each other's installs to another
headset. Held until this process exits. (FRAME_CONTROL_DATA_DIR gives a second,
separate one, as the tests do. A private server, the MCP adapter's, runs alongside:
it can't add, remove or switch headsets.)"""
lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=float(os.environ.get("FRAME_CONTROL_SERVER_WAIT") or 20)) # while the app restarts it
try:
lock.__enter__()
except OSError:
sys.exit("Frame Control is already running on this computer (the app, or a server started "
"from a terminal). Quit it, then try again.")
return lock
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
@@ -2477,6 +2644,14 @@ def main():
sweep_tmp()
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
frame_telemetry.start()
global LINK, _ONE_SERVER
if not LOCAL:
if not PRIVATE: # a private server only uses the headsets (see one_server)
_ONE_SERVER = one_server()
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.work_lock, LINK.work = _work_lock, lambda: _work[0]
LINK.start()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
@@ -2498,10 +2673,8 @@ def main():
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes
# 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()