mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1863c92101 | ||
|
|
c0ae75d56b | ||
|
|
12aaec7888 | ||
|
|
32fbbfe475 | ||
|
|
fa6d4fd81b | ||
|
|
7cc4abaffa | ||
|
|
02b9413f78 | ||
|
|
1a08258e3d | ||
|
|
a84d6b912b | ||
|
|
1cd56740a6 | ||
|
|
b685a601f7 | ||
|
|
31de17aab8 | ||
|
|
ffef4d897a | ||
|
|
094c12c6d8 | ||
|
|
c46bf68dd0 | ||
|
|
dc4a64a9c3 | ||
|
|
08fbe730c6 | ||
|
|
1cf353f419 | ||
|
|
c0183ca8b2 | ||
|
|
bf8a478063 | ||
|
|
215bc357ef | ||
|
|
7d6ff7f919 | ||
|
|
1343900aa1 | ||
|
|
d2a5db7caf | ||
|
|
a08ba6f65b | ||
|
|
53c51e1888 | ||
|
|
72e4ccf080 | ||
|
|
a9740ad6f2 | ||
|
|
cb6f294299 | ||
|
|
b87be6866b | ||
|
|
747dbcf72f | ||
|
|
409e328880 | ||
|
|
c5a614d989 | ||
|
|
0f33b4f094 | ||
|
|
49378b2c80 | ||
|
|
22863f2f87 | ||
|
|
b779376f5b | ||
|
|
8bd8d63546 | ||
|
|
42b51afc5a | ||
|
|
6597a90059 | ||
|
|
c3e3f2629c | ||
|
|
90fd2684ba | ||
|
|
fa05a4b310 | ||
|
|
93ebd34f2c | ||
|
|
34e91988b1 | ||
|
|
5874fe33f6 | ||
|
|
98ef6944c9 | ||
|
|
2e9bc8624b | ||
|
|
c69ea6266f | ||
|
|
43e19d17f6 | ||
|
|
175c39a2b0 | ||
|
|
51ef5d8283 | ||
|
|
ba33d2ff40 | ||
|
|
41ac28248a | ||
|
|
9dd57cde4e | ||
|
|
d383a26746 | ||
|
|
318bc3b84f | ||
|
|
cb182dbce5 | ||
|
|
18334b5989 | ||
|
|
13eb65603b | ||
|
|
a954fc83c9 |
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 ""; } },
|
||||||
|
|||||||
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
|
|||||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
||||||
browser profile and SSH tunnel and remove the profile and panel log.
|
browser profile and SSH tunnel and remove the profile and panel log.
|
||||||
|
|
||||||
|
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
|
||||||
|
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
|
||||||
|
backend, no API key, no prestarted server) and a human approved or rejected
|
||||||
|
each change in the approval page in a real Chrome window:
|
||||||
|
|
||||||
|
| Tool | Result on the Frame |
|
||||||
|
|---|---|
|
||||||
|
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
|
||||||
|
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
|
||||||
|
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
|
||||||
|
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
|
||||||
|
| `uninstall` | Approved; app and locale removed |
|
||||||
|
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
|
||||||
|
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
|
||||||
|
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
|
||||||
|
|
||||||
|
A separate Claude Code CLI session, with only this server configured, read
|
||||||
|
status, `computer_state` and a headset capture, and requested an install. It
|
||||||
|
received an approval URL and did not execute anything.
|
||||||
|
|
||||||
|
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
|
||||||
|
Against a loopback stub model, a send without consent made zero requests. With
|
||||||
|
consent it made exactly one, carrying the text and a fresh Frame screenshot.
|
||||||
|
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
|
||||||
|
and tunnel.
|
||||||
|
|
||||||
|
**Not verified while unworn:** every headset capture was a uniform dark frame,
|
||||||
|
so SteamVR's rendered view of panels and the game could not be checked; window
|
||||||
|
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
|
||||||
|
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
|
||||||
|
runtime it pulled in; Flatpak may also remove related extensions when that
|
||||||
|
runtime is removed by hand.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Computer-use coverage
|
## Computer-use coverage
|
||||||
|
|||||||
+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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Locked real-Frame repeat, 2026-09-29
|
||||||
|
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
|
||||||
|
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
|
||||||
|
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
|
||||||
|
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
|
||||||
|
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
|
||||||
|
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
|
||||||
|
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
|
||||||
|
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
|
||||||
|
|
||||||
|
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
|
||||||
|
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
|
||||||
|
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
|
||||||
|
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
|
||||||
+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
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
|||||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||||
SteamVR's original process remained running. No global settings changed.
|
SteamVR's original process remained running. No global settings changed.
|
||||||
|
|
||||||
|
### Locked repeat, 2026-09-29 (verified)
|
||||||
|
|
||||||
|
Acquired `/tmp/frame-test.lock` before installing or launching anything and
|
||||||
|
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
|
||||||
|
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
|
||||||
|
|
||||||
|
Reinstalled and launched both original APKs in immersive Lepton instances.
|
||||||
|
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
|
||||||
|
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
|
||||||
|
unmodified-client test, not the newer installer's automatic compatibility-layer
|
||||||
|
path. Neither reached a session that could be paired or exercised further.
|
||||||
|
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
|
||||||
|
record the failures.
|
||||||
|
|
||||||
|
SteamVR's screenshot API returned a 1920×1080 headset capture after each
|
||||||
|
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
|
||||||
|
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
|
||||||
|
rendering or a working client. The headset was unworn; visibility, controllers,
|
||||||
|
frame rate and motion-to-photon latency could not be judged. The explicit
|
||||||
|
OpenXR errors, rather than the dark captures, establish the client blocker.
|
||||||
|
|
||||||
|
Both test installs, app data, shader caches, shortcuts, containers and temporary
|
||||||
|
capture files were removed. The original Steam and SteamVR process IDs were
|
||||||
|
unchanged. No reboot, power action or global setting change was used. Native
|
||||||
|
clients remain untested, and no Linux gaming host was available for Valve's
|
||||||
|
streaming path. The recommendation below is unchanged.
|
||||||
|
|
||||||
### Relation to the VR APK branch
|
### Relation to the VR APK branch
|
||||||
|
|
||||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
|||||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||||
|
|
||||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||||
it running. This mode also returned live timing in a read-only device probe.
|
it running. This mode also returned live timing in a read-only device probe.
|
||||||
|
|||||||
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**Verified end to end, 2026-09-29** (same build; headset unworn): uploads
|
||||||
|
through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by
|
||||||
|
the owned player. Our generated test files:
|
||||||
|
|
||||||
|
| Case | Result |
|
||||||
|
|---|---|
|
||||||
|
| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio |
|
||||||
|
| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right |
|
||||||
|
| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s |
|
||||||
|
| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s |
|
||||||
|
| SBS PNG, OU JPEG (theatre) | Correct eye in each capture |
|
||||||
|
| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop |
|
||||||
|
| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message |
|
||||||
|
| Second Play while one runs | Refused: "Stop the current media…" |
|
||||||
|
|
||||||
|
Stop always left the unit inactive, and no player process remained.
|
||||||
|
|
||||||
|
**Standby (verified):** an unworn Frame turns its displays off a few seconds
|
||||||
|
after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The
|
||||||
|
first run's movie died there. The player now drops frames while the headset
|
||||||
|
is in standby, keeps the audio and its clock going, and resumes the picture
|
||||||
|
when the headset wakes. The 8 s movie above dropped 40 frames and finished.
|
||||||
|
Stills and the theatre surround are re-sent after waking. Five minutes
|
||||||
|
without an accepted frame is reported as an error. Headset-view captures
|
||||||
|
taken during standby show a flat dark frame, not our screen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||||
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
||||||
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
||||||
|
|||||||
+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):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -16,6 +19,13 @@ import frame_splat as splat
|
|||||||
import server
|
import server
|
||||||
|
|
||||||
|
|
||||||
|
def stop_now():
|
||||||
|
"""What systemd's SIGTERM does to the player, without signalling the test process."""
|
||||||
|
handler = signal.getsignal(signal.SIGTERM)
|
||||||
|
if callable(handler):
|
||||||
|
handler(signal.SIGTERM, None)
|
||||||
|
|
||||||
|
|
||||||
class Media(unittest.TestCase):
|
class Media(unittest.TestCase):
|
||||||
def test_layout_evidence_and_override(self):
|
def test_layout_evidence_and_override(self):
|
||||||
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
|
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
|
||||||
@@ -56,6 +66,156 @@ class Media(unittest.TestCase):
|
|||||||
self.assertNotIn('-re', cmd)
|
self.assertNotIn('-re', cmd)
|
||||||
self.assertIn('-frames:v', cmd)
|
self.assertIn('-frames:v', cmd)
|
||||||
|
|
||||||
|
def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None,
|
||||||
|
fail=None, layout='auto'):
|
||||||
|
"""Run the player against a fake OpenVR; returns (status, pixels calls).
|
||||||
|
|
||||||
|
Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes
|
||||||
|
the n-th upload busy; every status written is kept in self.writes."""
|
||||||
|
calls = []
|
||||||
|
self.writes = []
|
||||||
|
write_status = player.write_status
|
||||||
|
|
||||||
|
def record(path, **values):
|
||||||
|
self.writes.append(values)
|
||||||
|
write_status(path, **values)
|
||||||
|
|
||||||
|
class FakeOverlay:
|
||||||
|
def create(self, *a, **k):
|
||||||
|
return len(calls)
|
||||||
|
|
||||||
|
def call(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pixels(self, handle, data, w, h):
|
||||||
|
calls.append((handle, w, h))
|
||||||
|
if on_pixels:
|
||||||
|
on_pixels(len(calls))
|
||||||
|
if len(calls) <= busy or (fail and fail(handle, len(calls))):
|
||||||
|
raise player.OverlayBusy('standby')
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# A Stop landing during cleanup must be ignored, not become an error.
|
||||||
|
# Call the installed handler directly: a real SIGTERM kills Windows.
|
||||||
|
stop_now()
|
||||||
|
|
||||||
|
frame = bytes(4*2*4)
|
||||||
|
proc = unittest.mock.MagicMock()
|
||||||
|
proc.stdout = io.BytesIO(frame*4)
|
||||||
|
proc.wait.return_value = 0
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
|
||||||
|
with tempfile.TemporaryDirectory() as d, \
|
||||||
|
patch.object(player, 'Overlay', FakeOverlay), \
|
||||||
|
patch.object(player, 'probe', return_value=(info or {'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \
|
||||||
|
patch.object(player.subprocess, 'Popen', return_value=proc), \
|
||||||
|
patch.object(player, 'write_status', side_effect=record), \
|
||||||
|
patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \
|
||||||
|
patch.object(player.time, 'sleep', side_effect=sleeps):
|
||||||
|
path = Path(d)/name
|
||||||
|
path.write_bytes(b'x')
|
||||||
|
status = Path(d)/'status.json'
|
||||||
|
try:
|
||||||
|
player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status)))
|
||||||
|
finally:
|
||||||
|
signal.signal(signal.SIGTERM, old[0])
|
||||||
|
signal.signal(signal.SIGINT, old[1])
|
||||||
|
return json.loads(status.read_text()), calls
|
||||||
|
|
||||||
|
def test_video_survives_standby_and_stop_after_end_stays_ended(self):
|
||||||
|
# Verified 2026-09-29: an unworn Frame enters standby within seconds and
|
||||||
|
# SetOverlayRaw then returns RequestFailed (23) until it wakes.
|
||||||
|
result, calls = self.play_with('clip_SBS.mp4', busy=3)
|
||||||
|
self.assertEqual((result['state'], result['frames']), ('ended', 4))
|
||||||
|
# Two video frames were dropped; the surround (handle 0) waited and was re-sent.
|
||||||
|
self.assertEqual(result['dropped'], 2)
|
||||||
|
self.assertIn((0, 1, 1), calls[3:])
|
||||||
|
|
||||||
|
def test_stop_mid_video_reports_stopped(self):
|
||||||
|
result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now())
|
||||||
|
self.assertEqual(result['state'], 'stopped')
|
||||||
|
|
||||||
|
def test_video_errors_when_steamvr_never_takes_frames(self):
|
||||||
|
with patch.object(player, 'BUSY_LIMIT', -1), \
|
||||||
|
self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'):
|
||||||
|
self.play_with('clip_SBS.mp4', busy=99)
|
||||||
|
|
||||||
|
def test_still_waits_out_standby_without_a_limit(self):
|
||||||
|
# Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown.
|
||||||
|
ticks = iter(range(10))
|
||||||
|
def sleep(_):
|
||||||
|
if next(ticks) == 8:
|
||||||
|
stop_now()
|
||||||
|
with patch.object(player, 'BUSY_LIMIT', -1):
|
||||||
|
result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep,
|
||||||
|
info={'codec_name': 'png', 'width': 4, 'height': 2})
|
||||||
|
self.assertEqual(result['state'], 'stopped')
|
||||||
|
screen = [c for c in calls if c[0] == 1]
|
||||||
|
self.assertGreater(len(screen), 1) # retried through standby
|
||||||
|
self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame
|
||||||
|
|
||||||
|
def still_with_late_surround(self, name, **kw):
|
||||||
|
# The screen takes its first frame while the surround is still refused
|
||||||
|
# (its first upload, the drain right after the screen, and one retry).
|
||||||
|
ticks = iter(range(10))
|
||||||
|
def sleep(_):
|
||||||
|
if next(ticks) == 5:
|
||||||
|
stop_now()
|
||||||
|
surround_tries = []
|
||||||
|
def fail(handle, n):
|
||||||
|
if handle == 0:
|
||||||
|
surround_tries.append(n)
|
||||||
|
return len(surround_tries) <= 3
|
||||||
|
return False
|
||||||
|
result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw)
|
||||||
|
self.assertEqual(result['state'], 'stopped')
|
||||||
|
screen = [c for c in calls if c[0] == 1]
|
||||||
|
surround = [c for c in calls if c[0] == 0]
|
||||||
|
self.assertEqual(len(screen), 1) # shown once, not re-sent every second
|
||||||
|
self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took
|
||||||
|
self.assertEqual(calls[-1][0], 0)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def test_photo_surround_recovers_after_screen_is_shown(self):
|
||||||
|
self.still_with_late_surround('photo_SBS.png',
|
||||||
|
info={'codec_name': 'png', 'width': 4, 'height': 2})
|
||||||
|
|
||||||
|
def test_splat_surround_recovers_after_screen_is_shown(self):
|
||||||
|
self.still_with_late_surround('scene.splat')
|
||||||
|
|
||||||
|
def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self):
|
||||||
|
self.assertEqual(player.BUSY_LIMIT, 300)
|
||||||
|
def run(times, busy):
|
||||||
|
# Upload n happens at times[n] seconds on a fake clock; 1 is the surround.
|
||||||
|
clock = [1000.0]
|
||||||
|
def on_pixels(n):
|
||||||
|
clock[0] = 1000.0 + times.get(n, times[max(times)])
|
||||||
|
with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]):
|
||||||
|
return self.play_with('clip_SBS.mp4', on_pixels=on_pixels,
|
||||||
|
fail=lambda h, n: h == 1 and n in busy)
|
||||||
|
# Busy for 299 s, then a frame lands: no error.
|
||||||
|
result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3})
|
||||||
|
self.assertEqual((result['state'], result['dropped']), ('ended', 2))
|
||||||
|
# An accepted frame resets the timer: 600 s busy in total, never 300 s in a row.
|
||||||
|
result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5})
|
||||||
|
self.assertEqual((result['state'], result['dropped']), ('ended', 3))
|
||||||
|
# 301 s in a row without an accepted frame is an error.
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'):
|
||||||
|
run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5})
|
||||||
|
|
||||||
|
def test_status_reports_where_the_layout_came_from(self):
|
||||||
|
video = {'codec_name': 'h264', 'width': 4, 'height': 2}
|
||||||
|
for name, layout, tags, expect in [
|
||||||
|
('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')),
|
||||||
|
('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')),
|
||||||
|
('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')),
|
||||||
|
('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]:
|
||||||
|
with self.subTest(name=name, layout=layout):
|
||||||
|
self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video)
|
||||||
|
playing = self.writes[0]
|
||||||
|
self.assertEqual(playing['state'], 'playing')
|
||||||
|
self.assertEqual((playing['layout'], playing['source']), expect)
|
||||||
|
|
||||||
def test_fake_frame_library_and_traversal(self):
|
def test_fake_frame_library_and_traversal(self):
|
||||||
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
||||||
identity = 'a'*32+'/space and quote\'.png'
|
identity = 'a'*32+'/space and quote\'.png'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+80
-14
@@ -33,6 +33,15 @@ SLOTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayBusy(RuntimeError):
|
||||||
|
"""SetOverlayRaw's RequestFailed (23): SteamVR isn't taking frames, e.g. the
|
||||||
|
unworn headset is in standby (verified 2026-09-29). Transient, not fatal."""
|
||||||
|
|
||||||
|
|
||||||
|
# A screen that can't take a frame for this long is broken, not asleep.
|
||||||
|
BUSY_LIMIT = 300
|
||||||
|
|
||||||
|
|
||||||
class Overlay:
|
class Overlay:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.handles = []
|
self.handles = []
|
||||||
@@ -53,6 +62,8 @@ class Overlay:
|
|||||||
def call(self, name, *values):
|
def call(self, name, *values):
|
||||||
slot, args = SLOTS[name]
|
slot, args = SLOTS[name]
|
||||||
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
|
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
|
||||||
|
if rc == 23 and name == 'SetOverlayRaw':
|
||||||
|
raise OverlayBusy('SteamVR is not accepting frames (standby?)')
|
||||||
if rc:
|
if rc:
|
||||||
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
|
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
|
||||||
|
|
||||||
@@ -75,11 +86,14 @@ class Overlay:
|
|||||||
self.call('ShowOverlay', handle)
|
self.call('ShowOverlay', handle)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
try:
|
# Best effort: SteamVR removes a disconnected client's overlays anyway,
|
||||||
for h in reversed(self.handles):
|
# and a teardown error must not overwrite a finished playback's status.
|
||||||
|
for h in reversed(self.handles):
|
||||||
|
try:
|
||||||
self.call('DestroyOverlay', h)
|
self.call('DestroyOverlay', h)
|
||||||
finally:
|
except RuntimeError:
|
||||||
self.vr.VR_ShutdownInternal()
|
pass
|
||||||
|
self.vr.VR_ShutdownInternal()
|
||||||
|
|
||||||
|
|
||||||
def probe(path):
|
def probe(path):
|
||||||
@@ -114,6 +128,11 @@ def decoder_command(path, info, width, height, audio, photo=False):
|
|||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def ignore_signals():
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||||
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||||
|
|
||||||
|
|
||||||
def write_status(path, **values):
|
def write_status(path, **values):
|
||||||
tmp = path.with_suffix('.tmp')
|
tmp = path.with_suffix('.tmp')
|
||||||
tmp.write_text(json.dumps(values))
|
tmp.write_text(json.dumps(values))
|
||||||
@@ -135,6 +154,46 @@ def play(args):
|
|||||||
photo = plan['kind'] == 'photo'
|
photo = plan['kind'] == 'photo'
|
||||||
command = decoder_command(path, info, width, height, audio, photo)
|
command = decoder_command(path, info, width, height, audio, photo)
|
||||||
vr, proc, frames, started = None, None, 0, time.monotonic()
|
vr, proc, frames, started = None, None, 0, time.monotonic()
|
||||||
|
dropped, busy_since, pending = 0, None, []
|
||||||
|
|
||||||
|
def show(handle, data, w, h, video=False):
|
||||||
|
"""Submit a frame. During standby return False; a video frame is dropped."""
|
||||||
|
nonlocal dropped, busy_since
|
||||||
|
try:
|
||||||
|
vr.pixels(handle, data, w, h)
|
||||||
|
except OverlayBusy:
|
||||||
|
if not video:
|
||||||
|
return False # stills and the surround just wait; nothing is lost
|
||||||
|
dropped += 1
|
||||||
|
busy_since = busy_since or time.monotonic()
|
||||||
|
if time.monotonic() - busy_since > BUSY_LIMIT:
|
||||||
|
raise RuntimeError('SteamVR stopped accepting frames for %d s' % BUSY_LIMIT)
|
||||||
|
return False
|
||||||
|
if video:
|
||||||
|
busy_since = None
|
||||||
|
drain()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def drain():
|
||||||
|
"""Re-send anything that arrived during standby (e.g. the theatre surround)."""
|
||||||
|
while pending:
|
||||||
|
item = pending.pop(0)
|
||||||
|
try:
|
||||||
|
vr.pixels(*item)
|
||||||
|
except OverlayBusy:
|
||||||
|
pending.insert(0, item)
|
||||||
|
return
|
||||||
|
|
||||||
|
def hold(handle, data, w, h):
|
||||||
|
"""Keep a still (photo or splat) up until Stop, retrying through standby."""
|
||||||
|
shown = False
|
||||||
|
while True:
|
||||||
|
if shown:
|
||||||
|
drain()
|
||||||
|
else:
|
||||||
|
shown = show(handle, data, w, h)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
|
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
|
||||||
# ownership; no unrelated Steam/SteamVR process or setting is touched.
|
# ownership; no unrelated Steam/SteamVR process or setting is touched.
|
||||||
def stop(signum, frame):
|
def stop(signum, frame):
|
||||||
@@ -146,14 +205,13 @@ def play(args):
|
|||||||
if args.theatre:
|
if args.theatre:
|
||||||
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
|
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
|
||||||
vr.call('SetOverlayAlpha', surround, .85)
|
vr.call('SetOverlayAlpha', surround, .85)
|
||||||
vr.pixels(surround, b'\x00\x00\x00\xff', 1, 1)
|
if not show(surround, b'\x00\x00\x00\xff', 1, 1):
|
||||||
|
pending.append((surround, b'\x00\x00\x00\xff', 1, 1))
|
||||||
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
|
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
|
||||||
plan['layout'] != 'mono', aspect)
|
plan['layout'] != 'mono', aspect)
|
||||||
if splat:
|
if splat:
|
||||||
vr.pixels(screen, data, width, height)
|
|
||||||
write_status(status, state='playing', file=path.name, frames=1, **plan)
|
write_status(status, state='playing', file=path.name, frames=1, **plan)
|
||||||
while True:
|
hold(screen, data, width, height)
|
||||||
time.sleep(1)
|
|
||||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||||
video_start = time.monotonic()
|
video_start = time.monotonic()
|
||||||
while True:
|
while True:
|
||||||
@@ -163,11 +221,14 @@ def play(args):
|
|||||||
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
|
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
|
||||||
if not photo:
|
if not photo:
|
||||||
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||||
vr.pixels(screen, data, outw, outh)
|
if photo:
|
||||||
|
still = data, outw, outh
|
||||||
|
else:
|
||||||
|
show(screen, data, outw, outh, video=True)
|
||||||
frames += 1
|
frames += 1
|
||||||
if frames == 1 or frames % 30 == 0:
|
if frames == 1 or frames % 30 == 0:
|
||||||
write_status(status, state='playing', file=path.name, frames=frames,
|
write_status(status, state='playing', file=path.name, frames=frames,
|
||||||
seconds=time.monotonic()-started, **plan)
|
dropped=dropped, seconds=time.monotonic()-started, **plan)
|
||||||
if not photo:
|
if not photo:
|
||||||
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||||
rc = proc.wait(timeout=10)
|
rc = proc.wait(timeout=10)
|
||||||
@@ -176,12 +237,17 @@ def play(args):
|
|||||||
if not frames:
|
if not frames:
|
||||||
raise RuntimeError('Decoder produced no frames')
|
raise RuntimeError('Decoder produced no frames')
|
||||||
if photo:
|
if photo:
|
||||||
while True:
|
hold(screen, *still)
|
||||||
time.sleep(1)
|
# From here on a Stop can't change the outcome; don't let it turn
|
||||||
write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started)
|
# 'ended' into an error while we write status and clean up.
|
||||||
|
ignore_signals()
|
||||||
|
write_status(status, state='ended', frames=frames, dropped=dropped,
|
||||||
|
seconds=time.monotonic()-started)
|
||||||
except InterruptedError:
|
except InterruptedError:
|
||||||
write_status(status, state='stopped', frames=frames)
|
ignore_signals()
|
||||||
|
write_status(status, state='stopped', frames=frames, dropped=dropped)
|
||||||
finally:
|
finally:
|
||||||
|
ignore_signals()
|
||||||
if proc:
|
if proc:
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def run(body):
|
|||||||
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
||||||
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
||||||
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
|
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
|
||||||
'--layout', plan['layout'], '--status', str(STATUS)]
|
'--layout', body.get('layout', 'auto'), '--status', str(STATUS)]
|
||||||
if body.get('theatre'):
|
if body.get('theatre'):
|
||||||
command.append('--theatre')
|
command.append('--theatre')
|
||||||
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
||||||
|
|||||||
@@ -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
|
||||||
+736
-86
File diff suppressed because it is too large.
Load diff
+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