mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Compare commits
95
Commits
apk-store-fixes
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551cc54bbc | ||
|
|
c3e651cd25 | ||
|
|
f0ba42bfba | ||
|
|
99fc15bd79 | ||
|
|
e63dc43c2f | ||
|
|
80f433a2d3 | ||
|
|
07f44f9082 | ||
|
|
c305daae15 | ||
|
|
a4031db052 | ||
|
|
049d50f43a | ||
|
|
3a95d3b638 | ||
|
|
989962aecc | ||
|
|
08d75e3ffb | ||
|
|
01d5c612c0 | ||
|
|
28073e212a | ||
|
|
6abc765e22 | ||
|
|
f73efe414c | ||
|
|
704e5d7780 | ||
|
|
edbe8d4109 | ||
|
|
f2c8466ba9 | ||
|
|
6cf01729e2 | ||
|
|
63c1a9ff55 | ||
|
|
47a29afb4c | ||
|
|
a0f810c018 | ||
|
|
e8db571a2c | ||
|
|
d9cd40c035 | ||
|
|
865e8dc17f | ||
|
|
34a334fa27 | ||
|
|
3f273ca37a | ||
|
|
72ffec45c2 | ||
|
|
2ca0e6924a | ||
|
|
cf2db32721 | ||
|
|
3f0f09b138 | ||
|
|
b8ed53f2ff | ||
|
|
19a0d0af18 | ||
|
|
7308ac09b1 | ||
|
|
1a5f089e57 | ||
|
|
83d74548cd | ||
|
|
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
+108
-22
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -149,7 +149,7 @@ async function startServer() {
|
||||
const target = `http://127.0.0.1:${port}/`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||
if (await ping(target)) { url = target; return; }
|
||||
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (server === child) server = null;
|
||||
@@ -159,51 +159,76 @@ async function startServer() {
|
||||
|
||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||
// way on Windows); SIGTERM does the same elsewhere.
|
||||
// Resolves once it has exited (or after 20 s), so a replacement can take the server
|
||||
// lock: server.py allows one per user.
|
||||
function endServer(child) {
|
||||
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
|
||||
: new Promise((resolve) => child.once("exit", resolve));
|
||||
try { child.stdin.end(); } catch {}
|
||||
if (!IS_WIN) child.kill("SIGTERM");
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
||||
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
|
||||
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
if (server) endServer(server);
|
||||
}
|
||||
|
||||
function errorPage(message) {
|
||||
function errorPage(message, title = "Frame Control couldn't start") {
|
||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||
<p>${esc(message)}</p>
|
||||
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
|
||||
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
|
||||
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
|
||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||
}
|
||||
|
||||
// A server that had been running starts again by itself (something stopped it: a
|
||||
// signal, a crash). One that stops again within a minute shows the error instead,
|
||||
// so a server that can't stay up doesn't restart forever.
|
||||
let serverStarted = 0;
|
||||
function serverDied(why) {
|
||||
url = null;
|
||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||
if (!win) return;
|
||||
if (Date.now() - serverStarted > 60000) restartServer();
|
||||
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) endServer(old);
|
||||
await load();
|
||||
// Restarts that overlap share one: two could each start a server, and the one
|
||||
// that lost the lock would leave the app pointing at nothing.
|
||||
let restarting = null;
|
||||
function restartServer() {
|
||||
if (!restarting) {
|
||||
restarting = (async () => {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) await endServer(old);
|
||||
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
|
||||
await load();
|
||||
})().finally(() => { restarting = null; });
|
||||
}
|
||||
return restarting;
|
||||
}
|
||||
|
||||
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||
const CHROME_CSS = IS_MAC && `
|
||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
|
||||
`;
|
||||
|
||||
// Restart Server can start a new load while an older one is still waiting for
|
||||
// its server; only the newest load may touch the window.
|
||||
let loadGen = 0;
|
||||
let starting = null; // loads that overlap share one server start
|
||||
async function load() {
|
||||
const gen = ++loadGen;
|
||||
try {
|
||||
if (!url) await startServer();
|
||||
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||
} catch (e) {
|
||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||
@@ -247,13 +272,66 @@ function fromUi(e) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// The error page's Try Again button. The error page is the only data: page the window
|
||||
// shows (`url` can still be set then: the server answered but the page failed to load).
|
||||
ipcMain.handle("server:restart", (e) => {
|
||||
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||
});
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
|
||||
if (img.isEmpty()) throw new Error("not an image");
|
||||
clipboard.writeImage(img);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||
|
||||
// The page reports the headsets it knows (the server's Devices tab), so the Frame
|
||||
// menu can switch between them. Only plain names and ids go into the menu.
|
||||
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
let devices = [];
|
||||
ipcMain.on("devices:changed", (e, list) => {
|
||||
if (!fromUi(e) || !Array.isArray(list)) return;
|
||||
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
|
||||
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
|
||||
if (JSON.stringify(next) === JSON.stringify(devices)) return;
|
||||
devices = next;
|
||||
buildMenu();
|
||||
});
|
||||
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||
|
||||
// SSH through the server, so it goes to the headset and address the app is using
|
||||
// (with its own pinned identity), and refuses when there's none.
|
||||
function openSsh() {
|
||||
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
|
||||
const body = JSON.stringify({ what: "terminal" });
|
||||
const req = http.request(new URL("/api/open", url), {
|
||||
method: "POST", timeout: 15000,
|
||||
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
|
||||
}, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (c) => { data += c; });
|
||||
res.on("end", () => {
|
||||
if (res.statusCode === 200) return;
|
||||
let why = `HTTP ${res.statusCode}`;
|
||||
try { why = JSON.parse(data).error || why; } catch {}
|
||||
dialog.showErrorBox("Couldn't open SSH", why);
|
||||
});
|
||||
});
|
||||
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
|
||||
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||
req.end(body);
|
||||
}
|
||||
function showDevices() {
|
||||
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
|
||||
}
|
||||
|
||||
ipcMain.handle("comfort:notify", (e, message) => {
|
||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||
@@ -425,13 +503,14 @@ async function runInTerminal(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setUpConnection() {
|
||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||
async function setUpConnection(name = activeAlias()) {
|
||||
if (!ALIAS_RE.test(name)) return;
|
||||
const alias = `FRAME_ALIAS=${name}`;
|
||||
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
||||
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
@@ -446,8 +525,15 @@ function buildMenu() {
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||
{ type: "separator" },
|
||||
...(devices.length > 1 ? [{
|
||||
label: "Headset",
|
||||
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
|
||||
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
|
||||
}] : []),
|
||||
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
|
||||
{ type: "separator" },
|
||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
+10
-1
@@ -2,7 +2,8 @@
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can open Set Up Connection when the headset can't be reached.
|
||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
@@ -11,7 +12,15 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||
onUseDevice: (cb) => {
|
||||
ipcRenderer.removeAllListeners("use-device");
|
||||
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
|
||||
},
|
||||
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
|
||||
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
+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.
|
||||
+24
-9
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
||||
plain words and the app retries every few seconds, filling everything in once it
|
||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
reached, a banner says why in plain words, what was tried, and counts down to
|
||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
@@ -78,10 +81,20 @@ counts them while they run.
|
||||
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||
Remmina).
|
||||
- **Devices**: several headsets, each with several addresses (LAN IPs per
|
||||
network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
|
||||
tries them all at once and learns which worked on which network. Add, edit,
|
||||
reorder and test addresses, find a headset on Tailscale or on this network,
|
||||
name your networks, and switch headsets. See [devices.md](devices.md).
|
||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||
asks for the sudo password over SSH.
|
||||
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||
3389 (Developer Mode turns it on). On Windows it opens a connection file for
|
||||
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
|
||||
xrdp turns away. Accept the warning about the Frame's own certificate, then
|
||||
sign in with the Developer Mode password. Sleep, restart and
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -101,7 +114,9 @@ Frame for the keyboard and trackpad.
|
||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||
with a non-local `Host` header, and any `/api/` request without a custom
|
||||
header, so other websites can't drive it or read captures. Everything reaches
|
||||
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
|
||||
the headset through its SSH alias (`frame` for the first one), pointed at the
|
||||
address that answered with `-o HostName=` (`ui/frame_link.py`, described in
|
||||
[devices.md](devices.md)). On macOS and Linux it keeps one
|
||||
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
||||
Windows' OpenSSH can't share a connection, so there each request connects on
|
||||
its own and the app is a little slower. What differs between the three
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||
|
||||
+69
-4
@@ -103,9 +103,18 @@ privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
the person sends it deliberately. The report has the kind, title and text you
|
||||
wrote, how to reach you if you gave it, a short reference shown after sending,
|
||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
||||
your analytics events.
|
||||
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||
email address goes with it only if you tick **The maintainer may contact me
|
||||
with follow-up questions** (the report then carries `contact_followup: true`);
|
||||
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
|
||||
your analytics events. With that box ticked, the address also becomes your
|
||||
**Contact email** below with follow-up questions ticked, so you remove it there
|
||||
like any other. If it's a different address from the one saved there, it
|
||||
replaces it, and update notices stop until you turn them on again (they were
|
||||
agreed for the old address); the form says so before you send. The report then also
|
||||
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
|
||||
see below), so removing or changing the address later takes back the
|
||||
follow-up permission given with the report too.
|
||||
|
||||
With **Include diagnostics** ticked (the default), the report adds:
|
||||
|
||||
@@ -128,11 +137,67 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
|
||||
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||
API key as `frame_compat_db.py sync`.
|
||||
|
||||
## Contact email (optional)
|
||||
|
||||
Frame Control never needs an email address. If you'd like to leave one, there
|
||||
are two separate choices, both off until you tick them:
|
||||
|
||||
| Choice | What it's for |
|
||||
|---|---|
|
||||
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
|
||||
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||
|
||||
You're asked once, in a bar at the top of the page, after the Frame has
|
||||
connected for the first time, and never in the same visit as the first-run
|
||||
privacy notice. **No thanks** hides it for good, and it isn't
|
||||
shown again even if you ignore it. **Contact email** in **Privacy & updates**
|
||||
is where you add, change or remove the address and either choice at any time.
|
||||
|
||||
**What's sent, and where.** The address and the two choices go privately to
|
||||
Frame Control's PostHog project, the same place as problem reports, as a
|
||||
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
|
||||
or `withdraw`) and the common properties above. Only the maintainer can read
|
||||
that project, and nothing in it is published or shared. It's sent only when
|
||||
you save, or when you send a problem report with follow-up questions ticked,
|
||||
whatever the analytics settings are, because you chose to. With a report, the
|
||||
address and choices are saved before the report is sent and stay saved if it
|
||||
fails; like any change, they're sent as soon as PostHog can be reached. It
|
||||
carries its own random contact id, not the analytics id, so it isn't linked
|
||||
to your usage events, and a `rev` number that goes up with each change, so
|
||||
the newest choice always wins. Like everything else sent, it's listed under
|
||||
**Show what's been sent**. On this computer the address and choices are kept in
|
||||
`contact/contact.json` in Frame Control's data folder. An address is only
|
||||
kept with at least one choice ticked.
|
||||
|
||||
**Removing it.** **Remove my email** (or clearing the address and saving)
|
||||
deletes it from this computer, including from the **Show what's been sent**
|
||||
log (in earlier contact events and problem reports), and sends a `withdraw`
|
||||
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
|
||||
then on the address isn't listed for either choice. Unticking one choice
|
||||
works the same way for that choice. This also covers problem reports you sent
|
||||
from this copy with follow-up questions ticked: if your newest choice since the
|
||||
report (by change number, not the clock) no longer agrees to follow-up
|
||||
questions at that address, the maintainer's inbox shows the permission as
|
||||
withdrawn and leaves the address out. If you're offline, the change waits on
|
||||
this computer and is sent when PostHog can be reached. The earlier event
|
||||
stays in PostHog until its data retention removes it; to have it deleted
|
||||
sooner, ask the maintainer (for example in a problem report).
|
||||
|
||||
Nothing sends email yet: this only records who agreed to what. The
|
||||
maintainer lists the addresses with
|
||||
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
|
||||
personal API key as `inbox`.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
||||
the environment that starts Frame Control. A copy run from a source checkout
|
||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||
|
||||
These switches cover the analytics above. A problem report or a contact email
|
||||
is sent only because you pressed its Send or Save button, so those still go
|
||||
when you choose to send them (a contact change saved while offline is sent
|
||||
by itself once PostHog can be reached); if you don't, nothing is sent.
|
||||
|
||||
## Update checks
|
||||
|
||||
|
||||
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
|
||||
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
|
||||
Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||
|
||||
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
|
||||
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
|
||||
fills in `steamos` there on Windows, Remmina and FreeRDP.
|
||||
- The desktop is a separate login session (Xorg on display `:10`), not the
|
||||
headset's view. It uses about 1.3 GB of the Frame's memory.
|
||||
- Closing the client leaves the session running, and the next login
|
||||
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
|
||||
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
|
||||
gamescope or SteamVR session.
|
||||
|
||||
## B. Show the Mac's desktop inside the Frame
|
||||
|
||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||
|
||||
@@ -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
|
||||
`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.
|
||||
|
||||
+16
-3
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
|
||||
fi
|
||||
}
|
||||
|
||||
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
||||
# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
|
||||
# running app and this script never write over each other's change.
|
||||
write_config() {
|
||||
local lockfd="" rc
|
||||
zmodload zsh/system 2>/dev/null
|
||||
touch "$CONFIG.frame-control.lock" 2>/dev/null
|
||||
zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
|
||||
write_config_locked; rc=$?
|
||||
[[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
|
||||
return $rc
|
||||
}
|
||||
|
||||
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
||||
write_config_locked() {
|
||||
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
||||
local tmp
|
||||
local tmp new="$CONFIG.frame-control.$$"
|
||||
tmp=$(mktemp) || return 1
|
||||
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
||||
# value it sees per option, so this block must precede any other "Host frame"
|
||||
@@ -126,7 +138,8 @@ write_config() {
|
||||
print -r -- "Host *"
|
||||
print -r -- "$END_MARK"
|
||||
cat "$tmp"
|
||||
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
||||
} > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
|
||||
|| { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||
HERE=${0:A:h}
|
||||
cmd=${1:-status}
|
||||
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
||||
|
||||
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||
ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
|
||||
|
||||
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
||||
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||
# written the way Steam's settings page does (steamui module exporting the
|
||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||
# ("Interactive authentication required") but allows one from a user unit.
|
||||
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||
import json, os, subprocess, sys
|
||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||
from frame_steam import Page
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
||||
id="" name=""
|
||||
|
||||
@@ -109,4 +111,4 @@ EOF
|
||||
)
|
||||
b64=$(print -rn -- "$remote" | base64)
|
||||
|
||||
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
||||
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
||||
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), \
|
||||
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
|
||||
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
||||
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
|
||||
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
|
||||
# Per headset (its tag), and per process for a private server.
|
||||
self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C')
|
||||
self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C')
|
||||
|
||||
|
||||
class ComputerState(unittest.TestCase):
|
||||
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ScriptsFollowTheHeadset(unittest.TestCase):
|
||||
"""keep_awake and panel tools run scripts that ssh on their own: they must reach the
|
||||
headset the server is routed to, not whatever `frame` means in ~/.ssh/config."""
|
||||
|
||||
@unittest.skipUnless(shutil.which('zsh'), 'needs zsh')
|
||||
def test_scripts_get_the_routed_alias_and_options(self):
|
||||
import shlex
|
||||
fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui',
|
||||
SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab'])
|
||||
with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run:
|
||||
agent.run_script(fake, 'keep-awake.sh', ['status'])
|
||||
env = run.call_args.kwargs['env']
|
||||
self.assertEqual(env['FRAME_ALIAS'], 'frame-2')
|
||||
self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:])
|
||||
# and the script turns that back into the same argv
|
||||
out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'],
|
||||
env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True)
|
||||
self.assertEqual(out.stdout.splitlines(), fake.SSH[1:])
|
||||
@@ -0,0 +1,412 @@
|
||||
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
|
||||
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import sys
|
||||
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"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import frame_compat_db as db # noqa: E402
|
||||
import frame_contact as fc # noqa: E402
|
||||
import frame_report as fr # noqa: E402
|
||||
import frame_telemetry as tm # noqa: E402
|
||||
from test_telemetry import Base, ReportProblem # noqa: E402
|
||||
|
||||
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
|
||||
serve = ReportProblem.serve
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.addCleanup(fc._removed.clear)
|
||||
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
|
||||
p = mock.patch.object(fc, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.got = self.serve()
|
||||
|
||||
def events(self):
|
||||
return [body["batch"][0] for _, body in self.got]
|
||||
|
||||
def offline(self):
|
||||
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
|
||||
|
||||
# ---- storage and consent flags
|
||||
|
||||
def test_nothing_is_kept_or_sent_until_chosen(self):
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
|
||||
self.assertFalse(fc.FILE.exists())
|
||||
self.assertEqual(self.got, [])
|
||||
|
||||
def test_an_address_needs_a_choice_and_a_real_address(self):
|
||||
with self.assertRaisesRegex(ValueError, "tick"):
|
||||
fc.save({"email": "me@example.com"})
|
||||
with self.assertRaisesRegex(ValueError, "email address"):
|
||||
fc.save({"email": "not an address", "updates": True})
|
||||
self.assertEqual(fc.load()["email"], "")
|
||||
self.assertEqual(self.got, [])
|
||||
|
||||
def test_only_a_real_true_counts_as_consent(self):
|
||||
for wrong in ("false", "true", 1, 0, [], {}):
|
||||
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
|
||||
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
|
||||
self.assertEqual((fc.load()["email"], self.got), ("", []))
|
||||
fc.save({"email": "me@example.com", "updates": True}) # left out is no
|
||||
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
|
||||
|
||||
def test_each_choice_is_sent_privately_on_its_own(self):
|
||||
fc.save({"email": " me@example.com ", "updates": True})
|
||||
fc.save({"email": "me@example.com", "updates": False, "followup": True})
|
||||
first, second = self.events()
|
||||
self.assertEqual(first["event"], "contact_consent")
|
||||
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
|
||||
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
|
||||
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
|
||||
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
|
||||
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
|
||||
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
|
||||
(False, True))
|
||||
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
|
||||
|
||||
def test_sent_whatever_the_analytics_settings(self):
|
||||
tm.update_settings({"usage": False})
|
||||
fc.save({"email": "me@example.com", "followup": True})
|
||||
self.assertEqual(len(self.got), 1)
|
||||
|
||||
def test_saving_the_same_choice_again_sends_nothing(self):
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
self.assertEqual(len(self.got), 1)
|
||||
|
||||
# ---- withdrawal
|
||||
|
||||
def test_removing_the_address_sends_a_withdrawal_without_it(self):
|
||||
fc.save({"email": "me@example.com", "updates": True, "followup": True})
|
||||
s = fc.save({"email": "", "updates": True, "followup": True})
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
|
||||
withdrawal = self.events()[-1]["properties"]
|
||||
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
|
||||
("withdraw", "", False, False))
|
||||
self.assertNotIn("me@example.com", fc.FILE.read_text())
|
||||
|
||||
def test_an_address_still_waiting_is_withdrawn_too(self):
|
||||
with self.offline():
|
||||
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
|
||||
with mock.patch.object(tm, "post") as post:
|
||||
fc.save({"email": ""})
|
||||
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
|
||||
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
with self.offline():
|
||||
s = fc.save({"email": ""})
|
||||
self.assertTrue(s["waiting"])
|
||||
self.assertFalse(fc._send_pending())
|
||||
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
|
||||
self.assertTrue(fc._send_pending())
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
|
||||
|
||||
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
|
||||
fc.save({"email": "me@example.com", "followup": True})
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
self.assertIn("me@example.com", tm.SENT.read_text())
|
||||
fc.save({"email": ""})
|
||||
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
||||
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
|
||||
if e["event"] == "contact_consent"], ["set", "withdraw"])
|
||||
|
||||
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
fc.save({"email": "new@example.com", "updates": True})
|
||||
fc.save({"email": ""})
|
||||
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
|
||||
|
||||
def test_a_withdrawal_during_a_send_goes_after_it(self):
|
||||
started, release, order = threading.Event(), threading.Event(), []
|
||||
real = tm.post
|
||||
|
||||
def slow(batch, timeout=20):
|
||||
order.append(batch[0]["properties"]["action"])
|
||||
if len(order) == 1:
|
||||
started.set()
|
||||
release.wait(5)
|
||||
real(batch, timeout)
|
||||
|
||||
with mock.patch.object(tm, "post", side_effect=slow):
|
||||
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
|
||||
t.start()
|
||||
self.assertTrue(started.wait(5))
|
||||
w = threading.Thread(target=fc.save, args=({"email": ""},))
|
||||
w.start()
|
||||
for _ in range(500): # the withdrawal is saved while the first send is still out
|
||||
if fc.load()["rev"] == 2:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
|
||||
release.set()
|
||||
t.join(5)
|
||||
w.join(5)
|
||||
self.assertEqual(order, ["set", "withdraw"])
|
||||
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
||||
|
||||
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
|
||||
fc.save({"email": "me@example.com", "followup": True})
|
||||
real = tm.post
|
||||
|
||||
def remove_meanwhile(batch, timeout=20):
|
||||
real(batch, timeout)
|
||||
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
|
||||
|
||||
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
|
||||
|
||||
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
|
||||
fc._removed["me@example.com"] = 1790000000.3
|
||||
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
|
||||
before, after = event(), event() # the same whole second as the removal
|
||||
fc.redact_removed(before, 1790000000.1)
|
||||
fc.redact_removed(after, 1790000000.6)
|
||||
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
|
||||
("<removed>", "me@example.com"))
|
||||
|
||||
def test_saving_during_a_slow_send_returns_at_once(self):
|
||||
busy = fc._send_lock
|
||||
busy.acquire()
|
||||
try:
|
||||
s = fc.save({"email": "me@example.com", "updates": True})
|
||||
finally:
|
||||
busy.release()
|
||||
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
|
||||
self.assertEqual(self.got, [])
|
||||
self.assertTrue(fc._send_pending())
|
||||
self.assertEqual(len(self.got), 1)
|
||||
|
||||
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
|
||||
real = fc._send_lock
|
||||
|
||||
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
|
||||
saved = False
|
||||
|
||||
def acquire(self, blocking=True):
|
||||
return real.acquire(blocking)
|
||||
|
||||
def release(self):
|
||||
if not Lock.saved:
|
||||
Lock.saved = True
|
||||
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
|
||||
s.start()
|
||||
s.join(5)
|
||||
assert not s.is_alive() # the change is saved while the sender still holds the lock
|
||||
real.release()
|
||||
|
||||
with mock.patch.object(fc, "_send_lock", Lock()):
|
||||
self.assertTrue(fc._send_pending())
|
||||
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
|
||||
# ---- the one-time prompt
|
||||
|
||||
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
|
||||
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
self.assertTrue(fc.state()["showPrompt"])
|
||||
fc.prompt({"prompt": "dismissed"})
|
||||
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
|
||||
self.assertEqual(fc.load()["prompt"], "dismissed")
|
||||
self.assertFalse(fc.state()["showPrompt"])
|
||||
self.assertEqual(self.got, []) # No thanks sends nothing
|
||||
with self.assertRaises(ValueError):
|
||||
fc.prompt({"prompt": "reset"})
|
||||
|
||||
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
fc.prompt({"prompt": "shown"})
|
||||
self.assertFalse(fc.state()["showPrompt"])
|
||||
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
|
||||
self.assertEqual(fc.load()["prompt"], "answered")
|
||||
|
||||
# ---- reports and the maintainer's list
|
||||
|
||||
def reports(self):
|
||||
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
|
||||
|
||||
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
|
||||
fr.send({**REPORT, "contact": "me@example.com"})
|
||||
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
without, with_ = self.reports()
|
||||
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
|
||||
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
|
||||
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
|
||||
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
|
||||
with self.assertRaisesRegex(ValueError, "email address"):
|
||||
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
|
||||
|
||||
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
|
||||
consent = [e for e in self.events() if e["event"] == "contact_consent"]
|
||||
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
|
||||
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
|
||||
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
|
||||
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
|
||||
self.assertEqual(self.reports()[1]["contact_rev"], 1)
|
||||
fc.save({"email": ""}) # Remove my email
|
||||
last = self.events()[-1]
|
||||
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
|
||||
("withdraw", "", 2))
|
||||
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||
self.assertEqual(logged, ["<removed>", "<removed>"])
|
||||
|
||||
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
|
||||
"""Update notices were agreed for the old address, not the new one (the form says so)."""
|
||||
fc.save({"email": "old@example.com", "updates": True})
|
||||
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
|
||||
self.assertEqual(self.reports()[0]["contact_rev"], 2)
|
||||
fc.save({"email": "new@example.com", "updates": True, "followup": False})
|
||||
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
|
||||
|
||||
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
|
||||
"""Removed while the report's own consent is on its way: the report keeps that consent's
|
||||
rev (so the removal is newer) and is logged without the address."""
|
||||
post, removed = tm.post, []
|
||||
|
||||
def slow_post(events, **kw):
|
||||
post(events, **kw)
|
||||
if not removed and events[0]["event"] == "contact_consent":
|
||||
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
|
||||
with mock.patch.object(tm, "post", side_effect=slow_post):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
report = self.reports()[0]
|
||||
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
|
||||
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||
for e in self.events() if e["event"] == "contact_consent"]
|
||||
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||
fr.mark_withdrawn([row], consents)
|
||||
self.assertEqual(row[10], "withdrawn")
|
||||
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||
self.assertEqual(logged, ["<removed>"])
|
||||
|
||||
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
|
||||
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
|
||||
"0.4.0", "Windows", "", "", followup, cid, rev]
|
||||
|
||||
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
|
||||
reports = [self.report_row(), # removed later
|
||||
self.report_row(cid="other"), # another copy, still agrees
|
||||
self.report_row(rev=3), # sent after the removal
|
||||
self.report_row(cid="moved"), # address changed later
|
||||
self.report_row(cid="news-only"), # follow-up unticked later
|
||||
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
|
||||
self.report_row(cid="", followup=True), # no contact id: left alone
|
||||
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
|
||||
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
|
||||
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
|
||||
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
|
||||
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
|
||||
fr.mark_withdrawn(reports, consents)
|
||||
self.assertEqual([r[10] for r in reports],
|
||||
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
|
||||
|
||||
def test_the_change_number_decides_not_the_clock(self):
|
||||
"""The clock went back between the report and the removal: the removal still counts."""
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
|
||||
fc.save({"email": ""})
|
||||
report = self.reports()[0]
|
||||
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||
for e in self.events() if e["event"] == "contact_consent"]
|
||||
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
|
||||
fr.mark_withdrawn([row], consents)
|
||||
self.assertEqual(row[10], "withdrawn")
|
||||
|
||||
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
|
||||
reports = [self.report_row(), ["short"]]
|
||||
consents = [["copy", "", False, 2]]
|
||||
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
fr.main()
|
||||
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
|
||||
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
|
||||
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||
self.assertIn("follow-up permission since withdrawn", printed)
|
||||
self.assertNotIn("me@example.com", printed)
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
|
||||
fr.inbox()
|
||||
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
|
||||
|
||||
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
|
||||
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
|
||||
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
|
||||
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
|
||||
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
|
||||
found = fr.contacts()
|
||||
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
|
||||
q.call_args.args[0])
|
||||
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
|
||||
"followup": [("both@example.com", "2026-09-01")]})
|
||||
with mock.patch.object(fr, "contacts", return_value=found), \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
fr.main()
|
||||
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||
self.assertIn("both@example.com", printed)
|
||||
self.assertNotIn("news@example.com", printed)
|
||||
|
||||
def test_the_page_can_reach_it(self):
|
||||
import server
|
||||
self.assertIs(server.POST["/api/contact"], fc.save)
|
||||
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
|
||||
|
||||
def test_saving_is_not_headset_work(self):
|
||||
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
|
||||
import io
|
||||
import server
|
||||
seen = []
|
||||
for path in ("/api/contact", "/api/contact/prompt"):
|
||||
h = server.Handler.__new__(server.Handler)
|
||||
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
|
||||
h.path, h.rfile = path, io.BytesIO(body)
|
||||
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
|
||||
h.local_request = lambda: True
|
||||
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
|
||||
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
|
||||
h.do_POST()
|
||||
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
|
||||
|
||||
|
||||
# Run these once, in test_telemetry, not again through the import above.
|
||||
del Base, ReportProblem
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,403 @@
|
||||
"""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
|
||||
import frame_host # 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")
|
||||
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||
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")
|
||||
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||
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"])
|
||||
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
||||
data.install_obb(PKG, [path])
|
||||
stream.assert_not_called()
|
||||
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
||||
data._stream('command')
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
|
||||
import sandbox # noqa: F401
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
|
||||
import frame_host
|
||||
|
||||
|
||||
class CapturedSSH(unittest.TestCase):
|
||||
def run_command(self, source, **kwargs):
|
||||
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
|
||||
|
||||
def test_binary_output_and_input(self):
|
||||
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
|
||||
"sys.stderr.buffer.write(b'error\\r\\n')",
|
||||
capture_output=True, input=b"data\x00\xff")
|
||||
self.assertEqual(result.stdout, b"data\x00\xff")
|
||||
self.assertEqual(result.stderr, b"error\r\n")
|
||||
|
||||
def test_text_output_normalizes_newlines(self):
|
||||
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
|
||||
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
|
||||
capture_output=True, input="hello\n", text=True)
|
||||
self.assertEqual(result.stdout, "hello\n")
|
||||
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
|
||||
|
||||
def test_explicit_encoding_and_errors(self):
|
||||
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
|
||||
capture_output=True, encoding="ascii", errors="replace")
|
||||
self.assertEqual(result.stderr, "\ufffd\ufffd")
|
||||
|
||||
def test_check_preserves_error_output(self):
|
||||
with self.assertRaises(subprocess.CalledProcessError) as caught:
|
||||
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
|
||||
capture_output=True, text=True, check=True)
|
||||
self.assertEqual(caught.exception.returncode, 7)
|
||||
self.assertEqual(caught.exception.stdout, "out\n")
|
||||
self.assertEqual(caught.exception.stderr, "err\n")
|
||||
|
||||
def test_timeout_preserves_partial_stderr(self):
|
||||
with self.assertRaises(subprocess.TimeoutExpired) as caught:
|
||||
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
|
||||
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
|
||||
capture_output=True, text=True, timeout=1)
|
||||
self.assertEqual(caught.exception.stderr, b"waiting")
|
||||
|
||||
def test_streamed_stdout_is_kept_separate(self):
|
||||
with tempfile.TemporaryFile() as output:
|
||||
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
|
||||
"sys.stderr.buffer.write(b'error')",
|
||||
stdout=output, stderr=subprocess.PIPE)
|
||||
output.seek(0)
|
||||
self.assertEqual(output.read(), b"file")
|
||||
self.assertIsNone(result.stdout)
|
||||
self.assertEqual(result.stderr, b"error")
|
||||
|
||||
def test_uncaptured_windows_call_is_unchanged(self):
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
|
||||
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||
|
||||
def test_posix_call_is_unchanged(self):
|
||||
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
|
||||
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||
|
||||
def test_capture_rejects_explicit_streams(self):
|
||||
for stream in ("stdout", "stderr"):
|
||||
with self.subTest(stream=stream), self.assertRaises(ValueError):
|
||||
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
|
||||
|
||||
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
|
||||
def test_real_ssh_failure_returns_stderr_without_hanging(self):
|
||||
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 255)
|
||||
self.assertIn("Could not resolve hostname", result.stderr)
|
||||
@@ -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 http.client
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
@@ -42,6 +43,43 @@ class Helpers(unittest.TestCase):
|
||||
self.assertEqual(h, 1080)
|
||||
self.assertAlmostEqual(w / h, 0.5, places=2)
|
||||
|
||||
def test_the_tunnel_follows_the_headset(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
|
||||
mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
|
||||
self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
|
||||
|
||||
class Tunnel:
|
||||
ended = False
|
||||
def poll(self): return None
|
||||
def terminate(self): Tunnel.ended = True
|
||||
mv.tunnel, mv.remote_port = Tunnel(), 47999
|
||||
mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it
|
||||
self.assertFalse(Tunnel.ended)
|
||||
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel
|
||||
self.assertTrue(Tunnel.ended)
|
||||
self.assertIsNone(mv.tunnel)
|
||||
self.assertEqual(mv.frame, "frame-2")
|
||||
|
||||
def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
|
||||
mv.port = 47000
|
||||
ended = []
|
||||
|
||||
class Proc:
|
||||
def poll(self): return None
|
||||
def terminate(self): ended.append(self)
|
||||
def wait(self): return 0
|
||||
stderr = io.StringIO("")
|
||||
|
||||
def probe(port):
|
||||
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now
|
||||
return True
|
||||
with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \
|
||||
mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe):
|
||||
self.assertFalse(mv._open_tunnel([], [47001]))
|
||||
self.assertIsNone(mv.tunnel)
|
||||
self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||
def test_launch_script_parses(self):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
@@ -16,6 +19,13 @@ import frame_splat as splat
|
||||
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):
|
||||
def test_layout_evidence_and_override(self):
|
||||
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.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):
|
||||
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
||||
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()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
|
||||
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
|
||||
staying quiet when the page goes away mid-reply, which on Windows is
|
||||
ConnectionAbortedError (WinError 10053).
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import email.message
|
||||
import io
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
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_host # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def platform(name):
|
||||
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
|
||||
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
|
||||
LINUX=name == "linux")
|
||||
|
||||
|
||||
class OpenRdp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.xrdp = socket.socket()
|
||||
self.xrdp.bind(("127.0.0.1", 0))
|
||||
self.xrdp.listen(4)
|
||||
self.addCleanup(self.xrdp.close)
|
||||
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
|
||||
port.start()
|
||||
self.addCleanup(port.stop)
|
||||
self.spawned = []
|
||||
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
|
||||
spawn.start()
|
||||
self.addCleanup(spawn.stop)
|
||||
cache = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(cache.cleanup)
|
||||
self.cache = Path(cache.name)
|
||||
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
|
||||
where.start()
|
||||
self.addCleanup(where.stop)
|
||||
|
||||
def test_windows_signs_in_as_steamos(self):
|
||||
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
|
||||
with platform("windows"):
|
||||
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertEqual(len(self.spawned), 1)
|
||||
argv = self.spawned[0]
|
||||
self.assertEqual(argv[0], "mstsc.exe")
|
||||
self.assertNotIn("/v:127.0.0.1", argv)
|
||||
rdp = Path(argv[1])
|
||||
self.assertEqual(rdp.suffix, ".rdp")
|
||||
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
|
||||
self.assertNotIn(b"\r\r", data)
|
||||
lines = data.decode("utf-8").split("\r\n")
|
||||
self.assertIn("full address:s:127.0.0.1", lines)
|
||||
self.assertIn("username:s:steamos", lines)
|
||||
self.assertIn("steamos", message)
|
||||
self.assertIn("Developer Mode password", message)
|
||||
self.assertIn("certificate", message)
|
||||
self.assertIn("Connect", message)
|
||||
|
||||
def test_nothing_listening_says_why_and_opens_nothing(self):
|
||||
self.xrdp.close()
|
||||
for name in ("windows", "mac", "linux"):
|
||||
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
|
||||
frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertIn("Developer Mode", str(cm.exception))
|
||||
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
|
||||
self.assertEqual(self.spawned, [])
|
||||
|
||||
def test_says_which_way_it_failed(self):
|
||||
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
|
||||
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
|
||||
(socket.timeout("timed out"), "didn't answer"),
|
||||
(OSError(65, "No route to host"), "didn't answer")):
|
||||
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
|
||||
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
|
||||
frame_host.open_rdp("frame", "frame.local")
|
||||
self.assertIn(says, str(cm.exception))
|
||||
self.assertNotIn("refused", str(cm.exception))
|
||||
self.assertEqual(self.spawned, [])
|
||||
|
||||
def test_server_says_it_as_the_persons_to_fix(self):
|
||||
# A 400 with the message, not a 500 filed as an error diagnostic.
|
||||
self.xrdp.close()
|
||||
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
|
||||
self.assertRaises(server.Failure) as cm:
|
||||
server.open_thing({"what": "rdp"})
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
self.assertIn("Developer Mode", str(cm.exception))
|
||||
|
||||
def test_one_file_per_address(self):
|
||||
with platform("windows"):
|
||||
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
|
||||
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
|
||||
self.assertEqual(len({a, b, c, d}), 4)
|
||||
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
|
||||
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
|
||||
|
||||
def test_address_cant_add_lines_to_the_file(self):
|
||||
with platform("windows"), self.assertRaises(frame_host.HostError):
|
||||
frame_host.rdp_file("frame\r\nusername:s:root")
|
||||
self.assertEqual(list(self.cache.iterdir()), [])
|
||||
|
||||
def test_linux_clients_get_the_user(self):
|
||||
with platform("linux"), mock.patch.object(frame_host, "which",
|
||||
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
|
||||
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
|
||||
self.assertIn("steamos", message)
|
||||
|
||||
|
||||
class PageGoneAway(unittest.TestCase):
|
||||
"""The report's server log: the page closed while index.html was being sent, and the
|
||||
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
|
||||
|
||||
def handler(self, path="/"):
|
||||
h = server.Handler.__new__(server.Handler)
|
||||
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
|
||||
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
|
||||
h.headers = email.message.Message()
|
||||
h.headers["Host"] = "127.0.0.1:1"
|
||||
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
|
||||
h.close_connection = True
|
||||
return h
|
||||
|
||||
def test_not_a_server_error(self):
|
||||
h = self.handler()
|
||||
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
|
||||
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
|
||||
h.do_GET()
|
||||
diagnostic.assert_not_called()
|
||||
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
|
||||
|
||||
def test_server_logs_nothing(self):
|
||||
srv = server.LoopbackServer.__new__(server.LoopbackServer)
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(sys, "stderr", err):
|
||||
try:
|
||||
raise server.ClientGone()
|
||||
except server.ClientGone:
|
||||
srv.handle_error(None, ("127.0.0.1", 1))
|
||||
self.assertEqual(err.getvalue(), "")
|
||||
try:
|
||||
raise RuntimeError("real")
|
||||
except RuntimeError:
|
||||
srv.handle_error(None, ("127.0.0.1", 1))
|
||||
self.assertIn("RuntimeError: real", err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+61
-1
@@ -34,7 +34,9 @@ class ServerGuards(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.port = free_port()
|
||||
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up
|
||||
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
|
||||
"FRAME_CONTROL_SSH_DIR": cls.ssh_dir}
|
||||
cls.log = tempfile.TemporaryFile()
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
|
||||
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
|
||||
@@ -109,6 +111,8 @@ class ServerGuards(unittest.TestCase):
|
||||
("/api/volume", {"level": 1.5}),
|
||||
("/api/clipboard", {"text": ""}),
|
||||
("/api/open", {"what": "anything-else"}),
|
||||
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
|
||||
("/api/open", {"what": "shot"}),
|
||||
("/api/shots/save", {"ids": []}),
|
||||
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||
("/api/shots/save", {"ids": [1]}),
|
||||
@@ -119,6 +123,10 @@ class ServerGuards(unittest.TestCase):
|
||||
status, payload = self.post(path, body)
|
||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||
|
||||
def test_showing_a_shot_needs_it_saved_here(self):
|
||||
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
|
||||
self.assertEqual(status, 404, payload)
|
||||
|
||||
def test_screenshot_ids_checked_before_ssh(self):
|
||||
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
|
||||
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||
@@ -238,6 +246,58 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||
|
||||
|
||||
class OneServer(unittest.TestCase):
|
||||
"""Two servers for one user would each connect and edit headsets on their own."""
|
||||
|
||||
def start(self, env):
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close()))
|
||||
return proc
|
||||
|
||||
def test_a_second_server_is_refused_until_the_first_exits(self):
|
||||
data = tempfile.mkdtemp(prefix="frame-one-server-")
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
|
||||
"FRAME_CONTROL_SERVER_WAIT": "1"}
|
||||
first = self.start(env)
|
||||
self.assertIn("Frame Control on", first.stdout.readline())
|
||||
second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(second.returncode, 1)
|
||||
self.assertIn("already running", second.stderr)
|
||||
first.terminate()
|
||||
first.wait(10)
|
||||
self.assertIn("Frame Control on", self.start(env).stdout.readline())
|
||||
|
||||
def test_a_private_server_runs_alongside_but_cant_change_headsets(self):
|
||||
"""The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs."""
|
||||
data = tempfile.mkdtemp(prefix="frame-one-server-")
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
|
||||
"FRAME_CONTROL_SERVER_WAIT": "1"}
|
||||
self.assertIn("Frame Control on", self.start(env).stdout.readline())
|
||||
private = self.start({**env, "FRAME_PRIVATE_SSH": "1"})
|
||||
line = private.stdout.readline()
|
||||
self.assertIn("Frame Control on", line)
|
||||
port = int(line.split("http://127.0.0.1:")[1].split()[0])
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}),
|
||||
headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"})
|
||||
r = conn.getresponse()
|
||||
self.assertEqual(r.status, 403, r.read())
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
|
||||
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
|
||||
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
|
||||
"FRAME_ALIAS": "frame-control-test.invalid"}
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
|
||||
self.assertIn("Frame Control on", proc.stdout.readline())
|
||||
proc.terminate()
|
||||
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
|
||||
|
||||
|
||||
class ArtworkSettings(unittest.TestCase):
|
||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||
|
||||
|
||||
@@ -391,14 +391,16 @@ class ReportProblem(Base):
|
||||
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
||||
got = self.serve()
|
||||
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"contact": "me@example.com"})
|
||||
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
|
||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"contact": "me@example.com", "contactFollowup": True})
|
||||
path, body = got[0]
|
||||
event = body["batch"][0]
|
||||
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
||||
props = event["properties"]
|
||||
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
|
||||
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
|
||||
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
|
||||
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
|
||||
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
|
||||
self.assertIn(res["id"], res["message"])
|
||||
@@ -421,8 +423,9 @@ class ReportProblem(Base):
|
||||
|
||||
def test_the_inbox_skips_malformed_reports(self):
|
||||
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
||||
"0.4.0", "macOS", "", ""]
|
||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
|
||||
"0.4.0", "macOS", "", "", None, None, None]
|
||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
|
||||
["short"], good]
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
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_host # noqa: E402
|
||||
import frame_devices as fd # noqa: E402
|
||||
|
||||
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
|
||||
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
|
||||
|
||||
|
||||
def ran(*results):
|
||||
"""subprocess.run stand-in answering whoami, then icacls."""
|
||||
calls = []
|
||||
|
||||
def run(argv, **kw):
|
||||
calls.append(argv)
|
||||
return results[len(calls) - 1]
|
||||
return run, calls
|
||||
|
||||
|
||||
class MakePrivate(unittest.TestCase):
|
||||
def test_windows_sets_owner_only_acl_by_sid(self):
|
||||
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
|
||||
subprocess.CompletedProcess([], 0))
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
|
||||
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
|
||||
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
|
||||
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
|
||||
|
||||
def test_windows_falls_back_to_username_and_reports_failure(self):
|
||||
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
|
||||
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
|
||||
self.assertFalse(frame_host.make_private(Path("config")))
|
||||
self.assertIn("bob:F", calls[1])
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "POSIX modes")
|
||||
def test_posix_chmods_600(self):
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
os.chmod(f.name, 0o644)
|
||||
self.assertTrue(frame_host.make_private(f.name))
|
||||
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
|
||||
|
||||
|
||||
class ConfigWrites(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||
self.config = self.ssh / "config"
|
||||
|
||||
def test_devices_and_connect_writes_make_the_file_private(self):
|
||||
import frame_connect as fc
|
||||
self.config.write_text("Host other\n User me\n", encoding="utf-8")
|
||||
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
|
||||
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
|
||||
fc.write_config("10.0.0.5")
|
||||
self.assertTrue(fd.repair_permissions(self.config))
|
||||
fd.rewrite_block("frame", path=self.config, user="deck")
|
||||
self.assertEqual(private.call_count, 3)
|
||||
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
|
||||
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
|
||||
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
|
||||
|
||||
def test_setup_runs_isolated_as_the_app_starts_it(self):
|
||||
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
|
||||
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
|
||||
self.assertNotIn("ModuleNotFoundError", r.stderr)
|
||||
self.assertIn("frame_connect.py", r.stdout + r.stderr)
|
||||
|
||||
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
|
||||
self.assertFalse(fd.repair_permissions(self.config))
|
||||
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
|
||||
self.config.write_bytes(data)
|
||||
with mock.patch.object(frame_host, "make_private", return_value=True):
|
||||
self.assertTrue(fd.repair_permissions(self.config))
|
||||
self.assertEqual(self.config.read_bytes(), data)
|
||||
|
||||
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
|
||||
self.config.write_bytes(b"Host a\n")
|
||||
before = self.config.stat().st_ino
|
||||
with mock.patch.object(frame_host, "make_private", return_value=False):
|
||||
self.assertFalse(fd.repair_permissions(self.config))
|
||||
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
|
||||
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
|
||||
|
||||
|
||||
class ServerRepair(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.server = server
|
||||
|
||||
def setUp(self):
|
||||
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
|
||||
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
|
||||
mock.patch.object(frame_host, "WINDOWS", True),
|
||||
mock.patch.object(self.server, "_config_repaired", False)]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_repairs_the_refused_config_once(self):
|
||||
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
|
||||
self.assertTrue(self.server.repair_ssh_config(REFUSED))
|
||||
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||
repair.assert_called_once()
|
||||
|
||||
def test_leaves_other_files_and_errors_alone(self):
|
||||
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
|
||||
with mock.patch.object(fd, "repair_permissions") as repair:
|
||||
self.assertFalse(self.server.repair_ssh_config(key))
|
||||
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
|
||||
with mock.patch.object(frame_host, "WINDOWS", False):
|
||||
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||
repair.assert_not_called()
|
||||
|
||||
def test_ssh_retries_after_repairing(self):
|
||||
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
|
||||
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
|
||||
mock.patch.object(fd, "repair_permissions", return_value=True), \
|
||||
mock.patch.object(self.server, "LINK", None):
|
||||
self.assertEqual(self.server.ssh("true"), "ok")
|
||||
|
||||
|
||||
class LinkLocalZone(unittest.TestCase):
|
||||
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
|
||||
|
||||
def probe(self, windows):
|
||||
import frame_link as fl
|
||||
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
|
||||
sock = mock.MagicMock()
|
||||
with mock.patch.object(frame_host, "WINDOWS", windows), \
|
||||
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
|
||||
mock.patch.object(fl.socket, "socket", return_value=sock), \
|
||||
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
|
||||
return fl.probe("frame.local", 22)["ip"]
|
||||
|
||||
def test_windows_uses_the_numeric_zone(self):
|
||||
self.assertEqual(self.probe(True), "fe80::1%12")
|
||||
|
||||
def test_elsewhere_uses_the_interface_name(self):
|
||||
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+6
-1
@@ -1,7 +1,9 @@
|
||||
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -129,7 +131,10 @@ def run_script(server, name, args):
|
||||
script = server.HERE.parent / 'scripts' / name
|
||||
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
|
||||
raise ValueError(name + ' requires a computer with zsh and the matching script installed')
|
||||
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60)
|
||||
# The headset the server is routed to, not whatever `frame` means in ~/.ssh/config.
|
||||
env = {**os.environ, 'FRAME_ALIAS': server.FRAME,
|
||||
'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])}
|
||||
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env)
|
||||
if result.returncode:
|
||||
raise ValueError(result.stderr.strip() or 'Script failed')
|
||||
return {'message': result.stdout.strip()}
|
||||
|
||||
+3
-3
@@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120):
|
||||
try:
|
||||
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
||||
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'timed out talking to {FRAME}')
|
||||
if p.returncode != 0:
|
||||
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
|
||||
else:
|
||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'copying {name} to the Frame timed out')
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -11,16 +11,17 @@ import tempfile
|
||||
import uuid
|
||||
|
||||
import frame_android as android
|
||||
import frame_host
|
||||
|
||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||
|
||||
|
||||
def _stream(command, src=None, dst=None):
|
||||
try:
|
||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise android.FrameError('app-data transfer timed out')
|
||||
except OSError as error:
|
||||
|
||||
+58
-9
@@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of
|
||||
scripts/connect.sh (which the Mac app uses); same config block, so either can
|
||||
re-run over the other. Idempotent.
|
||||
|
||||
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
|
||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
||||
Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
|
||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for
|
||||
terminals that don't pass the environment on, like Windows' `start`)
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
@@ -23,6 +24,10 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# The app runs this with python -I, which leaves the script's folder off sys.path.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_host # noqa: E402
|
||||
|
||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||
@@ -283,10 +288,40 @@ def config_block(host, port=22, user=FRAME_USER):
|
||||
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
|
||||
|
||||
|
||||
class config_lock:
|
||||
"""The lock Frame Control takes to edit ~/.ssh/config (frame_devices.file_lock), so a
|
||||
running app and this setup never write over each other's change."""
|
||||
|
||||
def __enter__(self):
|
||||
self.fh = open(SSH_DIR / "config.frame-control.lock", "a+")
|
||||
for _ in range(300):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
self.fh.seek(0)
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.lockf(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return self
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
return self # 30 s: go ahead rather than fail the setup
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.fh.close() # closing releases the lock
|
||||
return False
|
||||
|
||||
|
||||
def write_config(host, port=22, user=FRAME_USER):
|
||||
make_ssh_dir()
|
||||
with config_lock():
|
||||
_write_config(host, port, user)
|
||||
|
||||
|
||||
def _write_config(host, port, user):
|
||||
"""Replace our managed block and put it first: ssh uses the first value it sees per
|
||||
option. The trailing "Host *" returns the rest of the file to global scope."""
|
||||
make_ssh_dir()
|
||||
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
||||
kept, skip = [], False
|
||||
for line in old.splitlines():
|
||||
@@ -297,10 +332,11 @@ def write_config(host, port=22, user=FRAME_USER):
|
||||
elif not skip:
|
||||
kept.append(line)
|
||||
block = config_block(host, port, user)
|
||||
tmp = CONFIG.with_name("config.frame-control.tmp")
|
||||
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
tmp.chmod(0o600)
|
||||
if not frame_host.make_private(tmp):
|
||||
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", "
|
||||
"Frame Control repairs it when it next connects")
|
||||
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
||||
# and locked, so the swap can fail for a moment; keep trying for a while.
|
||||
for attempt in range(60):
|
||||
@@ -318,9 +354,9 @@ def write_config(host, port=22, user=FRAME_USER):
|
||||
|
||||
def key_login_works():
|
||||
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def configured_user():
|
||||
@@ -367,9 +403,22 @@ def pair_with_devkit(host, port, user):
|
||||
return chosen[0], "paired, but key login still fails"
|
||||
|
||||
|
||||
def use_alias(alias):
|
||||
"""--alias: set up another headset under its own ~/.ssh/config alias (Devices tab)."""
|
||||
global FRAME_ALIAS, BEGIN, END
|
||||
if not NAME_RE.fullmatch(alias):
|
||||
sys.exit(f"--alias must be a plain name, not {alias!r}")
|
||||
FRAME_ALIAS = alias
|
||||
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
||||
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
||||
|
||||
|
||||
def main(argv):
|
||||
if argv and argv[0] in ("-h", "--help"):
|
||||
sys.exit(__doc__)
|
||||
if len(argv) >= 2 and argv[0] == "--alias":
|
||||
use_alias(argv[1])
|
||||
argv = argv[2:]
|
||||
say("==> Looking for the Steam Frame")
|
||||
found = pick_host(argv[0] if argv else None)
|
||||
while not found:
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""An email address the person chooses to leave, and what it may be used for. Python stdlib only.
|
||||
|
||||
Two separate opt-in choices, both off until ticked:
|
||||
|
||||
- updates: occasional notices about Frame Control releases and updates
|
||||
- followup: the maintainer may ask follow-up questions, mainly about problem reports
|
||||
|
||||
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
|
||||
sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way
|
||||
as problem reports (frame_report.py), so only the maintainer can read them. Every change
|
||||
sends a new event under this copy's own random contact id (not the analytics id), numbered
|
||||
by `rev`, and the highest rev for an id is the one that counts, whatever the clocks say:
|
||||
removing the address sends a withdrawal with no address in it, and wipes the address from
|
||||
the local log of what was sent. The maintainer lists who agreed to what with
|
||||
`python3 ui/frame_report.py contacts`. Nothing here sends email.
|
||||
|
||||
A change that can't be sent (offline) waits in the state file and is retried in the
|
||||
background, so a withdrawal is never lost. The page's one-time prompt is remembered here
|
||||
too: once it has been shown or dismissed it never comes back.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
STATE = frame_host.data_dir('contact')
|
||||
FILE = STATE / 'contact.json'
|
||||
EMAIL_MAX = 254
|
||||
EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+')
|
||||
PROMPTS = ('new', 'shown', 'dismissed', 'answered')
|
||||
RETRY_EVERY = 600
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order
|
||||
_removed = {} # address (lower case) -> when it was removed, for reports still being sent then
|
||||
_wake = threading.Event()
|
||||
_retrier = None
|
||||
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
|
||||
'prompt': 'new', 'pending': None, 'rev': 0}
|
||||
|
||||
|
||||
def load():
|
||||
with _lock:
|
||||
s = _defaults()
|
||||
try:
|
||||
with open(FILE) as f:
|
||||
saved = json.load(f)
|
||||
if isinstance(saved, dict):
|
||||
s.update({k: v for k, v in saved.items() if k in s})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def _save(s):
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = FILE.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(s, indent=1))
|
||||
os.replace(tmp, FILE)
|
||||
|
||||
|
||||
def valid_email(email):
|
||||
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
|
||||
|
||||
|
||||
def flag(body, key):
|
||||
"""A consent choice: true only when it really is true (not "false" or 1), left out is no."""
|
||||
v = body.get(key)
|
||||
if v is not None and not isinstance(v, bool):
|
||||
raise ValueError(f'{key} must be true or false')
|
||||
return v is True
|
||||
|
||||
|
||||
def from_report(email):
|
||||
"""Follow-up questions agreed to with a problem report: the address becomes the contact
|
||||
email with that choice ticked, so it shows in Settings and is removed the same way. Update
|
||||
notices stay on only for the same address: a different one replaces the old address with
|
||||
follow-up questions only (the report form says so before sending). Returns (contact id,
|
||||
rev) for the report to carry, read together with the change itself: a later change from
|
||||
this copy has a higher rev, and the newest such change decides whether the report's
|
||||
follow-up permission still stands, whatever the clocks say."""
|
||||
with _lock:
|
||||
s = load()
|
||||
same = s['email'].lower() == email.lower()
|
||||
changed, cid, rev = _apply({'email': s['email'] if same else email,
|
||||
'updates': s['updates'] and same, 'followup': True})
|
||||
_deliver(changed)
|
||||
return cid, rev
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
|
||||
and the Frame has connected at least once (setup worked), so it never greets a new install."""
|
||||
s = load()
|
||||
set_up = bool(frame_telemetry.settings().get('frames_seen'))
|
||||
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
|
||||
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
|
||||
|
||||
|
||||
def _event(s):
|
||||
email = s['email'] if s['updates'] or s['followup'] else ''
|
||||
return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']),
|
||||
'followup': bool(email and s['followup']),
|
||||
'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
|
||||
|
||||
|
||||
def _send_pending(block=True):
|
||||
"""Send what's waiting, including changes made while sending. True if nothing is left
|
||||
waiting. Without block, a send already under way is left to pick up the newest change."""
|
||||
if not _send_lock.acquire(blocking=block):
|
||||
return False
|
||||
try:
|
||||
while True:
|
||||
with _lock:
|
||||
event = load()['pending']
|
||||
if event is None:
|
||||
break
|
||||
try:
|
||||
frame_telemetry.post([event], timeout=30)
|
||||
except frame_telemetry.SendError:
|
||||
return False
|
||||
_sent(event)
|
||||
finally:
|
||||
_send_lock.release()
|
||||
# A change saved just as this finished found the lock still held and left it to us.
|
||||
with _lock:
|
||||
left = load()['pending'] is not None
|
||||
return _send_pending(block=False) if left else True
|
||||
|
||||
|
||||
def _sent(event):
|
||||
with _lock:
|
||||
s = load()
|
||||
if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile
|
||||
s['pending'] = None
|
||||
_save(s)
|
||||
# A withdrawal, or the address still in use: not an old one removed while this was on its way.
|
||||
if event['properties']['email'] in ('', s['email']):
|
||||
try:
|
||||
frame_telemetry.record_sent([event])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _forget_locally(email):
|
||||
"""Take a removed address out of the log of what was sent (contact events and reports)."""
|
||||
with frame_telemetry._lock:
|
||||
_removed[email.lower()] = time.time()
|
||||
rows = frame_telemetry._read_lines(frame_telemetry.SENT)
|
||||
hit = False
|
||||
for e in rows:
|
||||
p = e.get('properties') or {}
|
||||
for k in ('email', 'contact'):
|
||||
if p.get(k) and str(p[k]).strip().lower() == email.lower():
|
||||
p[k], hit = '<removed>', True
|
||||
if hit:
|
||||
frame_telemetry._write_lines(frame_telemetry.SENT, rows)
|
||||
|
||||
|
||||
def redact_removed(event, started):
|
||||
"""Before logging a report (started at time.time() `started`) whose address was removed
|
||||
while it was being sent: take the address out. Call with frame_telemetry._lock held, so a
|
||||
removal can't slip between this and the log."""
|
||||
p = event.get('properties') or {}
|
||||
removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
|
||||
if removed_at is not None and started <= removed_at:
|
||||
p['contact'] = '<removed>'
|
||||
|
||||
|
||||
def save(body):
|
||||
"""Set, change or remove the address and the two choices. An address needs at least one
|
||||
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
|
||||
_deliver(_apply(body)[0])
|
||||
return state()
|
||||
|
||||
|
||||
def _apply(body):
|
||||
"""save()'s change, kept here and waiting to send. Returns (changed, contact id, rev)."""
|
||||
email = str(body.get('email') or '').strip()
|
||||
updates, followup = flag(body, 'updates'), flag(body, 'followup')
|
||||
if email and not valid_email(email):
|
||||
raise ValueError("that doesn't look like an email address")
|
||||
if email and not (updates or followup):
|
||||
raise ValueError('tick what the address may be used for, or remove it')
|
||||
if not email:
|
||||
updates = followup = False
|
||||
with _lock:
|
||||
s = load()
|
||||
old = s['email']
|
||||
changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
|
||||
s.update(email=email, updates=updates, followup=followup)
|
||||
if body.get('fromPrompt') or email:
|
||||
s['prompt'] = 'answered'
|
||||
if changed:
|
||||
# Only the newest choice matters, so it replaces anything still waiting. A withdrawal
|
||||
# is sent even for an address still waiting here: its send may already be under way.
|
||||
s['rev'] += 1
|
||||
s['pending'] = _event(s)
|
||||
_save(s)
|
||||
if old and old.lower() != email.lower():
|
||||
try:
|
||||
_forget_locally(old)
|
||||
except OSError:
|
||||
pass
|
||||
return changed, s['id'], s['rev']
|
||||
|
||||
|
||||
def _deliver(changed):
|
||||
if changed and not _send_pending(block=False):
|
||||
_wake.set() # offline, or a send under way that will take this change with it
|
||||
|
||||
|
||||
def prompt(body):
|
||||
"""The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone."""
|
||||
action = body.get('prompt')
|
||||
if action not in ('shown', 'dismissed'):
|
||||
raise ValueError('unknown prompt action')
|
||||
with _lock:
|
||||
s = load()
|
||||
if s['prompt'] in ('new', 'shown'):
|
||||
s['prompt'] = action
|
||||
_save(s)
|
||||
return state()
|
||||
|
||||
|
||||
def start():
|
||||
"""Retry a change that couldn't be sent, from now on in the background."""
|
||||
global _retrier
|
||||
if _retrier:
|
||||
return
|
||||
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
_send_pending()
|
||||
except Exception:
|
||||
pass
|
||||
_wake.wait(RETRY_EVERY)
|
||||
_wake.clear()
|
||||
|
||||
_retrier = threading.Thread(target=loop, name='contact', daemon=True)
|
||||
_retrier.start()
|
||||
@@ -0,0 +1,832 @@
|
||||
"""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)
|
||||
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
|
||||
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 repair_permissions(path=None):
|
||||
"""Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
|
||||
file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
|
||||
got that ACL and replaced the file."""
|
||||
path = Path(path or ssh_config())
|
||||
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
|
||||
tmp = Path(tmp)
|
||||
try:
|
||||
with os.fdopen(fd_, "wb") as fh:
|
||||
fh.write(data)
|
||||
if not frame_host.make_private(tmp):
|
||||
return False
|
||||
for attempt in range(20): # a running ssh.exe can hold the file for a moment
|
||||
if path.read_bytes() != data:
|
||||
return False
|
||||
try:
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except PermissionError:
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
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 = frame_host.run_ssh(["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 frame_host.run_ssh(["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
|
||||
+152
-13
@@ -5,12 +5,16 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
|
||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MAC = sys.platform == "darwin"
|
||||
@@ -32,6 +36,45 @@ class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Unreachable(HostError):
|
||||
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
|
||||
|
||||
|
||||
def run_ssh(argv, **kwargs):
|
||||
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
||||
|
||||
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
|
||||
while keeping subprocess.run's captured output, text, check and timeout API.
|
||||
"""
|
||||
if not WINDOWS:
|
||||
return subprocess.run(argv, **kwargs)
|
||||
if kwargs.pop("capture_output", False):
|
||||
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
|
||||
raise ValueError("stdout and stderr arguments may not be used with capture_output")
|
||||
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if kwargs.get("stderr") != subprocess.PIPE:
|
||||
return subprocess.run(argv, **kwargs)
|
||||
check = kwargs.pop("check", False)
|
||||
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
|
||||
with tempfile.TemporaryFile() as stderr:
|
||||
kwargs["stderr"] = stderr
|
||||
try:
|
||||
result = subprocess.run(argv, **kwargs)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
stderr.seek(0)
|
||||
error.stderr = stderr.read()
|
||||
raise
|
||||
stderr.seek(0)
|
||||
if text:
|
||||
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
|
||||
result.stderr = reader.read()
|
||||
else:
|
||||
result.stderr = stderr.read()
|
||||
if check:
|
||||
result.check_returncode()
|
||||
return result
|
||||
|
||||
|
||||
def data_dir(*parts):
|
||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
||||
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
||||
@@ -56,13 +99,19 @@ def cache_dir(*parts):
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
def control_path(*, private=False):
|
||||
def control_path(tag="x", *, private=None):
|
||||
"""ssh ControlPath for the shared connection, or None where it isn't supported.
|
||||
|
||||
`tag` names the headset: ssh's %C hashes only the address, user and port, so two
|
||||
headsets reached at the same address (one of them moved) would otherwise share a
|
||||
connection, and one's commands would run on the other.
|
||||
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||
"""
|
||||
# A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1.
|
||||
if private is None:
|
||||
private = os.environ.get("FRAME_PRIVATE_SSH") == "1"
|
||||
suffix = f"-{os.getpid()}" if private else ""
|
||||
return f"/tmp/frame-ui-{os.getuid()}{suffix}-%C" if MUX else None
|
||||
return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None
|
||||
|
||||
|
||||
def which(name, *extra):
|
||||
@@ -147,6 +196,19 @@ def open_path(path):
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
def reveal_path(path):
|
||||
"""Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
|
||||
path = Path(path)
|
||||
if MAC:
|
||||
cmd = ["open", "-R", str(path)]
|
||||
elif WINDOWS:
|
||||
cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
|
||||
else:
|
||||
return open_path(path.parent)
|
||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
open_url = open_path # the same openers hand URLs to the default browser
|
||||
|
||||
|
||||
@@ -222,10 +284,46 @@ def clipboard_text():
|
||||
raise HostError("Can't read the clipboard")
|
||||
|
||||
|
||||
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
|
||||
BAD_PERMISSIONS = "Bad owner or permissions on "
|
||||
|
||||
|
||||
def make_private(path):
|
||||
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
|
||||
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
|
||||
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
|
||||
refuses it if that grants anyone else, even an account deleted long ago
|
||||
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
|
||||
if not WINDOWS:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
me = os.environ.get("USERNAME", "")
|
||||
try: # "desktop\me","S-1-5-21-..."
|
||||
# Bytes: the account name is in the console's code page, the SID is ASCII.
|
||||
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
|
||||
if sid.startswith("S-1-"):
|
||||
me = "*" + sid
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
if not me:
|
||||
return False
|
||||
try:
|
||||
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
|
||||
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def ssh_hostname(alias):
|
||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return alias
|
||||
for line in out.splitlines():
|
||||
@@ -258,24 +356,65 @@ def open_steam_link():
|
||||
return "Steam Link isn't installed; opened its download page"
|
||||
|
||||
|
||||
def open_rdp(alias):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos)."""
|
||||
host = ssh_hostname(alias)
|
||||
RDP_PORT = 3389
|
||||
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
|
||||
# xrdp's certificate is its own, so every client warns about it first.
|
||||
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
|
||||
"with your Developer Mode password")
|
||||
|
||||
|
||||
def check_rdp(host, timeout=3):
|
||||
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
|
||||
try:
|
||||
with socket.create_connection((host, RDP_PORT), timeout=timeout):
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
|
||||
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
|
||||
"then restart it and try again.") from None
|
||||
except socket.gaierror:
|
||||
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
|
||||
"address on the Devices tab.") from None
|
||||
except OSError as e:
|
||||
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
|
||||
"switched off or on another network; if it's on, check Developer Mode is on "
|
||||
"in Steam Settings > System.") from None
|
||||
|
||||
|
||||
def rdp_file(host):
|
||||
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
|
||||
computer's Windows account, which xrdp turns away; the file names steamos instead."""
|
||||
if any(c in host for c in "\r\n"):
|
||||
raise HostError("That headset address can't be used for remote desktop")
|
||||
# One file per address, so two launches close together can't swap headsets.
|
||||
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
|
||||
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
|
||||
return path
|
||||
|
||||
|
||||
def open_rdp(alias, host=None):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||
host = host or ssh_hostname(alias)
|
||||
# The client would open either way and then fail on its own, with nothing said here.
|
||||
check_rdp(host)
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||
return "Opened Windows App"
|
||||
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
|
||||
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||
return "Windows App isn't installed; opened its App Store page"
|
||||
if WINDOWS:
|
||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
||||
return f"Opened Remote Desktop to {host}"
|
||||
_spawn(["mstsc.exe", str(rdp_file(host))])
|
||||
# Windows asks about the unsigned connection file first.
|
||||
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
|
||||
if which("remmina"):
|
||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
||||
return f"Opened Remmina to {host}"
|
||||
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
|
||||
return f"Opened Remmina to {host}: {RDP_LOGIN}"
|
||||
for name in ("xfreerdp3", "xfreerdp"):
|
||||
if which(name):
|
||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}"
|
||||
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
|
||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||
|
||||
|
||||
|
||||
+1233
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.run = run
|
||||
self.frame = frame
|
||||
self.host_opts = [] # the headset in use and how to reach it (see retarget)
|
||||
# Short, never held across ssh: retarget() and publishing a new tunnel check and
|
||||
# change the headset together (self.lock is held while a tunnel is being opened).
|
||||
self.route_lock = threading.Lock()
|
||||
self.track = track or (lambda proc: None) # the server ends these on exit
|
||||
self.lock = threading.Lock()
|
||||
self.token = secrets.token_urlsafe(24)
|
||||
@@ -239,13 +243,37 @@ class MacView:
|
||||
last = self._last_tunnel_error
|
||||
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
|
||||
|
||||
def retarget(self, alias, host_opts):
|
||||
"""The server now reaches the headset as `alias` with `host_opts` (another address,
|
||||
or another headset). Only the short route_lock, never self.lock: this runs while
|
||||
the server routes, which a tunnel being opened may be waiting on."""
|
||||
# host_opts is "-o", "Name=value" pairs; the tunnel keeps its own connection,
|
||||
# not the shared master.
|
||||
opts = [x for flag, value in zip(host_opts[::2], host_opts[1::2])
|
||||
if not value.startswith("ControlPath=") for x in (flag, value)]
|
||||
with self.route_lock:
|
||||
moved = alias != self.frame
|
||||
self.frame, self.host_opts = alias, opts
|
||||
tunnel = self.tunnel
|
||||
if moved and tunnel is not None:
|
||||
# Another headset: its viewers can't be the old one's. The supervisor
|
||||
# reopens a tunnel to the new one if anything is being shown.
|
||||
self.tunnel, self.remote_port = None, None
|
||||
if moved and tunnel is not None and tunnel.poll() is None:
|
||||
tunnel.terminate()
|
||||
|
||||
def _open_tunnel(self, via, ports):
|
||||
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
|
||||
last = ""
|
||||
for port in ports:
|
||||
proc = subprocess.Popen([*self.tunnel_ssh, *via, "-o", "ExitOnForwardFailure=yes",
|
||||
with self.route_lock:
|
||||
target = (self.frame, self.host_opts) # retarget() may change these meanwhile
|
||||
# `via` first: ssh keeps the first value of an option, so USB-C's HostName wins
|
||||
# while the headset's pinned identity (in host_opts) still checks it.
|
||||
proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1],
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
|
||||
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame],
|
||||
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
# A taken port makes ssh exit once it's connected; a working
|
||||
@@ -259,7 +287,11 @@ class MacView:
|
||||
ok = True
|
||||
break
|
||||
if ok:
|
||||
self.tunnel, self.remote_port = proc, port
|
||||
with self.route_lock: # checked and published together, so a switch can't slip between
|
||||
ok = target[0] == self.frame # else the app switched headset while this connected
|
||||
if ok:
|
||||
self.tunnel, self.remote_port = proc, port
|
||||
if ok:
|
||||
self.track(proc)
|
||||
self._supervise()
|
||||
return True
|
||||
@@ -294,6 +326,8 @@ class MacView:
|
||||
socket.create_connection((ip, 22), timeout=1).close()
|
||||
except OSError:
|
||||
return [] # not plugged into this Mac
|
||||
if any(o.startswith("HostKeyAlias=") for o in self.host_opts):
|
||||
return ["-o", f"HostName={ip}"] # checked against the headset's own pinned key
|
||||
alias = self.frame
|
||||
try:
|
||||
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
|
||||
|
||||
+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:
|
||||
def __init__(self):
|
||||
self.handles = []
|
||||
@@ -53,6 +62,8 @@ class Overlay:
|
||||
def call(self, name, *values):
|
||||
slot, args = SLOTS[name]
|
||||
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:
|
||||
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
|
||||
|
||||
@@ -75,11 +86,14 @@ class Overlay:
|
||||
self.call('ShowOverlay', handle)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
for h in reversed(self.handles):
|
||||
# Best effort: SteamVR removes a disconnected client's overlays anyway,
|
||||
# and a teardown error must not overwrite a finished playback's status.
|
||||
for h in reversed(self.handles):
|
||||
try:
|
||||
self.call('DestroyOverlay', h)
|
||||
finally:
|
||||
self.vr.VR_ShutdownInternal()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.vr.VR_ShutdownInternal()
|
||||
|
||||
|
||||
def probe(path):
|
||||
@@ -114,6 +128,11 @@ def decoder_command(path, info, width, height, audio, photo=False):
|
||||
return cmd
|
||||
|
||||
|
||||
def ignore_signals():
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
|
||||
def write_status(path, **values):
|
||||
tmp = path.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(values))
|
||||
@@ -135,6 +154,46 @@ def play(args):
|
||||
photo = plan['kind'] == 'photo'
|
||||
command = decoder_command(path, info, width, height, audio, photo)
|
||||
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
|
||||
# ownership; no unrelated Steam/SteamVR process or setting is touched.
|
||||
def stop(signum, frame):
|
||||
@@ -146,14 +205,13 @@ def play(args):
|
||||
if args.theatre:
|
||||
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
|
||||
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,
|
||||
plan['layout'] != 'mono', aspect)
|
||||
if splat:
|
||||
vr.pixels(screen, data, width, height)
|
||||
write_status(status, state='playing', file=path.name, frames=1, **plan)
|
||||
while True:
|
||||
time.sleep(1)
|
||||
hold(screen, data, width, height)
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||
video_start = time.monotonic()
|
||||
while True:
|
||||
@@ -163,11 +221,14 @@ def play(args):
|
||||
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
|
||||
if not photo:
|
||||
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
|
||||
if frames == 1 or frames % 30 == 0:
|
||||
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:
|
||||
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||
rc = proc.wait(timeout=10)
|
||||
@@ -176,12 +237,17 @@ def play(args):
|
||||
if not frames:
|
||||
raise RuntimeError('Decoder produced no frames')
|
||||
if photo:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started)
|
||||
hold(screen, *still)
|
||||
# From here on a Stop can't change the outcome; don't let it turn
|
||||
# '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:
|
||||
write_status(status, state='stopped', frames=frames)
|
||||
ignore_signals()
|
||||
write_status(status, state='stopped', frames=frames, dropped=dropped)
|
||||
finally:
|
||||
ignore_signals()
|
||||
if proc:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
@@ -88,7 +88,7 @@ def run(body):
|
||||
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
||||
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
||||
'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'):
|
||||
command.append('--theatre')
|
||||
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
|
||||
+110
-11
@@ -6,6 +6,10 @@ project as a `problem_report` event: only the maintainer can read it, and
|
||||
nothing is published. It is sent whatever the analytics settings are, because
|
||||
the person sends it deliberately. Diagnostics are scrubbed first
|
||||
(frame_telemetry.scrub); the person's own words are sent as written.
|
||||
|
||||
An email address goes with a report only when the person ticks "may contact me with
|
||||
follow-up questions" (contact_followup). Standing choices made in Settings are
|
||||
frame_contact.py's `contact_consent` events; `contacts` lists them.
|
||||
"""
|
||||
import os
|
||||
import platform
|
||||
@@ -13,6 +17,7 @@ import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import frame_contact
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
@@ -107,9 +112,18 @@ def send(body):
|
||||
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||
title, text, diag = compose(body)
|
||||
followup = frame_contact.flag(body, 'contactFollowup')
|
||||
contact = str(body.get('contact') or '').strip() if followup else ''
|
||||
if followup and not frame_contact.valid_email(contact):
|
||||
raise ValueError('add your email address for follow-up questions, or untick that box')
|
||||
started = time.time() # a removal from now on (even while saving the address) is redacted from the log
|
||||
# It becomes the contact email in Settings, where it's changed or removed like any other.
|
||||
contact_id, contact_rev = frame_contact.from_report(contact) if followup else ('', 0)
|
||||
ref = uuid.uuid4().hex[:8].upper()
|
||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
||||
'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
|
||||
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
|
||||
# Only with an address: a later change from this copy (higher rev) can take it back.
|
||||
'contact_id': contact_id, 'contact_rev': contact_rev,
|
||||
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
|
||||
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
|
||||
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
|
||||
@@ -119,7 +133,9 @@ def send(body):
|
||||
except frame_telemetry.SendError as e:
|
||||
raise ReportError(str(e))
|
||||
try:
|
||||
frame_telemetry.record_sent([event])
|
||||
with frame_telemetry._lock: # the lock a removal holds while wiping its address
|
||||
frame_contact.redact_removed(event, started)
|
||||
frame_telemetry.record_sent([event])
|
||||
except OSError:
|
||||
pass # it was sent; failing to log it here mustn't make the person send it again
|
||||
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
|
||||
@@ -131,26 +147,109 @@ class ReportError(RuntimeError):
|
||||
|
||||
def inbox(days=30):
|
||||
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
|
||||
frame_compat_db.sync uses)."""
|
||||
frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
|
||||
questions now: 'withdrawn' when a later choice from the same copy took it back."""
|
||||
import frame_compat_db
|
||||
days = int(days)
|
||||
res = frame_compat_db._posthog_query(
|
||||
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
|
||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
|
||||
"properties.contact_followup, properties.contact_id, properties.contact_rev "
|
||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
|
||||
"ORDER BY timestamp DESC LIMIT 200")
|
||||
return res.get('results') or []
|
||||
rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
|
||||
if any(r[11] and _yes(r[10]) for r in rows):
|
||||
later = frame_compat_db._posthog_query(
|
||||
"SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0) "
|
||||
"FROM events WHERE event = 'contact_consent' LIMIT 100000")
|
||||
mark_withdrawn(rows, later.get('results') or [])
|
||||
return rows
|
||||
|
||||
|
||||
def mark_withdrawn(reports, consents):
|
||||
"""Mark reports whose follow-up permission was taken back: the newest contact choice from
|
||||
the same copy made after the report (a higher rev than it carries, not a later clock) no
|
||||
longer agrees to follow-up questions at that address."""
|
||||
newest = {}
|
||||
for c in consents:
|
||||
if not isinstance(c, list) or len(c) != 4:
|
||||
continue
|
||||
cid, email, followup, rev = c
|
||||
try:
|
||||
rev = int(rev or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if rev > newest.get(str(cid), (-1,))[0]:
|
||||
newest[str(cid)] = (rev, str(email or ''), followup)
|
||||
for r in reports:
|
||||
if not (r[11] and _yes(r[10])):
|
||||
continue
|
||||
try:
|
||||
sent_at = int(r[12] or 0)
|
||||
except (TypeError, ValueError):
|
||||
sent_at = 0
|
||||
rev, email, followup = newest.get(str(r[11]), (-1, '', None))
|
||||
if rev > sent_at and not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
|
||||
r[10] = 'withdrawn'
|
||||
|
||||
|
||||
def _yes(v):
|
||||
return v is True or str(v).lower() in ('true', '1')
|
||||
|
||||
|
||||
def contacts():
|
||||
"""{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
|
||||
contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
|
||||
address, replaces what came before, so withdrawn addresses are never listed. "Newest" is
|
||||
the highest rev from that copy (then time), so every field comes from the same event
|
||||
whatever order they arrived in or what the clocks said."""
|
||||
import frame_compat_db
|
||||
newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)"
|
||||
res = frame_compat_db._posthog_query(
|
||||
f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), "
|
||||
f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events "
|
||||
"WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
|
||||
out = {'updates': [], 'followup': []}
|
||||
for row in res.get('results') or []:
|
||||
if not isinstance(row, list) or len(row) != 5:
|
||||
continue
|
||||
_, email, updates, followup, ts = row
|
||||
email = str(email or '').strip()
|
||||
if not frame_contact.valid_email(email):
|
||||
continue
|
||||
for kind, agreed in (('updates', updates), ('followup', followup)):
|
||||
if _yes(agreed):
|
||||
out[kind].append((email, str(ts or '')[:10]))
|
||||
return out
|
||||
|
||||
|
||||
USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['inbox']
|
||||
if cmd == 'contacts':
|
||||
kinds = args[:1] or ['updates', 'followup']
|
||||
if not set(kinds) <= {'updates', 'followup'}:
|
||||
sys.exit(USAGE)
|
||||
found = contacts()
|
||||
for kind in kinds:
|
||||
print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}"
|
||||
f" ({len(found[kind])})")
|
||||
for email, since in found[kind]:
|
||||
print(f" {email} (since {since})")
|
||||
print()
|
||||
return
|
||||
if cmd != 'inbox':
|
||||
sys.exit('usage: frame_report.py inbox [days]')
|
||||
sys.exit(USAGE)
|
||||
for row in inbox(*(args[:1] or [30])):
|
||||
if not isinstance(row, list) or len(row) != 10:
|
||||
continue
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
|
||||
# Reports from before contact_followup existed only carried an address given for a reply.
|
||||
reply = contact and (row[10] is None or _yes(row[10]))
|
||||
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
|
||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
|
||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
|
||||
f"{', may follow up at ' + contact if reply else ''}"
|
||||
f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
|
||||
print(' ' + text.replace('\n', '\n '))
|
||||
if diag:
|
||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||
|
||||
+952
-50
File diff suppressed because it is too large.
Load diff
+299
-63
@@ -3,7 +3,8 @@
|
||||
|
||||
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
||||
up by scripts/connect.sh or ui/frame_connect.py.
|
||||
up by scripts/connect.sh or ui/frame_connect.py, or another headset picked on the
|
||||
Devices tab: frame_link.py finds it at one of its addresses (frame_devices.py).
|
||||
|
||||
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||
Env: FRAME_ALIAS (default frame)
|
||||
@@ -14,6 +15,7 @@ Env: FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
@@ -45,9 +47,12 @@ import frame_android # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_comfort # noqa: E402
|
||||
import frame_contact # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
@@ -74,18 +79,93 @@ DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
|
||||
# What the Frame's KDE Connect calls this device (keyboard and trackpad).
|
||||
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
|
||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||
FRAME_FROM_ENV = "FRAME_ALIAS" in os.environ
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||
# supports it (not on Windows: there every command connects on its own).
|
||||
CONTROL = None if LOCAL else frame_host.control_path(private=os.environ.get("FRAME_PRIVATE_SSH") == "1")
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||
# A private server (the MCP adapter starts one per session) keeps its own SSH masters, and
|
||||
# uses the headsets without editing them.
|
||||
PRIVATE = os.environ.get("FRAME_PRIVATE_SSH") == "1"
|
||||
CONTROL = None if LOCAL else frame_host.control_path(private=PRIVATE)
|
||||
# The ControlPath itself is per headset: the connector puts it in HOST_OPTS.
|
||||
MUX = ["ssh", "-o", "BatchMode=yes"]
|
||||
MUX_BASE = list(MUX)
|
||||
# Commands use the master when it's up and connect directly when it isn't.
|
||||
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
SSH_TAIL = [*(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
SSH = [*MUX, *SSH_TAIL]
|
||||
# The address the connector picked (-o HostName=... and friends), in every ssh command.
|
||||
HOST_OPTS = []
|
||||
|
||||
# Android helpers share the multiplexed connection when it's up.
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
|
||||
|
||||
_route_lock = threading.Lock()
|
||||
|
||||
|
||||
def route(alias, host_opts):
|
||||
"""Point every ssh, scp and rsync at `alias` with `host_opts` (frame_link calls this
|
||||
when it picks a headset and an address). The lists change in place, so code holding
|
||||
them follows; frame_titles reads frame_android.SSH_OPTS at call time."""
|
||||
global FRAME, HOST_OPTS
|
||||
with _route_lock:
|
||||
moved = alias != FRAME
|
||||
if moved:
|
||||
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
|
||||
FRAME = frame_android.FRAME = alias
|
||||
HOST_OPTS = list(host_opts)
|
||||
MUX[:] = [*MUX_BASE, *HOST_OPTS]
|
||||
SSH[:] = [*MUX, *SSH_TAIL]
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
# Long-lived ssh processes started on the old route (outside the lock: they take their own).
|
||||
mv = globals().get("macview")
|
||||
if mv:
|
||||
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
|
||||
agent = globals().get("_input")
|
||||
if moved and agent:
|
||||
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
|
||||
|
||||
|
||||
LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
|
||||
# Installs and other changes in progress. Switching headsets waits for them: they
|
||||
# read the ssh settings step by step, so a switch could send the rest (or a failed
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def working(meant=None):
|
||||
"""Counts as running work. `meant`: the headset the page made this change for; if the
|
||||
app has switched away from it, refuse (checked together with counting, so a switch
|
||||
can't slip in between)."""
|
||||
with _work_lock:
|
||||
if LINK and meant and meant != LINK.active_device()["id"]:
|
||||
raise Failure("Frame Control switched headsets; try again on this one", 409)
|
||||
_work[0] += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _work_lock:
|
||||
_work[0] -= 1
|
||||
|
||||
|
||||
def busy_thread(fn, *args):
|
||||
"""A thread that counts as work from before it starts until it ends."""
|
||||
with _work_lock:
|
||||
_work[0] += 1
|
||||
|
||||
def run():
|
||||
try:
|
||||
fn(*args)
|
||||
finally:
|
||||
with _work_lock:
|
||||
_work[0] -= 1
|
||||
return threading.Thread(target=run, daemon=True)
|
||||
|
||||
APPID = re.compile(r"^\d{1,10}$")
|
||||
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
|
||||
MAX_UPLOAD = 8 * 1024**3
|
||||
@@ -228,7 +308,7 @@ def start_job(label, work, progress=False):
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
busy_thread(run).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
|
||||
@@ -241,59 +321,59 @@ def job_status(query):
|
||||
return snapshot
|
||||
|
||||
|
||||
_master_lock = threading.Lock()
|
||||
_master = None
|
||||
|
||||
|
||||
def ensure_master():
|
||||
"""Start the shared SSH connection if it isn't up (one attempt at a time).
|
||||
|
||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||
"""
|
||||
global _master
|
||||
if not CONTROL:
|
||||
return
|
||||
|
||||
def up():
|
||||
try:
|
||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
|
||||
timeout=5).returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
|
||||
with _master_lock:
|
||||
if up() or (_master and _master.poll() is None):
|
||||
return
|
||||
# Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s,
|
||||
# so the next request starts a fresh master.
|
||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||
for _ in range(60):
|
||||
if up() or _master.poll() is not None:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
"""Make sure the connection to the headset is up, or being tried (frame_link.Link.ensure)."""
|
||||
if LINK:
|
||||
LINK.ensure()
|
||||
|
||||
|
||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
route_gen = LINK.gen if LINK else None # which headset this command is for
|
||||
try:
|
||||
ensure_master()
|
||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Timed out talking to {FRAME}")
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||
if r.returncode == 255 and repair_ssh_config(err):
|
||||
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
|
||||
if r.returncode == 255 and LINK and unreachable(err):
|
||||
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
||||
raise failure
|
||||
return r.stdout
|
||||
|
||||
|
||||
_config_repaired = False
|
||||
|
||||
|
||||
def repair_ssh_config(err):
|
||||
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
|
||||
once per run. -> True if it did, so the command is worth retrying."""
|
||||
global _config_repaired
|
||||
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
|
||||
return False
|
||||
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
|
||||
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
|
||||
config = frame_devices.ssh_config()
|
||||
if not named.lower().endswith("/" + config.name.lower()):
|
||||
return False # a key or another file: not ours to rewrite
|
||||
_config_repaired = True
|
||||
try:
|
||||
if frame_devices.repair_permissions(config):
|
||||
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def strip_ansi(s):
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||
|
||||
@@ -350,6 +430,12 @@ print(r.stdout, end='')
|
||||
|
||||
def headset_view():
|
||||
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
||||
# Counts as work: the copy and clean-up must reach the headset that took the capture.
|
||||
with working():
|
||||
return _headset_view()
|
||||
|
||||
|
||||
def _headset_view():
|
||||
# `timeout`: VR_Init can block if SteamVR is restarting.
|
||||
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
|
||||
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
|
||||
@@ -445,8 +531,8 @@ def save_shots(body):
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||
try:
|
||||
try:
|
||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure("Copying screenshots timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -1111,23 +1197,37 @@ def open_thing(body):
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
return power(what, body.get("password"))
|
||||
raise Failure("open that from the app", 400)
|
||||
# The headset and address in use, as every other command gets them (one snapshot).
|
||||
with _route_lock:
|
||||
alias, opts = LINK.named_route() if LINK else (FRAME, list(HOST_OPTS))
|
||||
host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None)
|
||||
if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"):
|
||||
raise Failure("No headset address to use: add one on the Devices tab", 400)
|
||||
try:
|
||||
if what == "terminal":
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', *opts, alias])}"}
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
||||
where = terminal(["ssh", "-t", *opts, alias, "sudo", "systemctl", what])
|
||||
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||
if what == "steamlink":
|
||||
return {"message": frame_host.open_steam_link()}
|
||||
if what == "rdp":
|
||||
return {"message": frame_host.open_rdp(FRAME)}
|
||||
return {"message": frame_host.open_rdp(alias, host)}
|
||||
if what == "sftp":
|
||||
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
||||
return {"message": f"Opened an SFTP session in {terminal(['sftp', *opts, alias])}"}
|
||||
if what == "shots":
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(SHOTS_DIR)
|
||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
if what == "shot":
|
||||
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
||||
if not saved.exists():
|
||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||
frame_host.reveal_path(saved)
|
||||
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
||||
except frame_host.Unreachable as e:
|
||||
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
raise Failure("unknown target", 400)
|
||||
@@ -1257,7 +1357,8 @@ def stage_title(path, temp_dir=None, name=None):
|
||||
raise
|
||||
token = secrets.token_hex(12)
|
||||
with _titles_lock:
|
||||
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
|
||||
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time(),
|
||||
"device": LINK.active_device()["id"] if LINK else None}
|
||||
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
|
||||
"token": token, "plan": frame_titles.public(plan)}
|
||||
|
||||
@@ -1302,13 +1403,15 @@ def titles(body):
|
||||
if action == "discard":
|
||||
_drop_staged(entry)
|
||||
return {"message": "Discarded"}
|
||||
if LINK and entry.get("device") != LINK.active_device()["id"]:
|
||||
_drop_staged(entry) # it was checked against the other headset's titles
|
||||
raise Failure("Frame Control switched headsets since this was read; drop the file again", 409)
|
||||
with _titles_lock:
|
||||
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
|
||||
"message": None, "title": None, "time": time.time()}
|
||||
ensure_master()
|
||||
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
|
||||
threading.Thread(target=_run_title_install, daemon=True,
|
||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
||||
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
|
||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||
if action not in ("launch", "remove", "refresh-art"):
|
||||
raise Failure("unknown action", 400)
|
||||
@@ -1421,7 +1524,7 @@ class AdbTunnel:
|
||||
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
|
||||
# Its own connection (ControlPath=none), so killing it drops the forwards.
|
||||
self.proc = _proc = subprocess.Popen(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none",
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none", *HOST_OPTS,
|
||||
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
_live_tunnels.add(_proc)
|
||||
@@ -1696,7 +1799,7 @@ def webinstall_start(body):
|
||||
_web_jobs.clear()
|
||||
_web_jobs[pid] = job
|
||||
# Started under the lock, so shutdown never sees a thread it can't join.
|
||||
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
|
||||
worker = busy_thread(_webinstall_run, plan, job)
|
||||
_web_workers.add(worker)
|
||||
worker.start()
|
||||
return {"job": pid}
|
||||
@@ -2084,7 +2187,56 @@ POST = {
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action}
|
||||
"/api/contact": frame_contact.save, "/api/contact/prompt": frame_contact.prompt,
|
||||
"/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 ------------------------------------------------------------------
|
||||
@@ -2131,7 +2283,7 @@ def push_file(path, dest="Downloads/"):
|
||||
else:
|
||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Copying {name} timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -2139,6 +2291,11 @@ def push_file(path, dest="Downloads/"):
|
||||
return f"Sent {name} to ~/{dest}"
|
||||
|
||||
|
||||
class ClientGone(Exception):
|
||||
"""The page went away (a reload, the app quitting) before its reply was written:
|
||||
nobody to answer, and nothing went wrong here."""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "FrameControl/1"
|
||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||
@@ -2171,8 +2328,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# Nobody may frame the UI (clickjacking).
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
try:
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
|
||||
raise ClientGone() from e
|
||||
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
@@ -2205,10 +2365,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path == "/api/connection":
|
||||
self.send_json(connection_state())
|
||||
elif path == "/api/connection/events":
|
||||
self.connection_events()
|
||||
elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"):
|
||||
self.send_json(devices_get(path, url.query))
|
||||
elif path.startswith("/source-image/"):
|
||||
from apk_sources import _images
|
||||
try:
|
||||
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||
except ClientGone:
|
||||
raise
|
||||
except Exception:
|
||||
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||
elif path == "/api/sources/details":
|
||||
@@ -2259,6 +2427,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(macview_state(parse_qs(url.query)))
|
||||
elif path == "/api/telemetry":
|
||||
self.send_json(frame_telemetry.state())
|
||||
elif path == "/api/contact":
|
||||
self.send_json(frame_contact.state())
|
||||
elif path == "/api/computer/state":
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||
elif path == "/api/status":
|
||||
@@ -2284,6 +2454,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
headers=[("X-Capture-Source", "gamescope")])
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except ClientGone:
|
||||
raise
|
||||
except Failure as e:
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except ValueError as e:
|
||||
@@ -2298,10 +2470,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not self.local_request():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
meant = self.headers.get("X-Frame-Device")
|
||||
body = None
|
||||
try:
|
||||
if path == "/api/upload":
|
||||
self.send_json(self.upload())
|
||||
with working(meant):
|
||||
self.send_json(self.upload())
|
||||
return
|
||||
handler = POST.get(path)
|
||||
if not handler:
|
||||
@@ -2313,7 +2487,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
||||
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
|
||||
result = handler(body)
|
||||
self.send_json(result)
|
||||
except ClientGone:
|
||||
raise
|
||||
except Failure as e:
|
||||
if e.status >= 500:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
@@ -2327,6 +2506,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def connection_events(self):
|
||||
"""Server-sent events: the connection state each time it changes, until the page goes.
|
||||
(The page reads it with fetch, which can send the X-Frame-UI header; EventSource can't.)"""
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.end_headers()
|
||||
self.close_connection = True
|
||||
version = -1
|
||||
try:
|
||||
while True:
|
||||
snap = connection_state() if version < 0 else (LINK.wait(version, 15) if LINK else None)
|
||||
if snap is None:
|
||||
if not LINK and version >= 0:
|
||||
time.sleep(15)
|
||||
self.wfile.write(b": still here\n\n") # keeps proxies and the page's watchdog happy
|
||||
else:
|
||||
version = max(snap["version"], 0)
|
||||
self.wfile.write(b"data: " + json.dumps(snap).encode() + b"\n\n")
|
||||
self.wfile.flush()
|
||||
except OSError:
|
||||
pass # the page went away
|
||||
|
||||
def stream_video(self, query):
|
||||
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
||||
global _stream_proc
|
||||
@@ -2465,6 +2668,28 @@ class LoopbackServer(ThreadingHTTPServer):
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
if not isinstance(sys.exc_info()[1], ClientGone):
|
||||
super().handle_error(request, client_address)
|
||||
|
||||
|
||||
_ONE_SERVER = None
|
||||
|
||||
|
||||
def one_server():
|
||||
"""Only one Frame Control server per user: two would each connect, reconnect and
|
||||
edit the headsets on their own, and could move each other's installs to another
|
||||
headset. Held until this process exits. (FRAME_CONTROL_DATA_DIR gives a second,
|
||||
separate one, as the tests do. A private server, the MCP adapter's, runs alongside:
|
||||
it can't add, remove or switch headsets.)"""
|
||||
lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=float(os.environ.get("FRAME_CONTROL_SERVER_WAIT") or 20)) # while the app restarts it
|
||||
try:
|
||||
lock.__enter__()
|
||||
except OSError:
|
||||
sys.exit("Frame Control is already running on this computer (the app, or a server started "
|
||||
"from a terminal). Quit it, then try again.")
|
||||
return lock
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
@@ -2477,16 +2702,29 @@ def main():
|
||||
sweep_tmp()
|
||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||
frame_telemetry.start()
|
||||
frame_contact.start()
|
||||
global LINK, _ONE_SERVER
|
||||
if not LOCAL:
|
||||
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
||||
_ONE_SERVER = one_server()
|
||||
LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None,
|
||||
mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable)
|
||||
LINK.work_lock, LINK.work = _work_lock, lambda: _work[0]
|
||||
LINK.start()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
def watch_stdin():
|
||||
sys.stdin.buffer.read()
|
||||
# os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock,
|
||||
# and if a signal stops the server first, Python aborts (SIGABRT) at exit
|
||||
# when it can't take that lock back from this thread.
|
||||
while os.read(0, 4096):
|
||||
pass
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
||||
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
try:
|
||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
||||
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -2498,10 +2736,8 @@ def main():
|
||||
webinstall_shutdown()
|
||||
macview.shutdown() # close the headset's viewers before the agent goes
|
||||
# The master was started with -N, so it stays up until told to exit.
|
||||
if CONTROL:
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
||||
if _master and _master.poll() is None:
|
||||
_master.terminate()
|
||||
if LINK:
|
||||
LINK.stop()
|
||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
Reference in new issue
Block a user