mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Merge pull request #45 from saphid/devices
Several headsets, several addresses each, a Devices tab, and live connection status
This commit is contained in:
23 files changed
+5042
-117
No files matched your search
+80
-16
@@ -159,10 +159,16 @@ async function startServer() {
|
|||||||
|
|
||||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||||
// way on Windows); SIGTERM does the same elsewhere.
|
// 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) {
|
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 {}
|
try { child.stdin.end(); } catch {}
|
||||||
if (!IS_WIN) child.kill("SIGTERM");
|
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() {
|
function stopServer() {
|
||||||
@@ -183,27 +189,37 @@ function serverDied(why) {
|
|||||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartServer() {
|
// Restarts that overlap share one: two could each start a server, and the one
|
||||||
const old = server;
|
// that lost the lock would leave the app pointing at nothing.
|
||||||
server = null;
|
let restarting = null;
|
||||||
url = null;
|
function restartServer() {
|
||||||
if (old) endServer(old);
|
if (!restarting) {
|
||||||
await load();
|
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.
|
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||||
const CHROME_CSS = IS_MAC && `
|
const CHROME_CSS = IS_MAC && `
|
||||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
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
|
// 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.
|
// its server; only the newest load may touch the window.
|
||||||
let loadGen = 0;
|
let loadGen = 0;
|
||||||
|
let starting = null; // loads that overlap share one server start
|
||||||
async function load() {
|
async function load() {
|
||||||
const gen = ++loadGen;
|
const gen = ++loadGen;
|
||||||
try {
|
try {
|
||||||
if (!url) await startServer();
|
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
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:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
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) => {
|
ipcMain.handle("comfort:notify", (e, message) => {
|
||||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||||
@@ -425,13 +481,14 @@ async function runInTerminal(argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setUpConnection() {
|
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
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")]);
|
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMenu() {
|
function buildMenu() {
|
||||||
@@ -446,8 +503,15 @@ function buildMenu() {
|
|||||||
{
|
{
|
||||||
label: "Frame",
|
label: "Frame",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Set Up Connection…", click: setUpConnection },
|
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
{ 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" },
|
{ type: "separator" },
|
||||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||||
|
|||||||
+8
-1
@@ -2,7 +2,8 @@
|
|||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// 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
|
// 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).
|
// 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
|
// 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.
|
// 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.
|
// 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),
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
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.
|
// 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),
|
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||||
|
|||||||
+180
@@ -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
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
|||||||
|
|
||||||
## Features
|
## 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,
|
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||||
plain words and the app retries every few seconds, filling everything in once it
|
connection pill in the header always shows which headset, which network this
|
||||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
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.
|
counts them while they run.
|
||||||
|
|
||||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
- **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).
|
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||||
Remmina).
|
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
|
- **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
|
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
|
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
|
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
|
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
|
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.
|
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
|
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
|
its own and the app is a little slower. What differs between the three
|
||||||
|
|||||||
+16
-3
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
|
|||||||
fi
|
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() {
|
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
|
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
||||||
local tmp
|
local tmp new="$CONFIG.frame-control.$$"
|
||||||
tmp=$(mktemp) || return 1
|
tmp=$(mktemp) || return 1
|
||||||
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
# 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"
|
# 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 -- "Host *"
|
||||||
print -r -- "$END_MARK"
|
print -r -- "$END_MARK"
|
||||||
cat "$tmp"
|
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"
|
rm -f "$tmp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
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}
|
HERE=${0:A:h}
|
||||||
cmd=${1:-status}
|
cmd=${1:-status}
|
||||||
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
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"
|
'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
|
# 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
|
# written the way Steam's settings page does (steamui module exporting the
|
||||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||||
# ("Interactive authentication required") but allows one from a user unit.
|
# ("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
|
import json, os, subprocess, sys
|
||||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||||
from frame_steam import Page
|
from frame_steam import Page
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
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"
|
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
||||||
id="" name=""
|
id="" name=""
|
||||||
|
|
||||||
@@ -109,4 +111,4 @@ EOF
|
|||||||
)
|
)
|
||||||
b64=$(print -rn -- "$remote" | base64)
|
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}}"
|
||||||
Executable
+79
@@ -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
@@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase):
|
|||||||
with mock.patch.object(server.frame_host, 'MUX', True), \
|
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, 'getuid', return_value=501, create=True), \
|
||||||
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
||||||
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
|
# Per headset (its tag), and per process for a private server.
|
||||||
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
|
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):
|
class ComputerState(unittest.TestCase):
|
||||||
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.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:])
|
||||||
@@ -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"])
|
||||||
@@ -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()
|
||||||
@@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests
|
|||||||
import base64
|
import base64
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
@@ -42,6 +43,43 @@ class Helpers(unittest.TestCase):
|
|||||||
self.assertEqual(h, 1080)
|
self.assertEqual(h, 1080)
|
||||||
self.assertAlmostEqual(w / h, 0.5, places=2)
|
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.skipUnless(shutil.which("bash"), "needs bash")
|
||||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||||
def test_launch_script_parses(self):
|
def test_launch_script_parses(self):
|
||||||
|
|||||||
@@ -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
@@ -34,7 +34,9 @@ class ServerGuards(unittest.TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
cls.port = free_port()
|
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.log = tempfile.TemporaryFile()
|
||||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
|
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
|
||||||
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
|
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
|
||||||
@@ -238,6 +240,46 @@ class ServerGuards(unittest.TestCase):
|
|||||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
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):
|
class ArtworkSettings(unittest.TestCase):
|
||||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,9 @@
|
|||||||
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
|
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -129,7 +131,10 @@ def run_script(server, name, args):
|
|||||||
script = server.HERE.parent / 'scripts' / name
|
script = server.HERE.parent / 'scripts' / name
|
||||||
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
|
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')
|
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:
|
if result.returncode:
|
||||||
raise ValueError(result.stderr.strip() or 'Script failed')
|
raise ValueError(result.stderr.strip() or 'Script failed')
|
||||||
return {'message': result.stdout.strip()}
|
return {'message': result.stdout.strip()}
|
||||||
|
|||||||
+48
-4
@@ -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
|
scripts/connect.sh (which the Mac app uses); same config block, so either can
|
||||||
re-run over the other. Idempotent.
|
re-run over the other. Idempotent.
|
||||||
|
|
||||||
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
|
Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
|
||||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
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 base64
|
||||||
import json
|
import json
|
||||||
@@ -283,10 +284,40 @@ def config_block(host, port=22, user=FRAME_USER):
|
|||||||
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
|
" 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):
|
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
|
"""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."""
|
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 ""
|
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
||||||
kept, skip = [], False
|
kept, skip = [], False
|
||||||
for line in old.splitlines():
|
for line in old.splitlines():
|
||||||
@@ -297,7 +328,7 @@ def write_config(host, port=22, user=FRAME_USER):
|
|||||||
elif not skip:
|
elif not skip:
|
||||||
kept.append(line)
|
kept.append(line)
|
||||||
block = config_block(host, port, user)
|
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")
|
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
tmp.chmod(0o600)
|
tmp.chmod(0o600)
|
||||||
@@ -367,9 +398,22 @@ def pair_with_devkit(host, port, user):
|
|||||||
return chosen[0], "paired, but key login still fails"
|
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):
|
def main(argv):
|
||||||
if argv and argv[0] in ("-h", "--help"):
|
if argv and argv[0] in ("-h", "--help"):
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
|
if len(argv) >= 2 and argv[0] == "--alias":
|
||||||
|
use_alias(argv[1])
|
||||||
|
argv = argv[2:]
|
||||||
say("==> Looking for the Steam Frame")
|
say("==> Looking for the Steam Frame")
|
||||||
found = pick_host(argv[0] if argv else None)
|
found = pick_host(argv[0] if argv else None)
|
||||||
while not found:
|
while not found:
|
||||||
|
|||||||
@@ -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
@@ -56,13 +56,19 @@ def cache_dir(*parts):
|
|||||||
return base.joinpath(*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.
|
"""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.
|
/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 ""
|
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):
|
def which(name, *extra):
|
||||||
@@ -258,9 +264,9 @@ def open_steam_link():
|
|||||||
return "Steam Link isn't installed; opened its download page"
|
return "Steam Link isn't installed; opened its download page"
|
||||||
|
|
||||||
|
|
||||||
def open_rdp(alias):
|
def open_rdp(alias, host=None):
|
||||||
"""Remote desktop to the Frame's xrdp (user steamos)."""
|
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||||
host = ssh_hostname(alias)
|
host = host or ssh_hostname(alias)
|
||||||
if MAC:
|
if MAC:
|
||||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||||
return "Opened Windows App"
|
return "Opened Windows App"
|
||||||
|
|||||||
+1232
File diff suppressed because it is too large.
Load diff
+37
-3
@@ -120,6 +120,10 @@ class MacView:
|
|||||||
self.tunnel_ssh = list(tunnel_ssh)
|
self.tunnel_ssh = list(tunnel_ssh)
|
||||||
self.run = run
|
self.run = run
|
||||||
self.frame = frame
|
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.track = track or (lambda proc: None) # the server ends these on exit
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.token = secrets.token_urlsafe(24)
|
self.token = secrets.token_urlsafe(24)
|
||||||
@@ -239,13 +243,37 @@ class MacView:
|
|||||||
last = self._last_tunnel_error
|
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'}")
|
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):
|
def _open_tunnel(self, via, ports):
|
||||||
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
|
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
|
||||||
last = ""
|
last = ""
|
||||||
for port in ports:
|
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",
|
"-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,
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE, text=True)
|
stderr=subprocess.PIPE, text=True)
|
||||||
# A taken port makes ssh exit once it's connected; a working
|
# A taken port makes ssh exit once it's connected; a working
|
||||||
@@ -259,7 +287,11 @@ class MacView:
|
|||||||
ok = True
|
ok = True
|
||||||
break
|
break
|
||||||
if ok:
|
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.track(proc)
|
||||||
self._supervise()
|
self._supervise()
|
||||||
return True
|
return True
|
||||||
@@ -294,6 +326,8 @@ class MacView:
|
|||||||
socket.create_connection((ip, 22), timeout=1).close()
|
socket.create_connection((ip, 22), timeout=1).close()
|
||||||
except OSError:
|
except OSError:
|
||||||
return [] # not plugged into this Mac
|
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
|
alias = self.frame
|
||||||
try:
|
try:
|
||||||
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
|
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
|
||||||
|
|||||||
@@ -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
@@ -86,6 +86,58 @@
|
|||||||
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
.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; }
|
.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 ---- */
|
/* ---- drop anywhere ---- */
|
||||||
#media select { min-width: 0; max-width: 100%; flex: 1; }
|
#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;
|
.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="#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="#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="#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>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<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>
|
<span class="chip" id="battChip" title="Battery">—</span>
|
||||||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
<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>
|
<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>
|
<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="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>
|
<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="small" id="offSetup" hidden>Set Up Connection…</button>
|
||||||
<button class="action small" id="offRetry">Retry now</button>
|
<button class="action small" id="offRetry">Retry now</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1079,6 +1134,25 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
<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;
|
<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>
|
<button type="submit" class="action small" id="pwGo">Restart</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -1285,9 +1398,18 @@ async function saveToDevice(items) {
|
|||||||
}
|
}
|
||||||
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
|
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} } : {
|
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;
|
let r;
|
||||||
try { r = await fetch(path, opts); }
|
try { r = await fetch(path, opts); }
|
||||||
catch {
|
catch {
|
||||||
@@ -1295,6 +1417,11 @@ async function api(path, body) {
|
|||||||
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
||||||
}
|
}
|
||||||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
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) {
|
if (!r.ok) {
|
||||||
const err = new Error(data.error || `HTTP ${r.status}`);
|
const err = new Error(data.error || `HTTP ${r.status}`);
|
||||||
err.offline = !!data.offline;
|
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
|
// 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.
|
// the Frame is unreachable (the banner explains), the real error otherwise.
|
||||||
function failed(el, e) {
|
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>`;
|
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) {
|
async function act(label, fn, btn) {
|
||||||
@@ -1322,6 +1450,7 @@ async function act(label, fn, btn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setConn(ok, text) {
|
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>`;
|
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,6 +1466,7 @@ function setOnline(ok, why) {
|
|||||||
setConn(false, "Offline");
|
setConn(false, "Offline");
|
||||||
$("battChip").hidden = true;
|
$("battChip").hidden = true;
|
||||||
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
||||||
|
if (window.connBanner) window.connBanner();
|
||||||
} else if (was === false) {
|
} else if (was === false) {
|
||||||
log("Connected to the Frame again", "ok");
|
log("Connected to the Frame again", "ok");
|
||||||
reloadAll();
|
reloadAll();
|
||||||
@@ -2409,8 +2539,9 @@ function setVolume(pct, muted) {
|
|||||||
$("vol").oninput = () => {
|
$("vol").oninput = () => {
|
||||||
setVolume(+$("vol").value);
|
setVolume(+$("vol").value);
|
||||||
clearTimeout(volTimer);
|
clearTimeout(volTimer);
|
||||||
|
const dev = window.connDevice; // the headset whose slider this was, even 250 ms later
|
||||||
volTimer = setTimeout(async () => {
|
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;
|
volTimer = null;
|
||||||
}, 250);
|
}, 250);
|
||||||
};
|
};
|
||||||
@@ -2426,9 +2557,10 @@ $("clipSend").onclick = () => {
|
|||||||
// In the app, Electron reads the clipboard; in a browser, the server does.
|
// In the app, Electron reads the clipboard; in a browser, the server does.
|
||||||
async function sendComputerClipboard() {
|
async function sendComputerClipboard() {
|
||||||
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
|
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();
|
const text = await window.frameApp.readClipboard();
|
||||||
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
|
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"));
|
$("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));
|
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||||||
sendFiles([...e.dataTransfer.files], dirs);
|
sendFiles([...e.dataTransfer.files], dirs);
|
||||||
});
|
});
|
||||||
function upload(file, mode) {
|
function upload(file, mode, dev = window.connDevice) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", "/api/upload");
|
xhr.open("POST", "/api/upload");
|
||||||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
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-Filename", encodeURIComponent(file.name));
|
||||||
xhr.setRequestHeader("X-Mode", mode);
|
xhr.setRequestHeader("X-Mode", mode);
|
||||||
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
||||||
const bar = $("prog").firstElementChild;
|
const bar = $("prog").firstElementChild;
|
||||||
$("prog").style.display = "block"; bar.style.width = "0";
|
$("prog").style.display = "block"; bar.style.width = "0";
|
||||||
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
||||||
|
const gen = devGen;
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
$("prog").style.display = "none";
|
$("prog").style.display = "none";
|
||||||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
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.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||||||
};
|
};
|
||||||
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
||||||
@@ -2519,7 +2658,7 @@ $("mediaStop").onclick = () => act("Stop media", async () => {
|
|||||||
}, $("mediaStop"));
|
}, $("mediaStop"));
|
||||||
|
|
||||||
let apkLookup = 0;
|
let apkLookup = 0;
|
||||||
async function checkApkAlternatives(apk) {
|
async function checkApkAlternatives(apk, dev = window.connDevice) {
|
||||||
const lookup = ++apkLookup;
|
const lookup = ++apkLookup;
|
||||||
$("apkAltReason").textContent = apk.blocker;
|
$("apkAltReason").textContent = apk.blocker;
|
||||||
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
|
$("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);
|
if (apk.version_code != null) query.set("code", apk.version_code);
|
||||||
try {
|
try {
|
||||||
const result = await api(`/api/apk-versions?${query}`);
|
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) {
|
} catch (e) {
|
||||||
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
|
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showApkAlternatives(reason, result) {
|
function showApkAlternatives(reason, result, dev = window.connDevice) {
|
||||||
$("apkAltReason").textContent = reason;
|
$("apkAltReason").textContent = reason;
|
||||||
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
|
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
|
||||||
$("apkAltErrors").textContent = result.errors.join(" · ");
|
$("apkAltErrors").textContent = result.errors.join(" · ");
|
||||||
@@ -2550,9 +2689,10 @@ function showApkAlternatives(reason, result) {
|
|||||||
const v = result.versions[+b.dataset.version];
|
const v = result.versions[+b.dataset.version];
|
||||||
if (installing.has(result.package)) return;
|
if (installing.has(result.package)) return;
|
||||||
b.disabled = true; b.textContent = "Installing…";
|
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", {
|
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
|
||||||
action: "install", package: result.package, url: v.url
|
action: "install", package: result.package, url: v.url
|
||||||
}));
|
}, dev));
|
||||||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||||||
await loadAndroid();
|
await loadAndroid();
|
||||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
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;
|
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||||
async function sendFiles(files, dirs = new Set()) {
|
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()) {
|
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) : "";
|
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
|
||||||
if (dirs.has(i)) {
|
if (dirs.has(i)) {
|
||||||
if (path) await sideload(f, path, true);
|
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 (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||||||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
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);
|
if (apk && res && res.app) await offerTest(res.app);
|
||||||
}
|
}
|
||||||
$("fileInput").value = "";
|
$("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.
|
// 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.
|
// One at a time, so a second drop doesn't take over the dialog of the first.
|
||||||
let sideloadQueue = Promise.resolve();
|
let sideloadQueue = Promise.resolve();
|
||||||
function sideload(...args) {
|
function sideload(file, path, isDir = false) {
|
||||||
const run = sideloadQueue.then(() => sideloadOne(...args));
|
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(() => {});
|
sideloadQueue = run.catch(() => {});
|
||||||
return run;
|
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;
|
const canPush = !isDir && file.size > 0;
|
||||||
log(`Reading ${file.name}…`);
|
log(`Reading ${file.name}…`);
|
||||||
let r;
|
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) {
|
catch (e) {
|
||||||
log(`${file.name}: ${e.message}`, "e");
|
log(`${file.name}: ${e.message}`, "e");
|
||||||
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Its own fresh list, so the dialog can say whether this replaces an installed title.
|
// 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);
|
const choice = await confirmTitle(r.plan, canPush, installed);
|
||||||
if (choice === "push") {
|
if (choice === "push") {
|
||||||
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); 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
|
refresh().then(loadShots); // after status, so app names resolve
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||||
</script>
|
</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>
|
<script src="/artwork-settings.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+226
-53
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
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
|
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)
|
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||||
Env: FRAME_ALIAS (default frame)
|
Env: FRAME_ALIAS (default frame)
|
||||||
@@ -14,6 +15,7 @@ Env: FRAME_ALIAS (default frame)
|
|||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
@@ -45,9 +47,11 @@ import frame_android # noqa: E402
|
|||||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||||
import frame_apk_versions # noqa: E402
|
import frame_apk_versions # noqa: E402
|
||||||
import frame_catalog # noqa: E402
|
import frame_catalog # noqa: E402
|
||||||
|
import frame_devices # noqa: E402
|
||||||
import frame_steamgriddb
|
import frame_steamgriddb
|
||||||
import frame_comfort # noqa: E402
|
import frame_comfort # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
|
import frame_link # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
import frame_media # noqa: E402
|
import frame_media # noqa: E402
|
||||||
import frame_panels # 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).
|
# What the Frame's KDE Connect calls this device (keyboard and trackpad).
|
||||||
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
|
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
|
||||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
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):
|
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}")
|
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
|
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||||
# supports it (not on Windows: there every command connects on its own).
|
# 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")
|
# A private server (the MCP adapter starts one per session) keeps its own SSH masters, and
|
||||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
# 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.
|
# 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.
|
# Android helpers share the multiplexed connection when it's up.
|
||||||
frame_android.SSH_OPTS = SSH[1:]
|
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}$")
|
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,}$")
|
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
|
||||||
MAX_UPLOAD = 8 * 1024**3
|
MAX_UPLOAD = 8 * 1024**3
|
||||||
@@ -228,7 +306,7 @@ def start_job(label, work, progress=False):
|
|||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
_jobs[job].update(fields, done=True, time=time.time())
|
_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}
|
return {"message": f"{label}…", "job": job}
|
||||||
|
|
||||||
|
|
||||||
@@ -241,42 +319,14 @@ def job_status(query):
|
|||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
_master_lock = threading.Lock()
|
|
||||||
_master = None
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_master():
|
def ensure_master():
|
||||||
"""Start the shared SSH connection if it isn't up (one attempt at a time).
|
"""Make sure the connection to the headset is up, or being tried (frame_link.Link.ensure)."""
|
||||||
|
if LINK:
|
||||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
LINK.ensure()
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||||
|
route_gen = LINK.gen if LINK else None # which headset this command is for
|
||||||
try:
|
try:
|
||||||
ensure_master()
|
ensure_master()
|
||||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
# 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}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
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 = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||||
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
||||||
raise failure
|
raise failure
|
||||||
@@ -350,6 +402,12 @@ print(r.stdout, end='')
|
|||||||
|
|
||||||
def headset_view():
|
def headset_view():
|
||||||
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
"""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.
|
# `timeout`: VR_Init can block if SteamVR is restarting.
|
||||||
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
|
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
|
# 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"):
|
if what in ("reboot", "poweroff", "suspend"):
|
||||||
return power(what, body.get("password"))
|
return power(what, body.get("password"))
|
||||||
raise Failure("open that from the app", 400)
|
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:
|
try:
|
||||||
if what == "terminal":
|
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"):
|
if what in ("reboot", "poweroff", "suspend"):
|
||||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
# 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}"}
|
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||||
if what == "steamlink":
|
if what == "steamlink":
|
||||||
return {"message": frame_host.open_steam_link()}
|
return {"message": frame_host.open_steam_link()}
|
||||||
if what == "rdp":
|
if what == "rdp":
|
||||||
return {"message": frame_host.open_rdp(FRAME)}
|
return {"message": frame_host.open_rdp(alias, host)}
|
||||||
if what == "sftp":
|
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":
|
if what == "shots":
|
||||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
frame_host.open_path(SHOTS_DIR)
|
frame_host.open_path(SHOTS_DIR)
|
||||||
@@ -1257,7 +1321,8 @@ def stage_title(path, temp_dir=None, name=None):
|
|||||||
raise
|
raise
|
||||||
token = secrets.token_hex(12)
|
token = secrets.token_hex(12)
|
||||||
with _titles_lock:
|
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']}",
|
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
|
||||||
"token": token, "plan": frame_titles.public(plan)}
|
"token": token, "plan": frame_titles.public(plan)}
|
||||||
|
|
||||||
@@ -1302,13 +1367,15 @@ def titles(body):
|
|||||||
if action == "discard":
|
if action == "discard":
|
||||||
_drop_staged(entry)
|
_drop_staged(entry)
|
||||||
return {"message": "Discarded"}
|
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:
|
with _titles_lock:
|
||||||
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
|
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
|
||||||
"message": None, "title": None, "time": time.time()}
|
"message": None, "title": None, "time": time.time()}
|
||||||
ensure_master()
|
ensure_master()
|
||||||
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
|
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
|
||||||
threading.Thread(target=_run_title_install, daemon=True,
|
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
|
||||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
|
||||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||||
if action not in ("launch", "remove", "refresh-art"):
|
if action not in ("launch", "remove", "refresh-art"):
|
||||||
raise Failure("unknown action", 400)
|
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}")]
|
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.
|
# Its own connection (ControlPath=none), so killing it drops the forwards.
|
||||||
self.proc = _proc = subprocess.Popen(
|
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],
|
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
_live_tunnels.add(_proc)
|
_live_tunnels.add(_proc)
|
||||||
@@ -1696,7 +1763,7 @@ def webinstall_start(body):
|
|||||||
_web_jobs.clear()
|
_web_jobs.clear()
|
||||||
_web_jobs[pid] = job
|
_web_jobs[pid] = job
|
||||||
# Started under the lock, so shutdown never sees a thread it can't join.
|
# 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)
|
_web_workers.add(worker)
|
||||||
worker.start()
|
worker.start()
|
||||||
return {"job": pid}
|
return {"job": pid}
|
||||||
@@ -2084,7 +2151,55 @@ POST = {
|
|||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
"/api/webinstall/cancel": webinstall_cancel,
|
"/api/webinstall/cancel": webinstall_cancel,
|
||||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
"/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 ------------------------------------------------------------------
|
# ---- HTTP ------------------------------------------------------------------
|
||||||
@@ -2205,6 +2320,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
"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/"):
|
elif path.startswith("/source-image/"):
|
||||||
from apk_sources import _images
|
from apk_sources import _images
|
||||||
try:
|
try:
|
||||||
@@ -2298,10 +2419,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not self.local_request():
|
if not self.local_request():
|
||||||
return
|
return
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
|
meant = self.headers.get("X-Frame-Device")
|
||||||
body = None
|
body = None
|
||||||
try:
|
try:
|
||||||
if path == "/api/upload":
|
if path == "/api/upload":
|
||||||
self.send_json(self.upload())
|
with working(meant):
|
||||||
|
self.send_json(self.upload())
|
||||||
return
|
return
|
||||||
handler = POST.get(path)
|
handler = POST.get(path)
|
||||||
if not handler:
|
if not handler:
|
||||||
@@ -2313,7 +2436,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
body = json.loads(self.rfile.read(length) or b"{}")
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
raise Failure("request body must be a JSON object", 400)
|
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:
|
except Failure as e:
|
||||||
if e.status >= 500:
|
if e.status >= 500:
|
||||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
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)
|
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
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):
|
def stream_video(self, query):
|
||||||
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
||||||
global _stream_proc
|
global _stream_proc
|
||||||
@@ -2466,6 +2615,24 @@ class LoopbackServer(ThreadingHTTPServer):
|
|||||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||||
@@ -2477,6 +2644,14 @@ def main():
|
|||||||
sweep_tmp()
|
sweep_tmp()
|
||||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||||
frame_telemetry.start()
|
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:
|
if not frame_host.WINDOWS:
|
||||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||||
if args.exit_on_eof:
|
if args.exit_on_eof:
|
||||||
@@ -2498,10 +2673,8 @@ def main():
|
|||||||
webinstall_shutdown()
|
webinstall_shutdown()
|
||||||
macview.shutdown() # close the headset's viewers before the agent goes
|
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.
|
# The master was started with -N, so it stays up until told to exit.
|
||||||
if CONTROL:
|
if LINK:
|
||||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
LINK.stop()
|
||||||
if _master and _master.poll() is None:
|
|
||||||
_master.terminate()
|
|
||||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
|||||||
Reference in new issue
Block a user