mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
024ec593f9 | ||
|
|
0373555519 | ||
|
|
3539215517 | ||
|
|
9f3fb89e30 | ||
|
|
dc7854d10b | ||
|
|
28073e212a | ||
|
|
6abc765e22 | ||
|
|
f73efe414c | ||
|
|
704e5d7780 | ||
|
|
edbe8d4109 | ||
|
|
f2c8466ba9 | ||
|
|
6cf01729e2 | ||
|
|
47a29afb4c | ||
|
|
a0f810c018 | ||
|
|
2ca0e6924a | ||
|
|
cf2db32721 | ||
|
|
3f0f09b138 | ||
|
|
b8ed53f2ff | ||
|
|
19a0d0af18 | ||
|
|
1a5f089e57 | ||
|
|
5a6b925448 | ||
|
|
83d74548cd | ||
|
|
fa6d4fd81b | ||
|
|
7cc4abaffa | ||
|
|
02b9413f78 | ||
|
|
1a08258e3d | ||
|
|
a84d6b912b | ||
|
|
1cd56740a6 | ||
|
|
b685a601f7 | ||
|
|
31de17aab8 | ||
|
|
ffef4d897a | ||
|
|
094c12c6d8 | ||
|
|
c46bf68dd0 | ||
|
|
dc4a64a9c3 | ||
|
|
60ade8c2e8 | ||
|
|
08fbe730c6 | ||
|
|
1cf353f419 | ||
|
|
c0183ca8b2 | ||
|
|
bf8a478063 | ||
|
|
215bc357ef | ||
|
|
7d6ff7f919 | ||
|
|
aa1ac7368c | ||
|
|
1343900aa1 | ||
|
|
d2a5db7caf | ||
|
|
46b6966cf7 | ||
|
|
be2e770e66 | ||
|
|
597f98af6d | ||
|
|
ac07efa8f1 | ||
|
|
a08ba6f65b | ||
|
|
53c51e1888 | ||
|
|
0f4f0b863f | ||
|
|
72e4ccf080 | ||
|
|
a9740ad6f2 | ||
|
|
e7c81733b1 | ||
|
|
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
@@ -73,7 +73,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
|||||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||||
|
|
||||||
**⌨️ Keyboard and trackpad**<br>
|
**⌨️ Keyboard and trackpad**<br>
|
||||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
|
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one.
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+104
-18
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
// 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
|
// 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.
|
// 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 { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -149,7 +149,7 @@ async function startServer() {
|
|||||||
const target = `http://127.0.0.1:${port}/`;
|
const target = `http://127.0.0.1:${port}/`;
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
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));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
if (server === child) server = null;
|
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
|
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||||
// way on Windows); SIGTERM does the same elsewhere.
|
// way on Windows); SIGTERM does the same elsewhere.
|
||||||
|
// Resolves once it has exited (or after 20 s), so a replacement can take the server
|
||||||
|
// lock: server.py allows one per user.
|
||||||
function endServer(child) {
|
function endServer(child) {
|
||||||
|
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
|
||||||
|
: new Promise((resolve) => child.once("exit", resolve));
|
||||||
try { child.stdin.end(); } catch {}
|
try { child.stdin.end(); } catch {}
|
||||||
if (!IS_WIN) child.kill("SIGTERM");
|
if (!IS_WIN) child.kill("SIGTERM");
|
||||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
|
||||||
|
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
|
||||||
|
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopServer() {
|
function stopServer() {
|
||||||
if (server) endServer(server);
|
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 esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
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">
|
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>
|
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
<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);
|
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) {
|
function serverDied(why) {
|
||||||
url = null;
|
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() {
|
// 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;
|
const old = server;
|
||||||
server = null;
|
server = null;
|
||||||
url = null;
|
url = null;
|
||||||
if (old) endServer(old);
|
if (old) await endServer(old);
|
||||||
|
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
|
||||||
await load();
|
await load();
|
||||||
|
})().finally(() => { restarting = null; });
|
||||||
|
}
|
||||||
|
return restarting;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||||
const CHROME_CSS = IS_MAC && `
|
const CHROME_CSS = IS_MAC && `
|
||||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Restart Server can start a new load while an older one is still waiting for
|
// Restart Server can start a new load while an older one is still waiting for
|
||||||
// its server; only the newest load may touch the window.
|
// its server; only the newest load may touch the window.
|
||||||
let loadGen = 0;
|
let loadGen = 0;
|
||||||
|
let starting = null; // loads that overlap share one server start
|
||||||
async function load() {
|
async function load() {
|
||||||
const gen = ++loadGen;
|
const gen = ++loadGen;
|
||||||
try {
|
try {
|
||||||
if (!url) await startServer();
|
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||||
@@ -247,13 +272,66 @@ function fromUi(e) {
|
|||||||
} catch { return false; }
|
} 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() : "");
|
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.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
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:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||||
|
|
||||||
|
// The page reports the headsets it knows (the server's Devices tab), so the Frame
|
||||||
|
// menu can switch between them. Only plain names and ids go into the menu.
|
||||||
|
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||||
|
let devices = [];
|
||||||
|
ipcMain.on("devices:changed", (e, list) => {
|
||||||
|
if (!fromUi(e) || !Array.isArray(list)) return;
|
||||||
|
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
|
||||||
|
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(devices)) return;
|
||||||
|
devices = next;
|
||||||
|
buildMenu();
|
||||||
|
});
|
||||||
|
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||||
|
|
||||||
|
// SSH through the server, so it goes to the headset and address the app is using
|
||||||
|
// (with its own pinned identity), and refuses when there's none.
|
||||||
|
function openSsh() {
|
||||||
|
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
|
||||||
|
const body = JSON.stringify({ what: "terminal" });
|
||||||
|
const req = http.request(new URL("/api/open", url), {
|
||||||
|
method: "POST", timeout: 15000,
|
||||||
|
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
|
||||||
|
}, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => { data += c; });
|
||||||
|
res.on("end", () => {
|
||||||
|
if (res.statusCode === 200) return;
|
||||||
|
let why = `HTTP ${res.statusCode}`;
|
||||||
|
try { why = JSON.parse(data).error || why; } catch {}
|
||||||
|
dialog.showErrorBox("Couldn't open SSH", why);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
|
||||||
|
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||||
|
req.end(body);
|
||||||
|
}
|
||||||
|
function showDevices() {
|
||||||
|
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle("comfort:notify", (e, message) => {
|
ipcMain.handle("comfort:notify", (e, message) => {
|
||||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||||
@@ -425,13 +503,14 @@ async function runInTerminal(argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setUpConnection() {
|
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
async function setUpConnection(name = activeAlias()) {
|
||||||
|
if (!ALIAS_RE.test(name)) return;
|
||||||
|
const alias = `FRAME_ALIAS=${name}`;
|
||||||
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMenu() {
|
function buildMenu() {
|
||||||
@@ -446,8 +525,15 @@ function buildMenu() {
|
|||||||
{
|
{
|
||||||
label: "Frame",
|
label: "Frame",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Set Up Connection…", click: setUpConnection },
|
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||||
|
{ type: "separator" },
|
||||||
|
...(devices.length > 1 ? [{
|
||||||
|
label: "Headset",
|
||||||
|
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
|
||||||
|
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
|
||||||
|
}] : []),
|
||||||
|
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||||
|
|||||||
+10
-1
@@ -2,7 +2,8 @@
|
|||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||||
// as a title without zipping it (the local server reads it from there).
|
// as a title without zipping it (the local server reads it from there).
|
||||||
// It can open Set Up Connection when the headset can't be reached.
|
// It can 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
|
// It also receives frame-control://install links (docs/web-install.md): only
|
||||||
// what the link asked for, never an install; the page asks the user first.
|
// what the link asked for, never an install; the page asks the user first.
|
||||||
// And it passes update state both ways: see app/updater.js.
|
// And it passes update state both ways: see app/updater.js.
|
||||||
@@ -11,7 +12,15 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
|||||||
contextBridge.exposeInMainWorld("frameApp", {
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
|
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
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.
|
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||||
|
|||||||
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
|
|||||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
||||||
browser profile and SSH tunnel and remove the profile and panel log.
|
browser profile and SSH tunnel and remove the profile and panel log.
|
||||||
|
|
||||||
|
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
|
||||||
|
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
|
||||||
|
backend, no API key, no prestarted server) and a human approved or rejected
|
||||||
|
each change in the approval page in a real Chrome window:
|
||||||
|
|
||||||
|
| Tool | Result on the Frame |
|
||||||
|
|---|---|
|
||||||
|
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
|
||||||
|
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
|
||||||
|
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
|
||||||
|
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
|
||||||
|
| `uninstall` | Approved; app and locale removed |
|
||||||
|
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
|
||||||
|
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
|
||||||
|
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
|
||||||
|
|
||||||
|
A separate Claude Code CLI session, with only this server configured, read
|
||||||
|
status, `computer_state` and a headset capture, and requested an install. It
|
||||||
|
received an approval URL and did not execute anything.
|
||||||
|
|
||||||
|
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
|
||||||
|
Against a loopback stub model, a send without consent made zero requests. With
|
||||||
|
consent it made exactly one, carrying the text and a fresh Frame screenshot.
|
||||||
|
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
|
||||||
|
and tunnel.
|
||||||
|
|
||||||
|
**Not verified while unworn:** every headset capture was a uniform dark frame,
|
||||||
|
so SteamVR's rendered view of panels and the game could not be checked; window
|
||||||
|
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
|
||||||
|
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
|
||||||
|
runtime it pulled in; Flatpak may also remove related extensions when that
|
||||||
|
runtime is removed by hand.
|
||||||
|
|
||||||
|

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

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

|

|
||||||
|
|
||||||
## Computer-use coverage
|
## Computer-use coverage
|
||||||
|
|||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
# Headsets, addresses and the connection
|
||||||
|
|
||||||
|
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||||
|
reached at more than one address: a LAN IP at home, another at the office, its
|
||||||
|
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||||
|
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||||
|
app is doing to reach the one in use, step by step, as it happens.
|
||||||
|
|
||||||
|
The code is in three modules, all stdlib-only Python on your computer:
|
||||||
|
|
||||||
|
| Module | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
|
||||||
|
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
|
||||||
|
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
|
||||||
|
|
||||||
|
## Headsets
|
||||||
|
|
||||||
|
Each headset keeps its own SSH alias, as Set Up Connection has always written
|
||||||
|
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
|
||||||
|
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
|
||||||
|
work for each one.
|
||||||
|
|
||||||
|
- **Nothing to migrate by hand.** On first start, the app imports every
|
||||||
|
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
|
||||||
|
the block's HostName as its first address. It also copies the host key your
|
||||||
|
`known_hosts` already trusts for that address into the headset's own
|
||||||
|
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
|
||||||
|
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
|
||||||
|
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
|
||||||
|
When it writes its block, the app picks the headset up by itself. If Set Up
|
||||||
|
Connection runs again and finds a headset somewhere new, that address is added
|
||||||
|
at the top of its list.
|
||||||
|
- **Use this headset** (or the switcher in the header, or the app's
|
||||||
|
**Frame → Headset** menu) moves the whole app to another headset; every panel
|
||||||
|
reloads from it. From that moment no command goes to the previous headset, even
|
||||||
|
if the new one never answers. It waits while an install is running, since an
|
||||||
|
install reads the SSH settings step by step.
|
||||||
|
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
|
||||||
|
the box; either way it isn't imported again unless Set Up Connection changes it.
|
||||||
|
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
|
||||||
|
app shows it as not set up and lets ssh's own config decide where it goes.
|
||||||
|
|
||||||
|
## Addresses
|
||||||
|
|
||||||
|
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
|
||||||
|
and changeable), an optional label, the networks it has worked on, and when it
|
||||||
|
last worked with its round-trip time.
|
||||||
|
|
||||||
|
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
|
||||||
|
and ranks them:
|
||||||
|
|
||||||
|
1. addresses that worked on the network this computer is on now;
|
||||||
|
2. mDNS names;
|
||||||
|
3. Tailscale addresses, if Tailscale is running here;
|
||||||
|
4. addresses not tried on this network yet;
|
||||||
|
5. addresses that only ever worked on other networks;
|
||||||
|
6. Tailscale addresses while Tailscale is off.
|
||||||
|
|
||||||
|
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||||
|
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||||
|
still trying. If SSH to the winner fails in a way another address could fix
|
||||||
|
(a different device answered there, or the link dropped), the next one that
|
||||||
|
answered is tried. Every success records the network on that address, so next
|
||||||
|
time on that network it's tried first.
|
||||||
|
|
||||||
|
**Test now** probes every address and tries SSH on each one that answers, without
|
||||||
|
disturbing the connection in use: "SSH works", "answered as a different
|
||||||
|
headset", "refused this computer's key", or why it didn't answer. **Find on
|
||||||
|
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
|
||||||
|
status --json`, including the Mac app's own CLI) with buttons to add their
|
||||||
|
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||||
|
services and checks `ALIAS.local` and `frame.local`.
|
||||||
|
|
||||||
|
## Networks
|
||||||
|
|
||||||
|
A network is told apart by its default gateway: the router's IP address plus its
|
||||||
|
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
|
||||||
|
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
|
||||||
|
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
|
||||||
|
permission. Where the system does share the Wi-Fi name, it's shown, and you can
|
||||||
|
name any network yourself ("Home Wi-Fi") on the Devices tab.
|
||||||
|
|
||||||
|
The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||||
|
30 seconds. Changing networks reconnects.
|
||||||
|
|
||||||
|
## The connection, stage by stage
|
||||||
|
|
||||||
|
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||||
|
|
||||||
|
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||||
|
2. **Finding the headset**: each address resolving, trying, answered in N ms,
|
||||||
|
no answer, refused, or can't be found.
|
||||||
|
3. **Opening SSH** to the address that answered.
|
||||||
|
4. **Checking the headset's identity**: the host key must match the one pinned
|
||||||
|
for this headset.
|
||||||
|
5. **Logging in** as the headset's user.
|
||||||
|
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
|
||||||
|
with the reason in plain words and a countdown to the next try (5, 10, 20,
|
||||||
|
then every 30 seconds). Retry now skips the wait.
|
||||||
|
|
||||||
|
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
|
||||||
|
connection is an SSH ControlMaster that every command shares; when it dies (the
|
||||||
|
headset slept or left the network) the connector notices and starts again. On
|
||||||
|
Windows, where OpenSSH can't share a connection, the same handshake runs once
|
||||||
|
and each command then connects on its own; a command that can't reach the
|
||||||
|
headset makes the connector start again.
|
||||||
|
|
||||||
|
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
|
||||||
|
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
|
||||||
|
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
|
||||||
|
alias's block in `~/.ssh/config` is also updated to the last address that
|
||||||
|
worked (and to the user and port you set), so Terminal's `ssh frame` and the
|
||||||
|
scripts follow. Edits to `~/.ssh/config` take a lock file
|
||||||
|
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
|
||||||
|
write over a change someone else made since the app last read the file.
|
||||||
|
|
||||||
|
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
|
||||||
|
is keyed by address, so a different device answering at a remembered IP (a DHCP
|
||||||
|
lease that moved) would look like a new host there. The app keeps one known_hosts
|
||||||
|
file per headset instead, so saving or forgetting one headset's key never touches
|
||||||
|
another's: a different device answering at one of its
|
||||||
|
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||||
|
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
||||||
|
headset has a new key; **Forget identity** on the Devices tab lets the next
|
||||||
|
connection save the new one.
|
||||||
|
|
||||||
|
## One server at a time
|
||||||
|
|
||||||
|
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
|
||||||
|
data folder). Two would each connect, reconnect and edit the headsets on their own, and
|
||||||
|
one could move the other's install to a different headset. A second one, say
|
||||||
|
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
|
||||||
|
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
|
||||||
|
|
||||||
|
| Request | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
|
||||||
|
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
|
||||||
|
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
|
||||||
|
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
|
||||||
|
| `GET /api/devices/mdns?id=` | Headsets found on this network |
|
||||||
|
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
|
||||||
|
|
||||||
|
Every host, alias and user is checked against strict patterns before it's
|
||||||
|
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
|
||||||
|
through a shell.
|
||||||
|
|
||||||
|
## The registry file
|
||||||
|
|
||||||
|
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
|
||||||
|
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
|
||||||
|
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
|
||||||
|
share the format later (it still connects to one host; see
|
||||||
|
[iphone.md](iphone.md)):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"version": 1, "active": "f67f8b7e",
|
||||||
|
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
|
||||||
|
"identity_files": ["~/.ssh/id_ed25519_frame"],
|
||||||
|
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
|
||||||
|
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
|
||||||
|
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
|
||||||
|
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
|
||||||
|
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
|
||||||
|
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
|
||||||
|
computer for the addresses, and temporary folders for `~/.ssh`
|
||||||
|
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
|
||||||
|
never touch yours.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Locked real-Frame repeat, 2026-09-29
|
||||||
|
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
|
||||||
|
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
|
||||||
|
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
|
||||||
|
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
|
||||||
|
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
|
||||||
|
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
|
||||||
|
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
|
||||||
|
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
|
||||||
|
|
||||||
|
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
|
||||||
|
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
|
||||||
|
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
|
||||||
|
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
|
||||||
+17
-7
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||||
plain words and the app retries every few seconds, filling everything in once it
|
connection pill in the header always shows which headset, which network this
|
||||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
computer is on, the address in use or being tried, and each step of connecting
|
||||||
|
as it happens; click it for the whole timeline. When the Frame can't be
|
||||||
|
reached, a banner says why in plain words, what was tried, and counts down to
|
||||||
|
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||||
counts them while they run.
|
counts them while they run.
|
||||||
|
|
||||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||||
@@ -78,6 +81,11 @@ counts them while they run.
|
|||||||
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||||
Remmina).
|
Remmina).
|
||||||
|
- **Devices**: several headsets, each with several addresses (LAN IPs per
|
||||||
|
network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
|
||||||
|
tries them all at once and learns which worked on which network. Add, edit,
|
||||||
|
reorder and test addresses, find a headset on Tailscale or on this network,
|
||||||
|
name your networks, and switch headsets. See [devices.md](devices.md).
|
||||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||||
@@ -101,7 +109,9 @@ Frame for the keyboard and trackpad.
|
|||||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||||
with a non-local `Host` header, and any `/api/` request without a custom
|
with a non-local `Host` header, and any `/api/` request without a custom
|
||||||
header, so other websites can't drive it or read captures. Everything reaches
|
header, so other websites can't drive it or read captures. Everything reaches
|
||||||
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
|
the headset through its SSH alias (`frame` for the first one), pointed at the
|
||||||
|
address that answered with `-o HostName=` (`ui/frame_link.py`, described in
|
||||||
|
[devices.md](devices.md)). On macOS and Linux it keeps one
|
||||||
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
||||||
Windows' OpenSSH can't share a connection, so there each request connects on
|
Windows' OpenSSH can't share a connection, so there each request connects on
|
||||||
its own and the app is a little slower. What differs between the three
|
its own and the app is a little slower. What differs between the three
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
|||||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||||
SteamVR's original process remained running. No global settings changed.
|
SteamVR's original process remained running. No global settings changed.
|
||||||
|
|
||||||
|
### Locked repeat, 2026-09-29 (verified)
|
||||||
|
|
||||||
|
Acquired `/tmp/frame-test.lock` before installing or launching anything and
|
||||||
|
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
|
||||||
|
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
|
||||||
|
|
||||||
|
Reinstalled and launched both original APKs in immersive Lepton instances.
|
||||||
|
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
|
||||||
|
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
|
||||||
|
unmodified-client test, not the newer installer's automatic compatibility-layer
|
||||||
|
path. Neither reached a session that could be paired or exercised further.
|
||||||
|
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
|
||||||
|
record the failures.
|
||||||
|
|
||||||
|
SteamVR's screenshot API returned a 1920×1080 headset capture after each
|
||||||
|
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
|
||||||
|
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
|
||||||
|
rendering or a working client. The headset was unworn; visibility, controllers,
|
||||||
|
frame rate and motion-to-photon latency could not be judged. The explicit
|
||||||
|
OpenXR errors, rather than the dark captures, establish the client blocker.
|
||||||
|
|
||||||
|
Both test installs, app data, shader caches, shortcuts, containers and temporary
|
||||||
|
capture files were removed. The original Steam and SteamVR process IDs were
|
||||||
|
unchanged. No reboot, power action or global setting change was used. Native
|
||||||
|
clients remain untested, and no Linux gaming host was available for Valve's
|
||||||
|
streaming path. The recommendation below is unchanged.
|
||||||
|
|
||||||
### Relation to the VR APK branch
|
### Relation to the VR APK branch
|
||||||
|
|
||||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||||
|
|||||||
+54
-3
@@ -103,8 +103,10 @@ 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
|
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
|
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
|
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,
|
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
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.
|
your analytics events.
|
||||||
|
|
||||||
With **Include diagnostics** ticked (the default), the report adds:
|
With **Include diagnostics** ticked (the default), the report adds:
|
||||||
@@ -128,11 +130,60 @@ 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
|
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||||
API key as `frame_compat_db.py sync`.
|
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 while or straight after the
|
||||||
|
first-run privacy notice is showing. **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, whatever the analytics settings are, because you chose to. 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. 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
|
## Turning it all off
|
||||||
|
|
||||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
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
|
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
|
## Update checks
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -123,10 +123,13 @@ two fingers to scroll, two-finger tap to right-click) plus a text field that
|
|||||||
types on the Frame. On a computer, clicking the pad passes your mouse and
|
types on the Frame. On a computer, clicking the pad passes your mouse and
|
||||||
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
|
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
|
||||||
|
|
||||||
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
|
Since Control (below), the pad goes through **gamescope's own input socket**
|
||||||
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
|
([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
|
||||||
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
|
focus, Steam's own menus included, and needs nothing installed on the Frame.
|
||||||
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
|
Only characters that aren't on a US keyboard (accents, emoji) go through
|
||||||
|
**KDE Connect** as below; it starts the first time you type one. (Before
|
||||||
|
Control, everything went through KDE Connect, which is why the notes below
|
||||||
|
describe it in detail.)
|
||||||
|
|
||||||
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
|
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
|
|||||||
|
|
||||||

|

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

|
||||||
|
|
||||||
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||||
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
||||||
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
||||||
|
|||||||
+16
-3
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
|
||||||
|
# running app and this script never write over each other's change.
|
||||||
write_config() {
|
write_config() {
|
||||||
|
local lockfd="" rc
|
||||||
|
zmodload zsh/system 2>/dev/null
|
||||||
|
touch "$CONFIG.frame-control.lock" 2>/dev/null
|
||||||
|
zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
|
||||||
|
write_config_locked; rc=$?
|
||||||
|
[[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
||||||
|
write_config_locked() {
|
||||||
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
||||||
local tmp
|
local tmp new="$CONFIG.frame-control.$$"
|
||||||
tmp=$(mktemp) || return 1
|
tmp=$(mktemp) || return 1
|
||||||
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
||||||
# value it sees per option, so this block must precede any other "Host frame"
|
# value it sees per option, so this block must precede any other "Host frame"
|
||||||
@@ -126,7 +138,8 @@ write_config() {
|
|||||||
print -r -- "Host *"
|
print -r -- "Host *"
|
||||||
print -r -- "$END_MARK"
|
print -r -- "$END_MARK"
|
||||||
cat "$tmp"
|
cat "$tmp"
|
||||||
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
} > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
|
||||||
|
|| { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||||
|
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||||
|
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||||
HERE=${0:A:h}
|
HERE=${0:A:h}
|
||||||
cmd=${1:-status}
|
cmd=${1:-status}
|
||||||
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
||||||
|
|
||||||
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||||
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
|
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
|
||||||
|
|
||||||
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
||||||
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
|||||||
# written the way Steam's settings page does (steamui module exporting the
|
# written the way Steam's settings page does (steamui module exporting the
|
||||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||||
# ("Interactive authentication required") but allows one from a user unit.
|
# ("Interactive authentication required") but allows one from a user unit.
|
||||||
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||||
import json, os, subprocess, sys
|
import json, os, subprocess, sys
|
||||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||||
from frame_steam import Page
|
from frame_steam import Page
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||||
|
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||||
|
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||||
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
||||||
id="" name=""
|
id="" name=""
|
||||||
|
|
||||||
@@ -109,4 +111,4 @@ EOF
|
|||||||
)
|
)
|
||||||
b64=$(print -rn -- "$remote" | base64)
|
b64=$(print -rn -- "$remote" | base64)
|
||||||
|
|
||||||
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at
|
||||||
|
each step of a connection, and plays a ControlMaster. What each HostName does comes
|
||||||
|
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
|
||||||
|
"slow" (hangs after connecting);
|
||||||
|
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with open(os.environ["FAKESSH_LOG"], "a") as f:
|
||||||
|
f.write(json.dumps(args) + "\n")
|
||||||
|
hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
|
||||||
|
opts = {}
|
||||||
|
i = 0
|
||||||
|
while i < len(args) and args[i].startswith("-"):
|
||||||
|
if args[i] in ("-o", "-O", "-p", "-l"):
|
||||||
|
key = args[i]
|
||||||
|
val = args[i + 1]
|
||||||
|
if key == "-o":
|
||||||
|
k, _, v = val.partition("=")
|
||||||
|
opts[k.lower()] = v
|
||||||
|
else:
|
||||||
|
opts[key] = val
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
opts[args[i]] = True
|
||||||
|
i += 1
|
||||||
|
alias = args[i] if i < len(args) else ""
|
||||||
|
host = opts.get("hostname", alias).replace("%%", "%")
|
||||||
|
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
|
||||||
|
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
|
||||||
|
|
||||||
|
if "-G" in opts:
|
||||||
|
print(f"hostname {alias}\nport 22\nuser tester")
|
||||||
|
sys.exit(0)
|
||||||
|
if opts.get("-O") == "check":
|
||||||
|
sys.exit(0 if os.path.exists(marker) else 255)
|
||||||
|
if opts.get("-O") == "exit":
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.unlink(marker)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
what = hosts.get(host)
|
||||||
|
if what is None:
|
||||||
|
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.")
|
||||||
|
say("debug1: Connection established.")
|
||||||
|
if what == "slow":
|
||||||
|
time.sleep(30)
|
||||||
|
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
|
||||||
|
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
|
||||||
|
if what == "wrong":
|
||||||
|
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
||||||
|
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
|
||||||
|
say("Host key verification failed.")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.")
|
||||||
|
say("debug1: Next authentication method: publickey")
|
||||||
|
if what == "denied":
|
||||||
|
say(f"tester@{host}: Permission denied (publickey).")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
|
||||||
|
if opts.get("controlmaster") == "yes":
|
||||||
|
open(marker, "w").close()
|
||||||
|
def bye(*_):
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.unlink(marker)
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGTERM, bye)
|
||||||
|
while True:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if not os.path.exists(marker):
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
+23
-2
@@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase):
|
|||||||
with mock.patch.object(server.frame_host, 'MUX', True), \
|
with mock.patch.object(server.frame_host, 'MUX', True), \
|
||||||
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
|
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
|
||||||
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
||||||
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
|
# Per headset (its tag), and per process for a private server.
|
||||||
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
|
self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C')
|
||||||
|
self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C')
|
||||||
|
|
||||||
|
|
||||||
class ComputerState(unittest.TestCase):
|
class ComputerState(unittest.TestCase):
|
||||||
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptsFollowTheHeadset(unittest.TestCase):
|
||||||
|
"""keep_awake and panel tools run scripts that ssh on their own: they must reach the
|
||||||
|
headset the server is routed to, not whatever `frame` means in ~/.ssh/config."""
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('zsh'), 'needs zsh')
|
||||||
|
def test_scripts_get_the_routed_alias_and_options(self):
|
||||||
|
import shlex
|
||||||
|
fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui',
|
||||||
|
SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab'])
|
||||||
|
with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run:
|
||||||
|
agent.run_script(fake, 'keep-awake.sh', ['status'])
|
||||||
|
env = run.call_args.kwargs['env']
|
||||||
|
self.assertEqual(env['FRAME_ALIAS'], 'frame-2')
|
||||||
|
self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:])
|
||||||
|
# and the script turns that back into the same argv
|
||||||
|
out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'],
|
||||||
|
env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True)
|
||||||
|
self.assertEqual(out.stdout.splitlines(), fake.SSH[1:])
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""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_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 test_a_report_carries_the_address_only_with_follow_up_consent(self):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com"})
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
without, with_ = (e["properties"] for e in self.events())
|
||||||
|
self.assertEqual((without["contact"], without["contact_followup"]), ("", False))
|
||||||
|
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
|
||||||
|
with self.assertRaisesRegex(ValueError, "email address"):
|
||||||
|
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# Run these once, in test_telemetry, not again through the import above.
|
||||||
|
del Base, ReportProblem
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned
|
||||||
|
host keys and input checks. Everything works in temporary folders.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_devices as fd # noqa: E402
|
||||||
|
|
||||||
|
CONFIG = """Host lxso1
|
||||||
|
HostName 192.168.1.109
|
||||||
|
|
||||||
|
# >>> steam-frame (frame) >>>
|
||||||
|
Host frame
|
||||||
|
HostName frame.tail1234.ts.net
|
||||||
|
User steamos
|
||||||
|
IdentityFile ~/.ssh/id_ed25519_frame
|
||||||
|
IdentityFile ~/.ssh/id_rsa_frame_devkit
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ServerAliveInterval 30
|
||||||
|
Host *
|
||||||
|
# <<< steam-frame (frame) <<<
|
||||||
|
# >>> steam-frame (frame-2) >>>
|
||||||
|
Host frame-2
|
||||||
|
HostName 192.168.1.60
|
||||||
|
Port 2222
|
||||||
|
User deck
|
||||||
|
IdentityFile ~/.ssh/id_ed25519_frame
|
||||||
|
Host *
|
||||||
|
# <<< steam-frame (frame-2) <<<
|
||||||
|
Host *
|
||||||
|
ServerAliveInterval 60
|
||||||
|
"""
|
||||||
|
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE"
|
||||||
|
|
||||||
|
|
||||||
|
class Base(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-"))
|
||||||
|
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||||
|
self.ssh = self.dir / "ssh"
|
||||||
|
self.ssh.mkdir()
|
||||||
|
(self.ssh / "config").write_text(CONFIG)
|
||||||
|
old = os.environ.get("FRAME_CONTROL_SSH_DIR")
|
||||||
|
os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh)
|
||||||
|
self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old
|
||||||
|
else os.environ.pop("FRAME_CONTROL_SSH_DIR", None))
|
||||||
|
self.reg = fd.Registry(self.dir / "devices.json")
|
||||||
|
|
||||||
|
|
||||||
|
class Validation(unittest.TestCase):
|
||||||
|
def test_hosts(self):
|
||||||
|
for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::1234:5678", "fe80::1%en0", "frame-2.tail1234.ts.net"):
|
||||||
|
self.assertEqual(fd.check_host(good), good)
|
||||||
|
for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)",
|
||||||
|
"frame%en0", "x" * 300, None, 5, "frame/../x"):
|
||||||
|
with self.assertRaises(fd.DeviceError, msg=repr(bad)):
|
||||||
|
fd.check_host(bad)
|
||||||
|
|
||||||
|
def test_names(self):
|
||||||
|
self.assertEqual(fd.check_alias("frame-2"), "frame-2")
|
||||||
|
for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None):
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
fd.check_alias(bad)
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
fd.check_user(bad)
|
||||||
|
for bad in ("0", "65536", "x", None, "22; id"):
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
fd.check_port(bad)
|
||||||
|
self.assertEqual(fd.check_port("2222"), 2222)
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
fd.check_text("line\nbreak", "label")
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
fd.check_kind("wifi")
|
||||||
|
|
||||||
|
def test_ipv6_zone_is_escaped_for_ssh(self):
|
||||||
|
self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(Base):
|
||||||
|
def test_blocks_are_parsed(self):
|
||||||
|
blocks = fd.parse_blocks(CONFIG)
|
||||||
|
self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"])
|
||||||
|
self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net")
|
||||||
|
self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"])
|
||||||
|
self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck"))
|
||||||
|
|
||||||
|
def test_existing_headsets_are_imported_once(self):
|
||||||
|
self.assertTrue(self.reg.sync_from_config(seed=False))
|
||||||
|
devices = self.reg.devices()
|
||||||
|
self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"])
|
||||||
|
frame, second = devices
|
||||||
|
self.assertEqual(frame["name"], "Steam Frame")
|
||||||
|
self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net")
|
||||||
|
self.assertEqual(frame["addresses"][0]["kind"], "tailscale")
|
||||||
|
self.assertEqual((second["user"], second["port"]), ("deck", 2222))
|
||||||
|
self.assertEqual(self.reg.active(), frame["id"])
|
||||||
|
self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new
|
||||||
|
# It's all on disk, in the documented shape.
|
||||||
|
data = json.loads((self.dir / "devices.json").read_text())
|
||||||
|
self.assertEqual(data["version"], 1)
|
||||||
|
self.assertEqual(len(data["devices"]), 2)
|
||||||
|
self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices())
|
||||||
|
|
||||||
|
def test_first_import_keeps_using_frame(self):
|
||||||
|
# Set Up Connection puts each new block first; the app used `frame` before.
|
||||||
|
blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>")
|
||||||
|
head, first = blocks[0].split("# >>> steam-frame (frame) >>>")
|
||||||
|
second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<")
|
||||||
|
(self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n"
|
||||||
|
+ "# >>> steam-frame (frame) >>>" + first + tail)
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"])
|
||||||
|
self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"])
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("ssh"), "needs ssh")
|
||||||
|
def test_a_port_inherited_from_another_host_entry_is_kept(self):
|
||||||
|
(self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222"))
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use
|
||||||
|
self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line
|
||||||
|
# Saving 22 must then say so in the block, or ssh would go on inheriting 2222.
|
||||||
|
self.assertTrue(fd.rewrite_block("frame", port=22))
|
||||||
|
self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22)
|
||||||
|
self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once
|
||||||
|
|
||||||
|
def test_setup_finding_a_new_address_adds_it(self):
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
(self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237"))
|
||||||
|
self.assertTrue(self.reg.sync_from_config(seed=False))
|
||||||
|
hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]]
|
||||||
|
self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first
|
||||||
|
|
||||||
|
def test_setup_changing_the_login_updates_the_headset(self):
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
(self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1))
|
||||||
|
self.assertTrue(self.reg.sync_from_config(seed=False))
|
||||||
|
d = self.reg.by_alias("frame")
|
||||||
|
self.assertEqual((d["user"], d["port"]), ("deck", 2200))
|
||||||
|
|
||||||
|
def test_removed_headset_stays_removed_until_setup_changes_it(self):
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
second = self.reg.by_alias("frame-2")
|
||||||
|
self.reg.remove_device(second["id"])
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
self.assertIsNone(self.reg.by_alias("frame-2"))
|
||||||
|
self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
self.assertIsNotNone(self.reg.by_alias("frame-2"))
|
||||||
|
|
||||||
|
def test_corrupt_registry_is_ignored(self):
|
||||||
|
(self.dir / "bad.json").write_text("{not json")
|
||||||
|
self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), [])
|
||||||
|
(self.dir / "evil.json").write_text(json.dumps({"devices": [
|
||||||
|
{"id": "x1", "alias": "-oProxyCommand=id", "addresses": []},
|
||||||
|
{"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]}))
|
||||||
|
devices = fd.Registry(self.dir / "evil.json").devices()
|
||||||
|
self.assertEqual([d["alias"] for d in devices], ["ok"])
|
||||||
|
self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SharedFile(Base):
|
||||||
|
"""The desktop app and a standalone server can share devices.json."""
|
||||||
|
|
||||||
|
def test_one_server_never_saves_over_anothers_change(self):
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
other = fd.Registry(self.dir / "devices.json") # a second server, loaded now
|
||||||
|
d = self.reg.by_alias("frame")
|
||||||
|
self.reg.add_address(d["id"], "100.101.1.2", "tailscale")
|
||||||
|
other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy
|
||||||
|
hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]]
|
||||||
|
self.assertIn("100.101.1.2", hosts)
|
||||||
|
self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it
|
||||||
|
|
||||||
|
def test_another_servers_choice_of_headset_doesnt_move_this_one(self):
|
||||||
|
self.reg.sync_from_config(seed=False)
|
||||||
|
other = fd.Registry(self.dir / "devices.json")
|
||||||
|
mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"]
|
||||||
|
self.reg.set_active(mine)
|
||||||
|
other.set_active(theirs)
|
||||||
|
self.assertEqual(self.reg.active(), mine) # after a refresh
|
||||||
|
self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file
|
||||||
|
self.assertEqual(self.reg.active(), mine)
|
||||||
|
self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start
|
||||||
|
|
||||||
|
class ConfigRewrite(Base):
|
||||||
|
def test_hostname_user_and_port_change_only_inside_the_block(self):
|
||||||
|
cfg = self.ssh / "config"
|
||||||
|
self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237"))
|
||||||
|
text = cfg.read_text()
|
||||||
|
self.assertIn(" HostName 192.168.1.237\n", text)
|
||||||
|
self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved
|
||||||
|
self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write
|
||||||
|
self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck"))
|
||||||
|
block = fd.parse_blocks(cfg.read_text())[0]
|
||||||
|
self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237"))
|
||||||
|
self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: said explicitly
|
||||||
|
self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22)
|
||||||
|
self.assertIn(" Port 22\n", cfg.read_text())
|
||||||
|
self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched
|
||||||
|
if os.name != "nt":
|
||||||
|
self.assertEqual(cfg.stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_concurrent_edits_all_land(self):
|
||||||
|
import threading
|
||||||
|
def edit(alias, prefix):
|
||||||
|
for n in range(15):
|
||||||
|
fd.rewrite_block(alias, hostname=f"{prefix}.{n}")
|
||||||
|
threads = [threading.Thread(target=edit, args=("frame", "10.0.0")),
|
||||||
|
threading.Thread(target=edit, args=("frame-2", "10.0.1"))]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
blocks = fd.parse_blocks((self.ssh / "config").read_text())
|
||||||
|
self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"])
|
||||||
|
self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left
|
||||||
|
|
||||||
|
def test_learning_skips_a_block_someone_changed(self):
|
||||||
|
# The connector learned an address, but Set Up Connection moved the block meanwhile.
|
||||||
|
self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9",
|
||||||
|
expect={"hostname": "old.example", "user": None, "port": None}))
|
||||||
|
self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text())
|
||||||
|
self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9",
|
||||||
|
expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22}))
|
||||||
|
|
||||||
|
def test_zone_is_escaped_and_read_back(self):
|
||||||
|
fd.rewrite_block("frame", hostname="fe80::1%en0")
|
||||||
|
self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
|
||||||
|
self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0")
|
||||||
|
|
||||||
|
def test_missing_block_is_left_alone(self):
|
||||||
|
self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1"))
|
||||||
|
self.assertFalse(fd.remove_block("frame-9"))
|
||||||
|
self.assertTrue(fd.remove_block("frame-2"))
|
||||||
|
self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen")
|
||||||
|
class Pins(Base):
|
||||||
|
def test_seed_copies_the_trusted_key_under_the_device_alias(self):
|
||||||
|
(self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n")
|
||||||
|
self.assertFalse(fd.pinned("d1"))
|
||||||
|
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
|
||||||
|
self.assertTrue(fd.pinned("d1"))
|
||||||
|
self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n")
|
||||||
|
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
|
||||||
|
self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1)
|
||||||
|
self.assertFalse(fd.seed_pin("d2", ["never-seen.example"]))
|
||||||
|
self.assertTrue(fd.forget_pin("d1"))
|
||||||
|
self.assertFalse(fd.pinned("d1"))
|
||||||
|
self.assertFalse(fd.forget_pin("d1"))
|
||||||
|
|
||||||
|
def test_each_headset_has_its_own_file(self):
|
||||||
|
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
|
||||||
|
fd.seed_pin("da", ["a.local"])
|
||||||
|
fd.seed_pin("db", ["b.local"])
|
||||||
|
fd.forget_pin("da")
|
||||||
|
self.assertTrue(fd.pinned("db")) # forgetting one can't touch another
|
||||||
|
self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db"))
|
||||||
|
|
||||||
|
def test_hashed_and_non_default_port_entries(self):
|
||||||
|
kh = self.ssh / "known_hosts"
|
||||||
|
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
||||||
|
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
|
||||||
|
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||||
|
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
||||||
|
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
||||||
|
|
||||||
|
def test_hashed_pins_are_found_and_forgotten(self):
|
||||||
|
target = fd.known_hosts("d4")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(f"frame-control-d4 {KEY}\n")
|
||||||
|
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
|
||||||
|
self.assertNotIn("frame-control-d4", target.read_text())
|
||||||
|
self.assertTrue(fd.pinned("d4"))
|
||||||
|
self.assertTrue(fd.forget_pin("d4"))
|
||||||
|
self.assertFalse(fd.pinned("d4"))
|
||||||
|
|
||||||
|
def test_known_hosts_option_uses_the_override(self):
|
||||||
|
self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5"))
|
||||||
|
os.environ.pop("FRAME_CONTROL_SSH_DIR")
|
||||||
|
self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on
|
||||||
|
|
||||||
|
|
||||||
|
class Registry(Base):
|
||||||
|
def test_address_editing(self):
|
||||||
|
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||||
|
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||||
|
self.assertEqual(a["kind"], "mdns")
|
||||||
|
self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale")
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.add_address(d["id"], "frame-3.local") # already there
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.add_address(d["id"], "frame-3.local; id")
|
||||||
|
self.reg.move_address(d["id"], "100.100.1.1", -1)
|
||||||
|
self.reg.move_address(d["id"], "100.100.1.1", -1)
|
||||||
|
self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays
|
||||||
|
hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]]
|
||||||
|
self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"])
|
||||||
|
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2)
|
||||||
|
self.reg.update_address(d["id"], "192.168.1.40", label="Home")
|
||||||
|
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel")
|
||||||
|
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged
|
||||||
|
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41")
|
||||||
|
moved = self.reg.get(d["id"])["addresses"][1]
|
||||||
|
self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None))
|
||||||
|
self.reg.remove_address(d["id"], "192.168.1.41")
|
||||||
|
self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"])
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.remove_address(d["id"], "nope")
|
||||||
|
|
||||||
|
def test_devices(self):
|
||||||
|
a = self.reg.add_device("frame")
|
||||||
|
b = self.reg.add_device("frame-2", name="Office")
|
||||||
|
self.assertEqual(self.reg.active(), a["id"])
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.add_device("frame")
|
||||||
|
self.reg.set_active(b["id"])
|
||||||
|
self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222)
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.update_device(b["id"], user="bad user")
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.update_device(b["id"], user="steam", port="bad")
|
||||||
|
self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing
|
||||||
|
self.reg.remove_device(b["id"])
|
||||||
|
self.assertEqual(self.reg.active(), a["id"])
|
||||||
|
self.assertFalse(self.reg.emptied())
|
||||||
|
self.reg.remove_device(a["id"])
|
||||||
|
self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all
|
||||||
|
self.reg.add_device("frame-4")
|
||||||
|
self.assertFalse(self.reg.emptied())
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.get(b["id"])
|
||||||
|
|
||||||
|
def test_networks_get_names(self):
|
||||||
|
net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True}
|
||||||
|
self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1")
|
||||||
|
self.reg.record_network(net)
|
||||||
|
self.reg.name_network("n-1", "Home Wi-Fi")
|
||||||
|
self.assertEqual(self.reg.network_name(net), "Home Wi-Fi")
|
||||||
|
self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe")
|
||||||
|
self.assertEqual(self.reg.network_name(None), "No network")
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
self.reg.name_network("n-unknown", "x")
|
||||||
|
|
||||||
|
|
||||||
|
class Order(unittest.TestCase):
|
||||||
|
def addr(self, host, kind, networks=()):
|
||||||
|
return {"host": host, "kind": kind, "networks": list(networks)}
|
||||||
|
|
||||||
|
def test_known_here_then_mdns_then_tailscale_then_the_rest(self):
|
||||||
|
addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"),
|
||||||
|
self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"),
|
||||||
|
self.addr("192.168.1.237", "lan", ["n-home"])]
|
||||||
|
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||||
|
self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"])
|
||||||
|
# Tailscale off: its addresses go last.
|
||||||
|
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)]
|
||||||
|
self.assertEqual(order[-1], "100.64.1.2")
|
||||||
|
# On an unknown network nothing has worked yet; the user's order breaks ties.
|
||||||
|
ranked = fd.order_addresses(addrs, None, True)
|
||||||
|
self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"])
|
||||||
|
self.assertEqual(ranked[0][1], "mDNS name")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingLongLivedSsh(unittest.TestCase):
|
||||||
|
"""The keyboard agent and the Mac view keep their own ssh open: switching headset
|
||||||
|
must end or retarget them, or input would go on reaching the old headset."""
|
||||||
|
|
||||||
|
def test_switching_headset_stops_input_and_retargets_the_mac_view(self):
|
||||||
|
import server
|
||||||
|
from unittest import mock
|
||||||
|
before = (server.FRAME, list(server.HOST_OPTS))
|
||||||
|
self.addCleanup(lambda: server.route(*before))
|
||||||
|
with mock.patch.object(server._input, "stop") as stop, \
|
||||||
|
mock.patch.object(server.macview, "retarget") as retarget:
|
||||||
|
server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address
|
||||||
|
stop.assert_not_called()
|
||||||
|
server.route("frame-2", ["-o", "HostName=192.0.2.2"])
|
||||||
|
stop.assert_called_once()
|
||||||
|
retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"])
|
||||||
@@ -630,3 +630,194 @@ class PageQueue(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class PadEvents(unittest.TestCase):
|
||||||
|
"""The trackpad and key row go through gamescope; accents and emoji go the KDE Connect way."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
import server
|
||||||
|
cls.pad = staticmethod(server.pad_events)
|
||||||
|
|
||||||
|
def test_moves_clicks_and_scroll(self):
|
||||||
|
out, extra = self.pad([{"dx": 3, "dy": -2}, {"singleclick": True}, {"singlehold": True},
|
||||||
|
{"singlerelease": True}, {"scroll": True, "dy": 1}, {"rightclick": True}])
|
||||||
|
self.assertEqual(out[0], {"dx": 3, "dy": -2})
|
||||||
|
self.assertEqual(out[1:3], [{"button": "left", "down": True}, {"button": "left", "down": False}])
|
||||||
|
self.assertEqual(out[3:5], [{"button": "left", "down": True}, {"button": "left", "down": False}])
|
||||||
|
self.assertEqual(out[5], {"scroll": [0, -15]}) # up = content follows the finger
|
||||||
|
self.assertEqual(out[6:], [{"button": "right", "down": True}, {"button": "right", "down": False}])
|
||||||
|
self.assertEqual(extra, [])
|
||||||
|
|
||||||
|
def test_keys_and_modifiers(self):
|
||||||
|
out, _ = self.pad([{"specialKey": 12}, {"specialKey": 1, "ctrl": True}, {"key": "c", "ctrl": True}])
|
||||||
|
self.assertEqual(out[:2], [{"key": 28, "down": True}, {"key": 28, "down": False}])
|
||||||
|
self.assertEqual([e["key"] for e in out[2:6]], [29, 14, 14, 29]) # Ctrl+Backspace
|
||||||
|
self.assertEqual([(e["key"], e["down"]) for e in out[6:]],
|
||||||
|
[(29, True), (46, True), (46, False), (29, False)]) # Ctrl+C
|
||||||
|
|
||||||
|
def test_text_stays_ascii_through_gamescope(self):
|
||||||
|
out, slow = self.pad([{"key": "Hi "}, {"key": "there"}])
|
||||||
|
self.assertEqual(out, [{"text": "Hi there"}])
|
||||||
|
self.assertEqual(slow, [])
|
||||||
|
|
||||||
|
def test_keyboard_goes_to_kde_whole_and_in_order(self):
|
||||||
|
events = [{"key": "a"}, {"key": "é"}, {"specialKey": 12}, {"key": "c", "ctrl": True}, {"dx": 2, "dy": 1}]
|
||||||
|
out, slow = self.pad(events, kde=True)
|
||||||
|
self.assertEqual(out, [{"dx": 2, "dy": 1}]) # the pointer still goes through gamescope
|
||||||
|
self.assertEqual(slow, events[:4]) # the keyboard as sent, in order
|
||||||
|
|
||||||
|
def test_shifted_key_with_modifier(self):
|
||||||
|
out, _ = self.pad([{"key": "A", "ctrl": True}])
|
||||||
|
self.assertEqual([e["key"] for e in out], [29, 42, 30, 30, 42, 29])
|
||||||
|
|
||||||
|
|
||||||
|
class PadDelivery(unittest.TestCase):
|
||||||
|
"""remote_input's promises: "sent" means the whole batch is taken; accents go once, in order."""
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
def __init__(self, sent=True, state="ready"):
|
||||||
|
self.sent, self.state, self.got = sent, state, []
|
||||||
|
|
||||||
|
def send(self, events):
|
||||||
|
if events and self.sent:
|
||||||
|
self.got.append(events)
|
||||||
|
return {"state": self.state, "sent": self.sent and bool(events)}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import server
|
||||||
|
self.s = server
|
||||||
|
self.saved = (server._touch, server._input, server.KDE_WAIT)
|
||||||
|
server._touch, server._input = self.Agent(), self.Agent()
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
|
||||||
|
self.settle()
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.s._kde_queue.clear()
|
||||||
|
self.s._kde_until = self.s._typed_until = 0.0
|
||||||
|
self.s._kde_worker[0] = None
|
||||||
|
|
||||||
|
def settle(self):
|
||||||
|
worker = self.s._kde_worker[0]
|
||||||
|
if worker:
|
||||||
|
worker.join(5)
|
||||||
|
|
||||||
|
def typed(self):
|
||||||
|
return [e for batch in self.s._input.got for e in batch]
|
||||||
|
|
||||||
|
def test_accent_only_batch_is_acknowledged_and_typed_once(self):
|
||||||
|
status = self.s.remote_input({"events": [{"key": "é"}]})
|
||||||
|
self.assertTrue(status["sent"]) # so the page doesn't send it again
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||||
|
|
||||||
|
def test_mixed_text_stays_in_one_ordered_stream(self):
|
||||||
|
self.s.remote_input({"events": [{"key": "aéb"}, {"specialKey": 12}]})
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.typed(), [{"key": "aéb"}, {"specialKey": 12}])
|
||||||
|
self.assertEqual(self.s._touch.got, []) # none of it split off through gamescope
|
||||||
|
|
||||||
|
def test_batch_gamescope_didnt_take_is_not_queued(self):
|
||||||
|
self.s._touch = self.Agent(sent=False, state="starting")
|
||||||
|
status = self.s.remote_input({"events": [{"dx": 1, "dy": 1}, {"key": "é"}]})
|
||||||
|
self.assertFalse(status["sent"]) # the page tries the whole batch again...
|
||||||
|
self.assertEqual(self.s._kde_queue, []) # ...so the accent isn't queued twice
|
||||||
|
|
||||||
|
def test_waits_for_kde_connect_then_sends_without_more_input(self):
|
||||||
|
self.s._input = self.Agent(sent=False, state="starting")
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}]})
|
||||||
|
time.sleep(0.2)
|
||||||
|
self.assertEqual(self.s._kde_queue, [{"key": "é"}])
|
||||||
|
self.s._input.sent = True
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||||
|
self.assertEqual(self.s._kde_queue, [])
|
||||||
|
|
||||||
|
def test_later_ascii_waits_behind_a_pending_accent(self):
|
||||||
|
self.s._input = self.Agent(sent=False, state="starting")
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}]})
|
||||||
|
self.s.remote_input({"events": [{"key": "x"}]})
|
||||||
|
self.assertEqual(self.s._touch.got, []) # "x" can't overtake the "é"
|
||||||
|
self.assertEqual(self.s._kde_queue, [{"key": "é"}, {"key": "x"}])
|
||||||
|
self.s._input.sent = True
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
|
||||||
|
|
||||||
|
def test_queue_is_bounded_and_dropped_on_error(self):
|
||||||
|
self.s._input = self.Agent(sent=False, state="error")
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.s._kde_queue, [])
|
||||||
|
self.s._input = self.Agent(sent=False, state="starting")
|
||||||
|
self.s.KDE_WAIT = 0.3
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||||
|
self.assertLessEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.s._kde_queue, [])
|
||||||
|
|
||||||
|
def test_ascii_just_after_an_accent_stays_behind_it(self):
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}]})
|
||||||
|
self.settle()
|
||||||
|
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.s._touch.got, [])
|
||||||
|
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
|
||||||
|
|
||||||
|
def test_accent_just_after_gamescope_text_waits_for_it(self):
|
||||||
|
self.s.remote_input({"events": [{"key": "x" * 50}]})
|
||||||
|
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
|
||||||
|
start = time.time()
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}]})
|
||||||
|
self.settle()
|
||||||
|
self.assertGreater(time.time() - start, 0.6) # 50 keys at 16 ms, plus the margin
|
||||||
|
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||||
|
|
||||||
|
def test_typing_time_counts_every_key_transition(self):
|
||||||
|
t = self.s.typing_seconds
|
||||||
|
self.assertAlmostEqual(t([{"text": "ab"}]), 4 * 0.008)
|
||||||
|
self.assertAlmostEqual(t([{"text": "A"}]), 4 * 0.008) # shift down, key down, key up, shift up
|
||||||
|
self.assertAlmostEqual(t([{"text": "x" * 500}]), 8.0) # a long paste
|
||||||
|
self.assertAlmostEqual(t([{"key": 29, "down": True}, {"dx": 3, "dy": 0}]), 0.008)
|
||||||
|
|
||||||
|
def test_the_margin_is_added_once_not_per_request(self):
|
||||||
|
for _ in range(10):
|
||||||
|
self.s.remote_input({"events": [{"key": "ab"}]})
|
||||||
|
# ten requests of 4 transitions each: the work queues up, with no margin added per request
|
||||||
|
self.assertLess(self.s._typed_until - time.time(), 10 * 4 * 0.008 + 0.05)
|
||||||
|
|
||||||
|
def test_trimming_while_sending_doesnt_drop_unsent_events(self):
|
||||||
|
slow = self.Agent()
|
||||||
|
gate = threading.Event()
|
||||||
|
send = slow.send
|
||||||
|
|
||||||
|
def held(events):
|
||||||
|
gate.wait(5)
|
||||||
|
return send(events)
|
||||||
|
slow.send = held
|
||||||
|
self.s._input = slow
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||||
|
time.sleep(0.2) # the worker has taken its 150 and is mid-send
|
||||||
|
self.s.remote_input({"events": [{"key": "ü"}] * 150})
|
||||||
|
gate.set()
|
||||||
|
self.settle()
|
||||||
|
sent = self.typed()
|
||||||
|
self.assertEqual(sent[:150], [{"key": "é"}] * 150)
|
||||||
|
self.assertEqual(sent[150:], [{"key": "ü"}] * 150) # none of the new ones went missing
|
||||||
|
|
||||||
|
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
|
||||||
|
for _ in range(40):
|
||||||
|
self.reset()
|
||||||
|
self.s._input.got.clear()
|
||||||
|
self.s.remote_input({"events": [{"key": "é"}]})
|
||||||
|
time.sleep(0.002)
|
||||||
|
self.s.remote_input({"events": [{"key": "ü"}]})
|
||||||
|
self.settle()
|
||||||
|
self.assertEqual(self.s._kde_queue, [])
|
||||||
|
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
"""frame_link: finding a headset among its addresses and following each stage of
|
||||||
|
connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer.
|
||||||
|
Also the server's /api/connection, its event stream, and /api/devices.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_devices as fd # noqa: E402
|
||||||
|
import frame_link as fl # noqa: E402
|
||||||
|
import frame_network as fn # noqa: E402
|
||||||
|
|
||||||
|
FAKESSH = ROOT / "tests" / "fakessh"
|
||||||
|
NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0",
|
||||||
|
"ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}}
|
||||||
|
|
||||||
|
|
||||||
|
def explain(msg):
|
||||||
|
"""A cut-down server.unreachable, so this needs no server import."""
|
||||||
|
if "Could not resolve" in msg:
|
||||||
|
return "Can't find the Frame on the network."
|
||||||
|
if "refused" in msg:
|
||||||
|
return "The Frame refused the connection."
|
||||||
|
if "timed out" in msg.lower():
|
||||||
|
return "The Frame isn't answering."
|
||||||
|
if "Permission denied" in msg:
|
||||||
|
return "The Frame didn't accept this computer's SSH key."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Probe(unittest.TestCase):
|
||||||
|
def test_answers_refusals_and_unknown_names(self):
|
||||||
|
with socket.socket() as srv:
|
||||||
|
srv.bind(("127.0.0.1", 0))
|
||||||
|
srv.listen(4)
|
||||||
|
port = srv.getsockname()[1]
|
||||||
|
seen = []
|
||||||
|
res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"]))
|
||||||
|
self.assertEqual(res["state"], "answered")
|
||||||
|
self.assertEqual(res["ip"], "127.0.0.1")
|
||||||
|
self.assertIsInstance(res["rtt_ms"], float)
|
||||||
|
self.assertEqual(seen, ["resolving", "trying"])
|
||||||
|
# Closed now. Windows retries a refused connect for about 2 s before saying so.
|
||||||
|
self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused")
|
||||||
|
self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved")
|
||||||
|
|
||||||
|
def test_failed_probes_read_like_ssh(self):
|
||||||
|
# So the server's UNREACHABLE table words them like any other ssh failure.
|
||||||
|
self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"}))
|
||||||
|
self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"}))
|
||||||
|
self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"}))
|
||||||
|
|
||||||
|
|
||||||
|
class Pick(unittest.TestCase):
|
||||||
|
def pick(self, results, tried=()):
|
||||||
|
return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5)
|
||||||
|
|
||||||
|
def test_best_ranked_answer_wins(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
ok = lambda t=now: {"state": "answered", "t": t}
|
||||||
|
no = {"state": "timeout", "t": now}
|
||||||
|
self.assertEqual(self.pick([no, ok(), ok()]), 1)
|
||||||
|
self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2)
|
||||||
|
self.assertIsNone(self.pick([no, no]))
|
||||||
|
# A worse-ranked answer waits PREFER for a better one still trying, then goes.
|
||||||
|
t0 = time.monotonic()
|
||||||
|
self.assertEqual(self.pick([None, ok(time.monotonic())]), 1)
|
||||||
|
self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05)
|
||||||
|
|
||||||
|
def test_gives_up_on_slow_lookups_at_the_deadline(self):
|
||||||
|
t0 = time.monotonic()
|
||||||
|
results = [None]
|
||||||
|
self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3))
|
||||||
|
self.assertLess(time.monotonic() - t0, 2)
|
||||||
|
self.assertEqual(results[0]["state"], "timeout")
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
|
||||||
|
class Connecting(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = Path(tempfile.mkdtemp(prefix="frame-link-"))
|
||||||
|
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||||
|
(self.dir / "ssh").mkdir()
|
||||||
|
self.log = self.dir / "calls.jsonl"
|
||||||
|
env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log),
|
||||||
|
"FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
|
||||||
|
patcher = mock.patch.dict(os.environ, env)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))):
|
||||||
|
p = mock.patch.object(fn, name, value)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
self.srv = socket.socket()
|
||||||
|
self.srv.bind(("127.0.0.1", 0))
|
||||||
|
self.srv.listen(16)
|
||||||
|
self.addCleanup(self.srv.close)
|
||||||
|
self.port = self.srv.getsockname()[1]
|
||||||
|
self.reg = fd.Registry(self.dir / "devices.json")
|
||||||
|
self.routes = []
|
||||||
|
self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"],
|
||||||
|
control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))),
|
||||||
|
explain=explain)
|
||||||
|
self.addCleanup(self.link.stop)
|
||||||
|
|
||||||
|
def test_start_routes_to_the_saved_headset_before_serving(self):
|
||||||
|
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
|
||||||
|
b = self.reg.add_device("frame-2", hosts=["192.0.2.2"])
|
||||||
|
self.reg.set_active(b["id"])
|
||||||
|
with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies
|
||||||
|
self.link.start()
|
||||||
|
self.assertEqual(self.routes[0][0], "frame-2")
|
||||||
|
self.assertIn("HostName=192.0.2.2", self.routes[0][1])
|
||||||
|
self.assertNotEqual(a["id"], b["id"])
|
||||||
|
|
||||||
|
def test_headset_removed_elsewhere_mid_install_reaches_nothing(self):
|
||||||
|
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
|
||||||
|
self.reg.add_device("frame-2", hosts=["192.0.2.2"])
|
||||||
|
self.reg.set_active(a["id"])
|
||||||
|
other = fd.Registry(self.dir / "devices.json") # another Frame Control server
|
||||||
|
other.remove_device(a["id"])
|
||||||
|
self.link.work = lambda: 1
|
||||||
|
self.assertTrue(self.link.active_device().get("none"))
|
||||||
|
self.link.work = lambda: 0
|
||||||
|
self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on
|
||||||
|
|
||||||
|
def test_nothing_elsewhere_moves_a_running_install(self):
|
||||||
|
"""A bare alias in use, then another server sets up and picks a headset."""
|
||||||
|
self.link.override = None
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||||
|
self.link.connect(["start"])
|
||||||
|
started = self.routes[-1]
|
||||||
|
other = fd.Registry(self.dir / "devices.json")
|
||||||
|
other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"])
|
||||||
|
self.link.work = lambda: 1
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||||
|
self.link.close_master()
|
||||||
|
self.link.connect(["dropped"])
|
||||||
|
self.assertEqual(self.routes[-1][0], started[0])
|
||||||
|
self.assertTrue(self.link.deferred)
|
||||||
|
|
||||||
|
def listen6(self):
|
||||||
|
"""A "different device": the same port on IPv6 loopback."""
|
||||||
|
try:
|
||||||
|
six = socket.socket(socket.AF_INET6)
|
||||||
|
self.addCleanup(six.close)
|
||||||
|
six.bind(("::1", self.port))
|
||||||
|
six.listen(4)
|
||||||
|
except OSError:
|
||||||
|
self.skipTest("no IPv6 loopback")
|
||||||
|
|
||||||
|
def pin(self, device_id):
|
||||||
|
fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n")
|
||||||
|
|
||||||
|
def hosts(self, mapping):
|
||||||
|
# An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1.
|
||||||
|
if "localhost" in mapping:
|
||||||
|
mapping = dict({"127.0.0.1": mapping["localhost"]}, **mapping)
|
||||||
|
os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
|
||||||
|
|
||||||
|
def calls(self):
|
||||||
|
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
|
||||||
|
|
||||||
|
def device(self, *hosts):
|
||||||
|
d = self.reg.add_device("frame-t", port=self.port, hosts=[])
|
||||||
|
for h in hosts:
|
||||||
|
self.reg.add_address(d["id"], h, kind="lan")
|
||||||
|
return d
|
||||||
|
|
||||||
|
def test_falls_through_to_the_address_that_is_really_the_headset(self):
|
||||||
|
# Tried in this order: a name that doesn't resolve, a different device, the headset.
|
||||||
|
self.listen6()
|
||||||
|
d = self.device("nothing.invalid", "::1", "127.0.0.1")
|
||||||
|
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual(s["phase"], "connected", s["error"])
|
||||||
|
self.assertEqual(s["via"]["host"], "127.0.0.1")
|
||||||
|
self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
|
||||||
|
rows = {p["host"]: p for p in s["probes"]}
|
||||||
|
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||||
|
self.assertEqual(rows["::1"]["state"], "sshfailed")
|
||||||
|
self.assertIn("different headset", rows["::1"]["detail"])
|
||||||
|
# Every ssh command was pointed at the winner, with the host key pinned per device.
|
||||||
|
alias, opts = self.routes[-1]
|
||||||
|
self.assertEqual(alias, "frame-t")
|
||||||
|
self.assertIn("HostName=127.0.0.1", opts)
|
||||||
|
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
|
||||||
|
self.assertIn(f"Port={self.port}", opts)
|
||||||
|
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
|
||||||
|
self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet
|
||||||
|
# ssh takes an option's first value: accept-new must come before the commands' own "yes".
|
||||||
|
self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes"))
|
||||||
|
self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key
|
||||||
|
self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts
|
||||||
|
# It learned: 127.0.0.1 works on this network.
|
||||||
|
learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
|
||||||
|
self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"])
|
||||||
|
self.assertEqual(learned["::1"]["networks"], [])
|
||||||
|
self.assertTrue(self.link.alive())
|
||||||
|
self.link.close_master()
|
||||||
|
self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir()))
|
||||||
|
|
||||||
|
def test_stages_are_published_as_they_happen(self):
|
||||||
|
self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
steps, versions = [], []
|
||||||
|
real = self.link.stage
|
||||||
|
|
||||||
|
def stage(sid, state, detail=None):
|
||||||
|
real(sid, state, detail)
|
||||||
|
steps.append((sid, state))
|
||||||
|
versions.append(self.link.snapshot()["version"])
|
||||||
|
self.link.stage = stage
|
||||||
|
before = self.link.snapshot()["version"]
|
||||||
|
self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet
|
||||||
|
self.link.connect(["start"])
|
||||||
|
started = [sid for sid, state in steps if state == "active"]
|
||||||
|
self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"])
|
||||||
|
self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"])
|
||||||
|
self.assertEqual(versions, sorted(versions)) # every step is a new version for the page
|
||||||
|
self.assertEqual(self.link.wait(before, 1)["phase"], "connected")
|
||||||
|
|
||||||
|
def test_nothing_answers(self):
|
||||||
|
self.device("nothing.invalid", "also-nothing.invalid")
|
||||||
|
self.link.connect(["start"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual(s["phase"], "failed")
|
||||||
|
self.assertEqual(s["error"]["stage"], "find")
|
||||||
|
self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.")
|
||||||
|
self.assertGreater(s["retry_at"], time.time())
|
||||||
|
self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"])
|
||||||
|
|
||||||
|
def test_refused_key_stops_at_login(self):
|
||||||
|
self.device("localhost")
|
||||||
|
self.hosts({"localhost": "denied"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
|
||||||
|
self.assertIn("SSH key", s["error"]["message"])
|
||||||
|
|
||||||
|
def test_pinned_identity_is_checked_strictly(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.pin(d["id"])
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
|
||||||
|
self.assertIn("StrictHostKeyChecking=yes", master)
|
||||||
|
|
||||||
|
def test_ssh_goes_to_the_ipv4_address_that_answered(self):
|
||||||
|
self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1"))
|
||||||
|
self.assertIn("HostName=127.0.0.1", self.routes[-1][1])
|
||||||
|
|
||||||
|
def test_no_headset_after_removing_them_all(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.reg.remove_device(d["id"])
|
||||||
|
self.link.connect(["switch"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None))
|
||||||
|
self.assertIn("No headset", s["error"]["message"])
|
||||||
|
self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"]))
|
||||||
|
|
||||||
|
def test_a_headset_without_addresses_reaches_nothing(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None)
|
||||||
|
self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry
|
||||||
|
self.link.connect(["switch"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual((s["phase"], s["retry_at"]), ("failed", None))
|
||||||
|
self.assertIn("no addresses", s["error"]["message"])
|
||||||
|
|
||||||
|
def test_switching_back_to_the_frame_alias_the_server_started_with(self):
|
||||||
|
self.link.override = self.link.session_alias = "frame-bare"
|
||||||
|
other = self.device("localhost")
|
||||||
|
ids = [d["id"] for d in fl.devices_view(self.link)["devices"]]
|
||||||
|
self.assertEqual(ids, ["alias-frame-bare", other["id"]])
|
||||||
|
fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None)
|
||||||
|
self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there
|
||||||
|
fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None)
|
||||||
|
self.assertEqual(self.link.active_device()["alias"], "frame-bare")
|
||||||
|
self.assertEqual(self.routes[-1][0], "frame-bare")
|
||||||
|
|
||||||
|
def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.link.override = self.link.session_alias = "frame-t"
|
||||||
|
fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None)
|
||||||
|
self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]])
|
||||||
|
|
||||||
|
def test_setup_changing_the_login_waits_for_installs(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
cfg = self.dir / "ssh" / "config"
|
||||||
|
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n"
|
||||||
|
f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n")
|
||||||
|
self.link.watch_config() # the block's login is recorded
|
||||||
|
routes = len(self.routes)
|
||||||
|
cfg.write_text(cfg.read_text().replace("User steamos", "User deck"))
|
||||||
|
running = [1]
|
||||||
|
self.link.work = lambda: running[0]
|
||||||
|
self.link.config_mtime = None
|
||||||
|
self.link.watch_config()
|
||||||
|
self.assertEqual(len(self.routes), routes) # an install is running: not yet
|
||||||
|
self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with
|
||||||
|
self.assertIn("User=steamos", self.routes[-1][1])
|
||||||
|
running[0] = 0
|
||||||
|
self.link.watch_config()
|
||||||
|
self.assertIn("User=deck", self.routes[-1][1])
|
||||||
|
|
||||||
|
def test_a_rename_leaves_the_login_in_the_config_alone(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
cfg = self.dir / "ssh" / "config"
|
||||||
|
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n"
|
||||||
|
"Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported
|
||||||
|
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
|
||||||
|
self.assertIn("User deck", cfg.read_text())
|
||||||
|
out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None)
|
||||||
|
self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is
|
||||||
|
self.assertIn("left as it is", out["message"])
|
||||||
|
|
||||||
|
def test_a_late_failure_from_the_last_headset_is_ignored(self):
|
||||||
|
self.link.state["phase"] = "connected"
|
||||||
|
self.link.gen = 3
|
||||||
|
self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch
|
||||||
|
self.assertEqual(self.link.kicks, [])
|
||||||
|
self.link.lost("ssh: connect to host b port 22: Operation timed out", 3)
|
||||||
|
self.assertEqual(len(self.link.kicks), 1)
|
||||||
|
|
||||||
|
def test_a_jump_hosts_login_isnt_the_headsets(self):
|
||||||
|
opts = ["-o", "HostName=10.0.0.5"]
|
||||||
|
self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame"))
|
||||||
|
self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame"))
|
||||||
|
self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".',
|
||||||
|
["-o", "HostName=FRAME.LOCAL"], "frame"))
|
||||||
|
|
||||||
|
def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self):
|
||||||
|
said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".",
|
||||||
|
"channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"]
|
||||||
|
self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""}
|
||||||
|
for i, _ in fl.STAGES]
|
||||||
|
self.assertEqual(self.link.failed("login", said, False, "frame"), "next")
|
||||||
|
self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"),
|
||||||
|
"stop")
|
||||||
|
|
||||||
|
def test_a_reconnect_being_started_isnt_a_live_connection(self):
|
||||||
|
self.link.state["phase"] = "connected"
|
||||||
|
self.assertTrue(self.link.alive())
|
||||||
|
self.link.busy = True # the loop took a Retry off the queue and is about to reconnect
|
||||||
|
self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it
|
||||||
|
|
||||||
|
def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self):
|
||||||
|
self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}])
|
||||||
|
self.link.probe_update(0, 1, state="answered", ip="10.0.0.2")
|
||||||
|
self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"})
|
||||||
|
|
||||||
|
def test_a_bare_alias_lets_ssh_config_decide(self):
|
||||||
|
self.link.override = "frame-bare"
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||||
|
self.link.connect(["start"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual(s["phase"], "connected", s["error"])
|
||||||
|
self.assertTrue(s["device"]["transient"])
|
||||||
|
# Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts).
|
||||||
|
alias, opts = self.routes[-1]
|
||||||
|
self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}",
|
||||||
|
"-o", "User=tester"]))
|
||||||
|
self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))])
|
||||||
|
|
||||||
|
def test_each_headset_has_its_own_shared_connection(self):
|
||||||
|
"""ssh's %C hashes only address, user and port: two headsets at one address
|
||||||
|
(one moved) must still never share a ControlMaster."""
|
||||||
|
a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222"))
|
||||||
|
pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b))
|
||||||
|
self.assertNotEqual(pa, pb)
|
||||||
|
self.assertIn("aaaa1111", pa)
|
||||||
|
long_alias = fl.Link.bare("x" * 64)
|
||||||
|
path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath="))
|
||||||
|
self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path
|
||||||
|
|
||||||
|
def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self):
|
||||||
|
self.link.override = "frame-bare"
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||||
|
self.link.connect(["start"])
|
||||||
|
pinned = self.routes[-1][1]
|
||||||
|
# ~/.ssh/config now sends the alias elsewhere, but an install is running.
|
||||||
|
self.link.work = lambda: 1
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)):
|
||||||
|
self.link.close_master()
|
||||||
|
self.link.connect(["dropped"])
|
||||||
|
self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go
|
||||||
|
self.assertEqual(self.link.snapshot()["phase"], "connected")
|
||||||
|
self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too
|
||||||
|
|
||||||
|
def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self):
|
||||||
|
d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly
|
||||||
|
self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"})
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
|
||||||
|
self.link.connect(["start"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"])
|
||||||
|
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset
|
||||||
|
|
||||||
|
def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self):
|
||||||
|
self.link.override = "frame-jump"
|
||||||
|
self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)):
|
||||||
|
self.link.connect(["start"])
|
||||||
|
s = self.link.snapshot()
|
||||||
|
self.assertEqual(s["phase"], "connected", s["error"])
|
||||||
|
self.assertEqual(s["via"]["why"], "through a jump host")
|
||||||
|
|
||||||
|
def test_changing_the_port_reroutes_even_if_the_attempt_fails(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
self.reg.update_device(d["id"], port=1) # nothing listens there
|
||||||
|
self.link.connect(["switch"])
|
||||||
|
self.assertEqual(self.link.snapshot()["phase"], "failed")
|
||||||
|
self.assertIn("Port=1", self.routes[-1][1])
|
||||||
|
|
||||||
|
def test_test_now_checks_every_address_without_touching_the_connection(self):
|
||||||
|
self.listen6()
|
||||||
|
d = self.device("::1", "127.0.0.1", "nothing.invalid")
|
||||||
|
self.pin(d["id"])
|
||||||
|
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
|
||||||
|
self.link.test(d["id"])
|
||||||
|
rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
|
||||||
|
self.assertEqual(rows["127.0.0.1"]["ssh"], "ok")
|
||||||
|
self.assertEqual(rows["::1"]["ssh"], "wrong")
|
||||||
|
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||||
|
self.assertEqual(self.routes, [])
|
||||||
|
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||||
|
|
||||||
|
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||||
|
self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
other = self.reg.add_device("frame-other", port=self.port)
|
||||||
|
self.reg.add_address(other["id"], "nothing.invalid")
|
||||||
|
self.link.use(other["id"])
|
||||||
|
self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt
|
||||||
|
self.assertEqual(self.link.snapshot()["phase"], "connecting")
|
||||||
|
self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master
|
||||||
|
self.link.connect(["switch"])
|
||||||
|
self.assertEqual(self.link.snapshot()["phase"], "failed")
|
||||||
|
alias, opts = self.routes[-1]
|
||||||
|
self.assertEqual(alias, "frame-other")
|
||||||
|
self.assertIn("HostName=nothing.invalid", opts)
|
||||||
|
self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts)
|
||||||
|
|
||||||
|
def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self):
|
||||||
|
self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
other = self.reg.add_device("frame-other", port=self.port)
|
||||||
|
self.reg.add_address(other["id"], "nothing.invalid")
|
||||||
|
pick = fl.Link.pick
|
||||||
|
|
||||||
|
def switch_then_pick(*args):
|
||||||
|
if not getattr(self, "switched", False):
|
||||||
|
self.switched = True
|
||||||
|
self.link.use(other["id"]) # the user switches while A is being found
|
||||||
|
return pick(*args)
|
||||||
|
with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)):
|
||||||
|
self.link.connect(["start"])
|
||||||
|
self.assertEqual(self.routes[-1][0], "frame-other")
|
||||||
|
self.assertEqual(self.link.snapshot()["phase"], "connecting")
|
||||||
|
self.assertFalse(self.link.alive())
|
||||||
|
self.assertIsNone(self.link.master)
|
||||||
|
|
||||||
|
def test_no_switching_while_something_is_installing(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
other = self.reg.add_device("frame-other")
|
||||||
|
for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]},
|
||||||
|
{"action": "update", "id": d["id"], "port": 2222},
|
||||||
|
{"action": "address-remove", "id": d["id"], "host": "localhost"},
|
||||||
|
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
|
||||||
|
{"action": "forget-identity", "id": d["id"]}):
|
||||||
|
with self.assertRaises(fd.DeviceError, msg=body):
|
||||||
|
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
|
||||||
|
# Renaming, or changing another headset, is fine.
|
||||||
|
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1)
|
||||||
|
fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1)
|
||||||
|
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
|
||||||
|
|
||||||
|
def test_no_reconnecting_under_a_running_install(self):
|
||||||
|
self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1)
|
||||||
|
fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs
|
||||||
|
|
||||||
|
def test_terminals_get_the_address_by_name(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
alias, opts = self.link.named_route()
|
||||||
|
self.assertEqual(alias, "frame-t")
|
||||||
|
self.assertIn("HostName=localhost", opts)
|
||||||
|
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
|
||||||
|
|
||||||
|
def test_renaming_during_an_install_is_fine(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
# The page sends the user and port along with the name, unchanged.
|
||||||
|
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos",
|
||||||
|
"port": str(self.port)}, None, busy=lambda: 1)
|
||||||
|
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
|
||||||
|
|
||||||
|
def test_a_rename_shows_at_once(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
self.hosts({"localhost": "ok"})
|
||||||
|
self.link.connect(["start"])
|
||||||
|
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
|
||||||
|
self.assertEqual(self.link.snapshot()["device"]["name"], "Desk")
|
||||||
|
|
||||||
|
def test_stopping_mid_handshake_leaves_no_ssh_behind(self):
|
||||||
|
self.device("localhost")
|
||||||
|
self.hosts({"localhost": "slow"})
|
||||||
|
t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True)
|
||||||
|
t.start()
|
||||||
|
for _ in range(100):
|
||||||
|
if self.link.pending:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
proc = self.link.pending
|
||||||
|
self.assertIsNotNone(proc)
|
||||||
|
self.link.stop()
|
||||||
|
t.join(10)
|
||||||
|
self.assertFalse(t.is_alive())
|
||||||
|
self.assertIsNotNone(proc.poll())
|
||||||
|
self.assertIsNone(self.link.master)
|
||||||
|
|
||||||
|
def test_test_now_goes_through_a_jump_host(self):
|
||||||
|
d = self.device("10.99.99.99")
|
||||||
|
self.pin(d["id"])
|
||||||
|
self.hosts({"10.99.99.99": "ok"})
|
||||||
|
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
|
||||||
|
self.link.test(d["id"])
|
||||||
|
row = self.link.snapshot()["tests"][d["id"]]["rows"][0]
|
||||||
|
self.assertEqual(row["ssh"], "ok", row)
|
||||||
|
self.assertIn("jump host", row["detail"])
|
||||||
|
|
||||||
|
def test_devices_api_checks_everything(self):
|
||||||
|
d = self.device("localhost")
|
||||||
|
bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"},
|
||||||
|
{"action": "address-add", "id": d["id"], "host": "a\nHost *"},
|
||||||
|
{"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"},
|
||||||
|
{"action": "update", "id": d["id"], "user": "root; id"},
|
||||||
|
{"action": "update", "id": d["id"], "port": 0},
|
||||||
|
{"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5},
|
||||||
|
{"action": "setup", "alias": "-F/etc/passwd"},
|
||||||
|
{"action": "setup", "alias": "frame-9", "host": "$(id)"},
|
||||||
|
{"action": "use", "id": "nope"},
|
||||||
|
{"action": "explode"}]
|
||||||
|
for body in bad:
|
||||||
|
with self.assertRaises(fd.DeviceError, msg=body):
|
||||||
|
fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran"))
|
||||||
|
# Removing every headset leaves none in use, rather than falling back to the `frame` alias.
|
||||||
|
spare = self.reg.add_device("frame-spare")
|
||||||
|
fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None)
|
||||||
|
# The only headset, whose ssh alias would stay: not without removing that too.
|
||||||
|
(self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n"
|
||||||
|
"Host *\n# <<< steam-frame (frame-t) <<<\n")
|
||||||
|
with self.assertRaises(fd.DeviceError):
|
||||||
|
fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None)
|
||||||
|
opened = []
|
||||||
|
out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"},
|
||||||
|
open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal")
|
||||||
|
self.assertEqual(opened, [("frame-9", "192.168.1.50")])
|
||||||
|
self.assertIn("frame-9", out["message"])
|
||||||
|
self.assertEqual(out["active"], d["id"])
|
||||||
|
self.assertEqual(fl.next_alias(self.link), "frame")
|
||||||
|
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
|
||||||
|
self.assertEqual(fl.next_alias(self.link), "frame-2")
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
|
||||||
|
class ServerConnection(unittest.TestCase):
|
||||||
|
"""The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-"))
|
||||||
|
ssh_dir = cls.dir / "ssh"
|
||||||
|
ssh_dir.mkdir()
|
||||||
|
cls.srv = socket.socket() # the "headset's" port 22
|
||||||
|
cls.srv.bind(("127.0.0.1", 0))
|
||||||
|
cls.srv.listen(16)
|
||||||
|
(ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n"
|
||||||
|
f" Port {cls.srv.getsockname()[1]}\n"
|
||||||
|
" User steamos\nHost *\n# <<< steam-frame (frame) <<<\n")
|
||||||
|
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir),
|
||||||
|
"FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"),
|
||||||
|
"FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok", "127.0.0.1": "ok"}),
|
||||||
|
"PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
|
||||||
|
env.pop("FRAME_ALIAS", None)
|
||||||
|
cls.log = tempfile.TemporaryFile()
|
||||||
|
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
|
||||||
|
stdout=subprocess.PIPE, stderr=cls.log, text=True)
|
||||||
|
cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.proc.terminate()
|
||||||
|
cls.proc.wait(timeout=15)
|
||||||
|
cls.proc.stdout.close()
|
||||||
|
cls.log.close()
|
||||||
|
cls.srv.close()
|
||||||
|
shutil.rmtree(cls.dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def request(self, method, path, body=None, key="1"):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
|
||||||
|
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
|
||||||
|
headers={"X-Frame-UI": key, "Content-Type": "application/json"})
|
||||||
|
r = conn.getresponse()
|
||||||
|
data = json.loads(r.read() or b"{}")
|
||||||
|
conn.close()
|
||||||
|
return r.status, data
|
||||||
|
|
||||||
|
def wait_connected(self):
|
||||||
|
for _ in range(100):
|
||||||
|
status, s = self.request("GET", "/api/connection")
|
||||||
|
if s.get("phase") in ("connected", "failed"):
|
||||||
|
return s
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.fail(f"never connected: {s}")
|
||||||
|
|
||||||
|
def test_imports_the_headset_and_connects_through_its_port(self):
|
||||||
|
s = self.wait_connected()
|
||||||
|
self.assertEqual(s["phase"], "connected", s["error"])
|
||||||
|
self.assertEqual(s["via"]["host"], "localhost")
|
||||||
|
self.assertEqual(s["device"]["alias"], "frame")
|
||||||
|
self.assertEqual(s["device"]["name"], "Steam Frame")
|
||||||
|
self.assertEqual(s["probes"][0]["host"], "localhost")
|
||||||
|
status, devices = self.request("GET", "/api/devices")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"])
|
||||||
|
self.assertEqual(devices["nextAlias"], "frame-2")
|
||||||
|
|
||||||
|
def test_events_stream_the_state(self):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
|
||||||
|
conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"})
|
||||||
|
r = conn.getresponse()
|
||||||
|
self.assertEqual(r.status, 200)
|
||||||
|
self.assertEqual(r.getheader("Content-Type"), "text/event-stream")
|
||||||
|
line = r.fp.readline()
|
||||||
|
self.assertTrue(line.startswith(b"data: "), line)
|
||||||
|
self.assertIn("stages", json.loads(line[6:]))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_changes_meant_for_another_headset_are_refused(self):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
|
||||||
|
conn.request("POST", "/api/launch", body=b'{"appid": "620"}',
|
||||||
|
headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"})
|
||||||
|
r = conn.getresponse()
|
||||||
|
self.assertEqual(r.status, 409)
|
||||||
|
self.assertIn("switched headsets", json.loads(r.read())["error"])
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_guards_and_validation(self):
|
||||||
|
self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403)
|
||||||
|
self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400)
|
||||||
|
self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400)
|
||||||
|
self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests
|
|||||||
import base64
|
import base64
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
@@ -42,6 +43,43 @@ class Helpers(unittest.TestCase):
|
|||||||
self.assertEqual(h, 1080)
|
self.assertEqual(h, 1080)
|
||||||
self.assertAlmostEqual(w / h, 0.5, places=2)
|
self.assertAlmostEqual(w / h, 0.5, places=2)
|
||||||
|
|
||||||
|
def test_the_tunnel_follows_the_headset(self):
|
||||||
|
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
|
||||||
|
mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
|
||||||
|
self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
|
||||||
|
|
||||||
|
class Tunnel:
|
||||||
|
ended = False
|
||||||
|
def poll(self): return None
|
||||||
|
def terminate(self): Tunnel.ended = True
|
||||||
|
mv.tunnel, mv.remote_port = Tunnel(), 47999
|
||||||
|
mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it
|
||||||
|
self.assertFalse(Tunnel.ended)
|
||||||
|
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel
|
||||||
|
self.assertTrue(Tunnel.ended)
|
||||||
|
self.assertIsNone(mv.tunnel)
|
||||||
|
self.assertEqual(mv.frame, "frame-2")
|
||||||
|
|
||||||
|
def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self):
|
||||||
|
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
|
||||||
|
mv.port = 47000
|
||||||
|
ended = []
|
||||||
|
|
||||||
|
class Proc:
|
||||||
|
def poll(self): return None
|
||||||
|
def terminate(self): ended.append(self)
|
||||||
|
def wait(self): return 0
|
||||||
|
stderr = io.StringIO("")
|
||||||
|
|
||||||
|
def probe(port):
|
||||||
|
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now
|
||||||
|
return True
|
||||||
|
with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \
|
||||||
|
mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe):
|
||||||
|
self.assertFalse(mv._open_tunnel([], [47001]))
|
||||||
|
self.assertIsNone(mv.tunnel)
|
||||||
|
self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||||
def test_launch_script_parses(self):
|
def test_launch_script_parses(self):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -16,6 +19,13 @@ import frame_splat as splat
|
|||||||
import server
|
import server
|
||||||
|
|
||||||
|
|
||||||
|
def stop_now():
|
||||||
|
"""What systemd's SIGTERM does to the player, without signalling the test process."""
|
||||||
|
handler = signal.getsignal(signal.SIGTERM)
|
||||||
|
if callable(handler):
|
||||||
|
handler(signal.SIGTERM, None)
|
||||||
|
|
||||||
|
|
||||||
class Media(unittest.TestCase):
|
class Media(unittest.TestCase):
|
||||||
def test_layout_evidence_and_override(self):
|
def test_layout_evidence_and_override(self):
|
||||||
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
|
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
|
||||||
@@ -56,6 +66,156 @@ class Media(unittest.TestCase):
|
|||||||
self.assertNotIn('-re', cmd)
|
self.assertNotIn('-re', cmd)
|
||||||
self.assertIn('-frames:v', cmd)
|
self.assertIn('-frames:v', cmd)
|
||||||
|
|
||||||
|
def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None,
|
||||||
|
fail=None, layout='auto'):
|
||||||
|
"""Run the player against a fake OpenVR; returns (status, pixels calls).
|
||||||
|
|
||||||
|
Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes
|
||||||
|
the n-th upload busy; every status written is kept in self.writes."""
|
||||||
|
calls = []
|
||||||
|
self.writes = []
|
||||||
|
write_status = player.write_status
|
||||||
|
|
||||||
|
def record(path, **values):
|
||||||
|
self.writes.append(values)
|
||||||
|
write_status(path, **values)
|
||||||
|
|
||||||
|
class FakeOverlay:
|
||||||
|
def create(self, *a, **k):
|
||||||
|
return len(calls)
|
||||||
|
|
||||||
|
def call(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pixels(self, handle, data, w, h):
|
||||||
|
calls.append((handle, w, h))
|
||||||
|
if on_pixels:
|
||||||
|
on_pixels(len(calls))
|
||||||
|
if len(calls) <= busy or (fail and fail(handle, len(calls))):
|
||||||
|
raise player.OverlayBusy('standby')
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# A Stop landing during cleanup must be ignored, not become an error.
|
||||||
|
# Call the installed handler directly: a real SIGTERM kills Windows.
|
||||||
|
stop_now()
|
||||||
|
|
||||||
|
frame = bytes(4*2*4)
|
||||||
|
proc = unittest.mock.MagicMock()
|
||||||
|
proc.stdout = io.BytesIO(frame*4)
|
||||||
|
proc.wait.return_value = 0
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
|
||||||
|
with tempfile.TemporaryDirectory() as d, \
|
||||||
|
patch.object(player, 'Overlay', FakeOverlay), \
|
||||||
|
patch.object(player, 'probe', return_value=(info or {'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \
|
||||||
|
patch.object(player.subprocess, 'Popen', return_value=proc), \
|
||||||
|
patch.object(player, 'write_status', side_effect=record), \
|
||||||
|
patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \
|
||||||
|
patch.object(player.time, 'sleep', side_effect=sleeps):
|
||||||
|
path = Path(d)/name
|
||||||
|
path.write_bytes(b'x')
|
||||||
|
status = Path(d)/'status.json'
|
||||||
|
try:
|
||||||
|
player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status)))
|
||||||
|
finally:
|
||||||
|
signal.signal(signal.SIGTERM, old[0])
|
||||||
|
signal.signal(signal.SIGINT, old[1])
|
||||||
|
return json.loads(status.read_text()), calls
|
||||||
|
|
||||||
|
def test_video_survives_standby_and_stop_after_end_stays_ended(self):
|
||||||
|
# Verified 2026-09-29: an unworn Frame enters standby within seconds and
|
||||||
|
# SetOverlayRaw then returns RequestFailed (23) until it wakes.
|
||||||
|
result, calls = self.play_with('clip_SBS.mp4', busy=3)
|
||||||
|
self.assertEqual((result['state'], result['frames']), ('ended', 4))
|
||||||
|
# Two video frames were dropped; the surround (handle 0) waited and was re-sent.
|
||||||
|
self.assertEqual(result['dropped'], 2)
|
||||||
|
self.assertIn((0, 1, 1), calls[3:])
|
||||||
|
|
||||||
|
def test_stop_mid_video_reports_stopped(self):
|
||||||
|
result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now())
|
||||||
|
self.assertEqual(result['state'], 'stopped')
|
||||||
|
|
||||||
|
def test_video_errors_when_steamvr_never_takes_frames(self):
|
||||||
|
with patch.object(player, 'BUSY_LIMIT', -1), \
|
||||||
|
self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'):
|
||||||
|
self.play_with('clip_SBS.mp4', busy=99)
|
||||||
|
|
||||||
|
def test_still_waits_out_standby_without_a_limit(self):
|
||||||
|
# Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown.
|
||||||
|
ticks = iter(range(10))
|
||||||
|
def sleep(_):
|
||||||
|
if next(ticks) == 8:
|
||||||
|
stop_now()
|
||||||
|
with patch.object(player, 'BUSY_LIMIT', -1):
|
||||||
|
result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep,
|
||||||
|
info={'codec_name': 'png', 'width': 4, 'height': 2})
|
||||||
|
self.assertEqual(result['state'], 'stopped')
|
||||||
|
screen = [c for c in calls if c[0] == 1]
|
||||||
|
self.assertGreater(len(screen), 1) # retried through standby
|
||||||
|
self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame
|
||||||
|
|
||||||
|
def still_with_late_surround(self, name, **kw):
|
||||||
|
# The screen takes its first frame while the surround is still refused
|
||||||
|
# (its first upload, the drain right after the screen, and one retry).
|
||||||
|
ticks = iter(range(10))
|
||||||
|
def sleep(_):
|
||||||
|
if next(ticks) == 5:
|
||||||
|
stop_now()
|
||||||
|
surround_tries = []
|
||||||
|
def fail(handle, n):
|
||||||
|
if handle == 0:
|
||||||
|
surround_tries.append(n)
|
||||||
|
return len(surround_tries) <= 3
|
||||||
|
return False
|
||||||
|
result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw)
|
||||||
|
self.assertEqual(result['state'], 'stopped')
|
||||||
|
screen = [c for c in calls if c[0] == 1]
|
||||||
|
surround = [c for c in calls if c[0] == 0]
|
||||||
|
self.assertEqual(len(screen), 1) # shown once, not re-sent every second
|
||||||
|
self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took
|
||||||
|
self.assertEqual(calls[-1][0], 0)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def test_photo_surround_recovers_after_screen_is_shown(self):
|
||||||
|
self.still_with_late_surround('photo_SBS.png',
|
||||||
|
info={'codec_name': 'png', 'width': 4, 'height': 2})
|
||||||
|
|
||||||
|
def test_splat_surround_recovers_after_screen_is_shown(self):
|
||||||
|
self.still_with_late_surround('scene.splat')
|
||||||
|
|
||||||
|
def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self):
|
||||||
|
self.assertEqual(player.BUSY_LIMIT, 300)
|
||||||
|
def run(times, busy):
|
||||||
|
# Upload n happens at times[n] seconds on a fake clock; 1 is the surround.
|
||||||
|
clock = [1000.0]
|
||||||
|
def on_pixels(n):
|
||||||
|
clock[0] = 1000.0 + times.get(n, times[max(times)])
|
||||||
|
with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]):
|
||||||
|
return self.play_with('clip_SBS.mp4', on_pixels=on_pixels,
|
||||||
|
fail=lambda h, n: h == 1 and n in busy)
|
||||||
|
# Busy for 299 s, then a frame lands: no error.
|
||||||
|
result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3})
|
||||||
|
self.assertEqual((result['state'], result['dropped']), ('ended', 2))
|
||||||
|
# An accepted frame resets the timer: 600 s busy in total, never 300 s in a row.
|
||||||
|
result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5})
|
||||||
|
self.assertEqual((result['state'], result['dropped']), ('ended', 3))
|
||||||
|
# 301 s in a row without an accepted frame is an error.
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'):
|
||||||
|
run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5})
|
||||||
|
|
||||||
|
def test_status_reports_where_the_layout_came_from(self):
|
||||||
|
video = {'codec_name': 'h264', 'width': 4, 'height': 2}
|
||||||
|
for name, layout, tags, expect in [
|
||||||
|
('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')),
|
||||||
|
('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')),
|
||||||
|
('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')),
|
||||||
|
('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]:
|
||||||
|
with self.subTest(name=name, layout=layout):
|
||||||
|
self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video)
|
||||||
|
playing = self.writes[0]
|
||||||
|
self.assertEqual(playing['state'], 'playing')
|
||||||
|
self.assertEqual((playing['layout'], playing['source']), expect)
|
||||||
|
|
||||||
def test_fake_frame_library_and_traversal(self):
|
def test_fake_frame_library_and_traversal(self):
|
||||||
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
||||||
identity = 'a'*32+'/space and quote\'.png'
|
identity = 'a'*32+'/space and quote\'.png'
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""frame_network's parsers, with what macOS, Linux and Windows print.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_network as fn # noqa: E402
|
||||||
|
|
||||||
|
MAC_ROUTE = """ route to: default
|
||||||
|
destination: default
|
||||||
|
mask: default
|
||||||
|
gateway: 192.168.1.1
|
||||||
|
interface: en0
|
||||||
|
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
|
||||||
|
"""
|
||||||
|
MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n"
|
||||||
|
MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n"
|
||||||
|
MAC_SUMMARY = """<dictionary> {
|
||||||
|
BSSID : <redacted>
|
||||||
|
ConnectionID : 1
|
||||||
|
InterfaceType : WiFi
|
||||||
|
LinkStatusActive : TRUE
|
||||||
|
NetworkID : <redacted>
|
||||||
|
SSID : <redacted>
|
||||||
|
Security : WPA2_PSK
|
||||||
|
}"""
|
||||||
|
MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : <redacted>\n Security", "SSID : Home Net\n Security")
|
||||||
|
MAC_SUMMARY_WIRED = "<dictionary> {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}"
|
||||||
|
|
||||||
|
LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600
|
||||||
|
default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100
|
||||||
|
"""
|
||||||
|
LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n"
|
||||||
|
NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n"
|
||||||
|
|
||||||
|
WIN_ROUTE = """===========================================================================
|
||||||
|
Interface List
|
||||||
|
12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz
|
||||||
|
===========================================================================
|
||||||
|
|
||||||
|
IPv4 Route Table
|
||||||
|
===========================================================================
|
||||||
|
Active Routes:
|
||||||
|
Network Destination Netmask Gateway Interface Metric
|
||||||
|
0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50
|
||||||
|
0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35
|
||||||
|
===========================================================================
|
||||||
|
Persistent Routes:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
WIN_ARP = """
|
||||||
|
Interface: 192.168.1.50 --- 0xc
|
||||||
|
Internet Address Physical Address Type
|
||||||
|
192.168.1.1 b4-fb-e4-b5-67-55 dynamic
|
||||||
|
"""
|
||||||
|
NETSH = """
|
||||||
|
There is 1 interface on the system:
|
||||||
|
|
||||||
|
Name : Wi-Fi
|
||||||
|
Description : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||||
|
State : connected
|
||||||
|
SSID : Office 5G
|
||||||
|
BSSID : 12:34:56:78:9a:bc
|
||||||
|
Network type : Infrastructure
|
||||||
|
"""
|
||||||
|
NETSH_OFF = NETSH.replace("State : connected", "State : disconnected")
|
||||||
|
|
||||||
|
TAILSCALE = json.dumps({
|
||||||
|
"BackendState": "Running",
|
||||||
|
"CurrentTailnet": {"Name": "example.github"},
|
||||||
|
"Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]},
|
||||||
|
"Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True,
|
||||||
|
"TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1234:5678"]},
|
||||||
|
"nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False,
|
||||||
|
"TailscaleIPs": ["100.77.1.2"]}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class Parsers(unittest.TestCase):
|
||||||
|
def test_macos(self):
|
||||||
|
self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0"))
|
||||||
|
self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None))
|
||||||
|
self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded
|
||||||
|
self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1"))
|
||||||
|
self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10"))
|
||||||
|
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission
|
||||||
|
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True))
|
||||||
|
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False))
|
||||||
|
|
||||||
|
def test_linux(self):
|
||||||
|
self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric
|
||||||
|
self.assertEqual(fn.parse_route_linux(""), (None, None))
|
||||||
|
self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc")
|
||||||
|
self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1"))
|
||||||
|
self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
|
||||||
|
self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
|
||||||
|
|
||||||
|
def test_windows(self):
|
||||||
|
self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
|
||||||
|
self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
|
||||||
|
self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID
|
||||||
|
self.assertIsNone(fn.parse_netsh(NETSH_OFF))
|
||||||
|
|
||||||
|
def test_mac_addresses(self):
|
||||||
|
self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55")
|
||||||
|
for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"):
|
||||||
|
self.assertIsNone(fn.norm_mac(bad), bad)
|
||||||
|
|
||||||
|
def test_network_id_is_stable_and_needs_both_parts(self):
|
||||||
|
a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")
|
||||||
|
self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55"))
|
||||||
|
self.assertTrue(a.startswith("n-"))
|
||||||
|
self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router
|
||||||
|
self.assertIsNone(fn.network_id("192.168.1.1", None))
|
||||||
|
self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55"))
|
||||||
|
|
||||||
|
def test_tailscale(self):
|
||||||
|
ts = fn.parse_tailscale(TAILSCALE)
|
||||||
|
self.assertTrue(ts["up"])
|
||||||
|
self.assertEqual(ts["ip"], "100.101.102.103")
|
||||||
|
self.assertEqual(ts["name"], "laptop.tail1234.ts.net")
|
||||||
|
self.assertEqual(ts["tailnet"], "example.github")
|
||||||
|
frame = ts["peers"][0]
|
||||||
|
self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]),
|
||||||
|
("frame", "frame.tail1234.ts.net", "linux", True))
|
||||||
|
self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"])
|
||||||
|
self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []})
|
||||||
|
self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []})
|
||||||
|
|
||||||
|
def test_address_kinds(self):
|
||||||
|
cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale",
|
||||||
|
"100.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "tailscale",
|
||||||
|
"192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan",
|
||||||
|
"frame.example.com": "manual", "8.8.8.8": "manual"}
|
||||||
|
for host, kind in cases.items():
|
||||||
|
self.assertEqual(fn.guess_kind(host), kind, host)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+61
-1
@@ -34,7 +34,9 @@ class ServerGuards(unittest.TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
cls.port = free_port()
|
cls.port = free_port()
|
||||||
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
|
cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up
|
||||||
|
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
|
||||||
|
"FRAME_CONTROL_SSH_DIR": cls.ssh_dir}
|
||||||
cls.log = tempfile.TemporaryFile()
|
cls.log = tempfile.TemporaryFile()
|
||||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
|
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
|
||||||
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
|
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
|
||||||
@@ -109,6 +111,8 @@ class ServerGuards(unittest.TestCase):
|
|||||||
("/api/volume", {"level": 1.5}),
|
("/api/volume", {"level": 1.5}),
|
||||||
("/api/clipboard", {"text": ""}),
|
("/api/clipboard", {"text": ""}),
|
||||||
("/api/open", {"what": "anything-else"}),
|
("/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": []}),
|
||||||
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||||
("/api/shots/save", {"ids": [1]}),
|
("/api/shots/save", {"ids": [1]}),
|
||||||
@@ -119,6 +123,10 @@ class ServerGuards(unittest.TestCase):
|
|||||||
status, payload = self.post(path, body)
|
status, payload = self.post(path, body)
|
||||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
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):
|
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"):
|
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"})
|
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)
|
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):
|
class ArtworkSettings(unittest.TestCase):
|
||||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||||
|
|
||||||
|
|||||||
@@ -392,13 +392,14 @@ class ReportProblem(Base):
|
|||||||
got = self.serve()
|
got = self.serve()
|
||||||
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
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.",
|
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||||
"contact": "me@example.com"})
|
"contact": "me@example.com", "contactFollowup": True})
|
||||||
path, body = got[0]
|
path, body = got[0]
|
||||||
event = body["batch"][0]
|
event = body["batch"][0]
|
||||||
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
||||||
props = event["properties"]
|
props = event["properties"]
|
||||||
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
|
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"]))
|
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
|
||||||
|
self.assertIs(props["contact_followup"], True)
|
||||||
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
|
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.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
|
||||||
self.assertIn(res["id"], res["message"])
|
self.assertIn(res["id"], res["message"])
|
||||||
@@ -421,8 +422,9 @@ class ReportProblem(Base):
|
|||||||
|
|
||||||
def test_the_inbox_skips_malformed_reports(self):
|
def test_the_inbox_skips_malformed_reports(self):
|
||||||
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
||||||
"0.4.0", "macOS", "", ""]
|
"0.4.0", "macOS", "", "", None]
|
||||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
|
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None],
|
||||||
|
["short"], good]
|
||||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
||||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
||||||
mock.patch("builtins.print") as out:
|
mock.patch("builtins.print") as out:
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ class Apply(unittest.TestCase):
|
|||||||
self.assertFalse(self.t.STALE[0])
|
self.assertFalse(self.t.STALE[0])
|
||||||
self.assertEqual(self.said[-1][1].get("stale"), None)
|
self.assertEqual(self.said[-1][1].get("stale"), None)
|
||||||
|
|
||||||
|
def test_stale_survives_a_bare_release(self):
|
||||||
|
gs = FakeGamescope()
|
||||||
|
panel = self.t.apply(gs, {"button": "left", "down": True, "window": 99, "display": ":1"}, None)
|
||||||
|
self.assertTrue(self.t.STALE[0])
|
||||||
|
self.t.apply(gs, {"button": "left", "down": False}, panel) # the page's release names no panel
|
||||||
|
self.assertTrue(self.t.STALE[0])
|
||||||
|
self.assertEqual(self.said[-1][1].get("stale"), True)
|
||||||
|
|
||||||
def test_same_window_id_on_the_other_display_is_another_panel(self):
|
def test_same_window_id_on_the_other_display_is_another_panel(self):
|
||||||
gs = FakeGamescope()
|
gs = FakeGamescope()
|
||||||
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
|
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,9 @@
|
|||||||
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
|
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -129,7 +131,10 @@ def run_script(server, name, args):
|
|||||||
script = server.HERE.parent / 'scripts' / name
|
script = server.HERE.parent / 'scripts' / name
|
||||||
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
|
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
|
||||||
raise ValueError(name + ' requires a computer with zsh and the matching script installed')
|
raise ValueError(name + ' requires a computer with zsh and the matching script installed')
|
||||||
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60)
|
# The headset the server is routed to, not whatever `frame` means in ~/.ssh/config.
|
||||||
|
env = {**os.environ, 'FRAME_ALIAS': server.FRAME,
|
||||||
|
'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])}
|
||||||
|
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env)
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
raise ValueError(result.stderr.strip() or 'Script failed')
|
raise ValueError(result.stderr.strip() or 'Script failed')
|
||||||
return {'message': result.stdout.strip()}
|
return {'message': result.stdout.strip()}
|
||||||
|
|||||||
+48
-4
@@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of
|
|||||||
scripts/connect.sh (which the Mac app uses); same config block, so either can
|
scripts/connect.sh (which the Mac app uses); same config block, so either can
|
||||||
re-run over the other. Idempotent.
|
re-run over the other. Idempotent.
|
||||||
|
|
||||||
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
|
Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
|
||||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for
|
||||||
|
terminals that don't pass the environment on, like Windows' `start`)
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
@@ -283,10 +284,40 @@ def config_block(host, port=22, user=FRAME_USER):
|
|||||||
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
|
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
|
||||||
|
|
||||||
|
|
||||||
|
class config_lock:
|
||||||
|
"""The lock Frame Control takes to edit ~/.ssh/config (frame_devices.file_lock), so a
|
||||||
|
running app and this setup never write over each other's change."""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.fh = open(SSH_DIR / "config.frame-control.lock", "a+")
|
||||||
|
for _ in range(300):
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
import msvcrt
|
||||||
|
self.fh.seek(0)
|
||||||
|
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
fcntl.lockf(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return self
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return self # 30 s: go ahead rather than fail the setup
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.fh.close() # closing releases the lock
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def write_config(host, port=22, user=FRAME_USER):
|
def write_config(host, port=22, user=FRAME_USER):
|
||||||
|
make_ssh_dir()
|
||||||
|
with config_lock():
|
||||||
|
_write_config(host, port, user)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(host, port, user):
|
||||||
"""Replace our managed block and put it first: ssh uses the first value it sees per
|
"""Replace our managed block and put it first: ssh uses the first value it sees per
|
||||||
option. The trailing "Host *" returns the rest of the file to global scope."""
|
option. The trailing "Host *" returns the rest of the file to global scope."""
|
||||||
make_ssh_dir()
|
|
||||||
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
||||||
kept, skip = [], False
|
kept, skip = [], False
|
||||||
for line in old.splitlines():
|
for line in old.splitlines():
|
||||||
@@ -297,7 +328,7 @@ def write_config(host, port=22, user=FRAME_USER):
|
|||||||
elif not skip:
|
elif not skip:
|
||||||
kept.append(line)
|
kept.append(line)
|
||||||
block = config_block(host, port, user)
|
block = config_block(host, port, user)
|
||||||
tmp = CONFIG.with_name("config.frame-control.tmp")
|
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
||||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
tmp.chmod(0o600)
|
tmp.chmod(0o600)
|
||||||
@@ -367,9 +398,22 @@ def pair_with_devkit(host, port, user):
|
|||||||
return chosen[0], "paired, but key login still fails"
|
return chosen[0], "paired, but key login still fails"
|
||||||
|
|
||||||
|
|
||||||
|
def use_alias(alias):
|
||||||
|
"""--alias: set up another headset under its own ~/.ssh/config alias (Devices tab)."""
|
||||||
|
global FRAME_ALIAS, BEGIN, END
|
||||||
|
if not NAME_RE.fullmatch(alias):
|
||||||
|
sys.exit(f"--alias must be a plain name, not {alias!r}")
|
||||||
|
FRAME_ALIAS = alias
|
||||||
|
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
||||||
|
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
if argv and argv[0] in ("-h", "--help"):
|
if argv and argv[0] in ("-h", "--help"):
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
|
if len(argv) >= 2 and argv[0] == "--alias":
|
||||||
|
use_alias(argv[1])
|
||||||
|
argv = argv[2:]
|
||||||
say("==> Looking for the Steam Frame")
|
say("==> Looking for the Steam Frame")
|
||||||
found = pick_host(argv[0] if argv else None)
|
found = pick_host(argv[0] if argv else None)
|
||||||
while not found:
|
while not found:
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""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 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."""
|
||||||
|
email = str(body.get('email') or '').strip()
|
||||||
|
updates, followup = bool(body.get('updates')), bool(body.get('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
|
||||||
|
if changed and not _send_pending(block=False):
|
||||||
|
_wake.set() # offline, or a send under way that will take this change with it
|
||||||
|
return state()
|
||||||
|
|
||||||
|
|
||||||
|
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,802 @@
|
|||||||
|
"""The headsets Frame Control knows, and the addresses each can be reached at.
|
||||||
|
|
||||||
|
One headset can answer at several addresses: a LAN IP at home, another in the
|
||||||
|
office, its mDNS name (frame.local), its Tailscale IP or MagicDNS name. The
|
||||||
|
registry keeps them all, learns which worked on which network, and hands the
|
||||||
|
connector (frame_link.py) an order to try them in.
|
||||||
|
|
||||||
|
Stored as JSON in frame_host.data_dir("devices.json"). The format is plain so the
|
||||||
|
iPhone app can share it later; docs/devices.md describes it:
|
||||||
|
|
||||||
|
{"version": 1, "active": "<device id>",
|
||||||
|
"devices": [{"id", "name", "alias", "user", "port", "identity_files",
|
||||||
|
"addresses": [{"host", "kind": lan|mdns|tailscale|manual, "label",
|
||||||
|
"networks": [network ids it worked on], "last_ok", "last_rtt_ms"}]}],
|
||||||
|
"networks": {"<network id>": {"name", "ssid", "gateway", "gateway_mac", "last_seen"}}}
|
||||||
|
|
||||||
|
Headsets set up before this existed live only in ~/.ssh/config, in the managed
|
||||||
|
`# >>> steam-frame (ALIAS) >>>` blocks that scripts/connect.sh and
|
||||||
|
ui/frame_connect.py write; they're imported from there, so nobody has to add
|
||||||
|
them again. Each device keeps its alias: Terminal's `ssh frame` and the helper
|
||||||
|
scripts go on working, and the connector rewrites the block's HostName to the
|
||||||
|
last address that worked, so they follow it.
|
||||||
|
|
||||||
|
Host keys are pinned per headset, not per address: ssh gets
|
||||||
|
`-o HostKeyAlias=frame-control-<id>` and a known_hosts file of the headset's own
|
||||||
|
(~/.ssh/frame-control-hosts/<id>), so a different device answering at a
|
||||||
|
remembered IP is caught.
|
||||||
|
|
||||||
|
Python stdlib only.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
import frame_network
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
# Everything here can end up in ssh arguments or ~/.ssh/config, so nothing that
|
||||||
|
# could start an option, add a line, or carry a directive.
|
||||||
|
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||||
|
HOST_RE = re.compile(r"[A-Za-z0-9:][A-Za-z0-9.:-]{0,252}(%[A-Za-z0-9._-]{1,32})?")
|
||||||
|
TEXT_MAX = 60
|
||||||
|
KINDS = ("lan", "mdns", "tailscale", "manual")
|
||||||
|
KIND_LABEL = {"lan": "Local network", "mdns": "mDNS (.local)", "tailscale": "Tailscale", "manual": "Other"}
|
||||||
|
DEFAULT_USER = "steamos"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceError(ValueError):
|
||||||
|
"""Bad input from the page; the server answers 400 with the message."""
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_dir():
|
||||||
|
"""~/.ssh, or $FRAME_CONTROL_SSH_DIR in tests so they never touch the real one."""
|
||||||
|
return Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_config():
|
||||||
|
return ssh_dir() / "config"
|
||||||
|
|
||||||
|
|
||||||
|
PIN_DIR = "frame-control-hosts"
|
||||||
|
|
||||||
|
|
||||||
|
def known_hosts(device_id):
|
||||||
|
"""The headset's own known_hosts file: one per headset, so saving or forgetting one
|
||||||
|
headset's key (by ssh or by the app) can never touch another's."""
|
||||||
|
return ssh_dir() / PIN_DIR / device_id
|
||||||
|
|
||||||
|
|
||||||
|
def known_hosts_opt(device_id):
|
||||||
|
"""How ssh is told about it. `~` rather than the full path when it's the usual
|
||||||
|
place, so a home folder with a space in its name can't split the option."""
|
||||||
|
if os.environ.get("FRAME_CONTROL_SSH_DIR"):
|
||||||
|
return str(known_hosts(device_id))
|
||||||
|
return f"~/.ssh/{PIN_DIR}/{device_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def host_key_alias(device_id):
|
||||||
|
return f"frame-control-{device_id}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- validation ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_alias(alias):
|
||||||
|
if not isinstance(alias, str) or not NAME_RE.fullmatch(alias):
|
||||||
|
raise DeviceError("The SSH alias must be a plain name: letters, digits, dot, dash or underscore")
|
||||||
|
return alias
|
||||||
|
|
||||||
|
|
||||||
|
def check_user(user):
|
||||||
|
if not isinstance(user, str) or not NAME_RE.fullmatch(user):
|
||||||
|
raise DeviceError("The user name must be letters, digits, dot, dash or underscore")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def check_host(host):
|
||||||
|
host = host.strip() if isinstance(host, str) else host
|
||||||
|
if (not isinstance(host, str) or not HOST_RE.fullmatch(host) or ".." in host
|
||||||
|
or ("%" in host and ":" not in host.split("%")[0])): # a zone only follows an IPv6 address
|
||||||
|
raise DeviceError(f"{host!r} isn't a host name or IP address")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def check_port(port):
|
||||||
|
try:
|
||||||
|
port = int(port)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise DeviceError("The port must be a number") from None
|
||||||
|
if not 1 <= port <= 65535:
|
||||||
|
raise DeviceError("The port must be between 1 and 65535")
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def check_text(text, what):
|
||||||
|
text = (text or "").strip() if isinstance(text, (str, type(None))) else None
|
||||||
|
if text is None or len(text) > TEXT_MAX or re.search(r"[\x00-\x1f\x7f]", text):
|
||||||
|
raise DeviceError(f"The {what} must be plain text of at most {TEXT_MAX} characters")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def check_kind(kind):
|
||||||
|
if kind not in KINDS:
|
||||||
|
raise DeviceError(f"The kind must be one of {', '.join(KINDS)}")
|
||||||
|
return kind
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_host(host):
|
||||||
|
"""A host for ssh's HostName, which expands %-tokens: an IPv6 zone's % is doubled."""
|
||||||
|
return host.replace("%", "%%")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- ~/.ssh/config's managed blocks ---------------------------------------------
|
||||||
|
|
||||||
|
BLOCK_RE = re.compile(r"# >>> steam-frame \((" + NAME_RE.pattern + r")\) >>>")
|
||||||
|
|
||||||
|
|
||||||
|
def begin_mark(alias):
|
||||||
|
return f"# >>> steam-frame ({alias}) >>>"
|
||||||
|
|
||||||
|
|
||||||
|
def end_mark(alias):
|
||||||
|
return f"# <<< steam-frame ({alias}) <<<"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_blocks(text):
|
||||||
|
"""The managed blocks: [{"alias", "hostname", "user", "port", "identity_files"}]."""
|
||||||
|
blocks, cur = [], None
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = BLOCK_RE.fullmatch(line.strip())
|
||||||
|
if m:
|
||||||
|
cur = {"alias": m.group(1), "hostname": None, "user": None, "port": 22, "port_set": False,
|
||||||
|
"identity_files": []}
|
||||||
|
continue
|
||||||
|
if cur is None:
|
||||||
|
continue
|
||||||
|
if line.strip() == end_mark(cur["alias"]):
|
||||||
|
blocks.append(cur)
|
||||||
|
cur = None
|
||||||
|
continue
|
||||||
|
f = line.split(None, 1)
|
||||||
|
if len(f) != 2:
|
||||||
|
continue
|
||||||
|
key, value = f[0].lower(), f[1].strip()
|
||||||
|
if key == "hostname" and cur["hostname"] is None:
|
||||||
|
cur["hostname"] = value.replace("%%", "%")
|
||||||
|
elif key == "user" and cur["user"] is None:
|
||||||
|
cur["user"] = value
|
||||||
|
elif key == "port" and value.isdigit():
|
||||||
|
cur["port"], cur["port_set"] = int(value), True
|
||||||
|
elif key == "identityfile":
|
||||||
|
cur["identity_files"].append(value)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(path=None):
|
||||||
|
path = Path(path or ssh_config())
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# One edit of ~/.ssh/config at a time: between this app's threads (_config_lock) and
|
||||||
|
# with Set Up Connection (frame_connect.py and scripts/connect.sh take the same lock
|
||||||
|
# file). _edit_config also notices any other program writing in between.
|
||||||
|
_config_lock = threading.Lock()
|
||||||
|
LOCK_NAME = "config.frame-control.lock"
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def file_lock(path, timeout=30):
|
||||||
|
"""An exclusive lock on `path` (created if need be) shared with other processes:
|
||||||
|
POSIX record locks (what zsh's `zsystem flock` takes), or msvcrt on Windows."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fh = open(path, "a+")
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if frame_host.WINDOWS:
|
||||||
|
import msvcrt
|
||||||
|
fh.seek(0)
|
||||||
|
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
fcntl.lockf(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise OSError(f"{path} stayed locked (is Set Up Connection running?)")
|
||||||
|
time.sleep(0.1)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if frame_host.WINDOWS:
|
||||||
|
import msvcrt
|
||||||
|
fh.seek(0)
|
||||||
|
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
fcntl.lockf(fh, fcntl.LOCK_UN)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
fh.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(path, text, expected):
|
||||||
|
"""Swap the file in whole (as frame_connect.write_config does), keeping it private.
|
||||||
|
Returns False, writing nothing, if the file no longer holds `expected`."""
|
||||||
|
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
|
||||||
|
tmp = Path(tmp)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(text)
|
||||||
|
if not frame_host.WINDOWS:
|
||||||
|
tmp.chmod(0o600)
|
||||||
|
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
||||||
|
if read_config(path) != expected:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return True
|
||||||
|
except PermissionError:
|
||||||
|
time.sleep(0.25)
|
||||||
|
raise OSError(f"{path} stayed locked by another program")
|
||||||
|
finally:
|
||||||
|
if tmp.exists():
|
||||||
|
tmp.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_config(path, change):
|
||||||
|
"""Apply change(lines) -> new lines or None to the file, retrying if another program
|
||||||
|
wrote it meanwhile. -> True if the file changed."""
|
||||||
|
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
|
||||||
|
for _ in range(5):
|
||||||
|
text = read_config(path)
|
||||||
|
new = change(text.splitlines())
|
||||||
|
if new is None:
|
||||||
|
return False
|
||||||
|
if _write_config(path, "\n".join(new) + "\n", text):
|
||||||
|
return True
|
||||||
|
raise OSError(f"{path} kept changing while Frame Control tried to update it")
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
|
||||||
|
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
|
||||||
|
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
||||||
|
if `expect` ({"hostname", "user", "port"}; None values match anything) no longer
|
||||||
|
describes the block, checked under the lock: someone else changed it meanwhile."""
|
||||||
|
def change(lines):
|
||||||
|
if expect:
|
||||||
|
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
|
||||||
|
# A port the block doesn't set is inherited from elsewhere in the file: not compared.
|
||||||
|
if not block or any(v is not None and block[k] != v and (k != "port" or block["port_set"])
|
||||||
|
for k, v in expect.items()):
|
||||||
|
return None
|
||||||
|
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
|
||||||
|
# Port 22 needs no line, unless the block would otherwise inherit another port
|
||||||
|
# from a later Host entry (which ssh would use).
|
||||||
|
force = bool(port) and block is not None and not block["port_set"] and \
|
||||||
|
effective_port(alias, config_path) != int(port)
|
||||||
|
return _rewritten(lines, alias, hostname, user, port, force)
|
||||||
|
config_path = Path(path or ssh_config())
|
||||||
|
return _edit_config(config_path, change)
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten(lines, alias, hostname, user, port, force_port=False):
|
||||||
|
begin, end = begin_mark(alias), end_mark(alias)
|
||||||
|
if begin not in lines or end not in lines:
|
||||||
|
return None
|
||||||
|
i, j = lines.index(begin), lines.index(end)
|
||||||
|
if j < i:
|
||||||
|
return None
|
||||||
|
block = lines[i:j]
|
||||||
|
want = {"hostname": ssh_host(hostname) if hostname else None, "user": user,
|
||||||
|
"port": str(port) if port else None}
|
||||||
|
out, seen = [], set()
|
||||||
|
for line in block:
|
||||||
|
f = line.split(None, 1)
|
||||||
|
key = f[0].lower() if f else ""
|
||||||
|
if key in want and want[key] is not None and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
# An existing Port line is kept, even for 22: dropping it could let a later
|
||||||
|
# `Host *` Port apply to Terminal but not to the app.
|
||||||
|
out.append(f" {f[0]} {want[key]}")
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
if want["port"] and (want["port"] != "22" or force_port) and "port" not in seen:
|
||||||
|
at = next((n + 1 for n, line in enumerate(out) if line.split(None, 1)[:1] == ["HostName"]), 2)
|
||||||
|
out.insert(at, f" Port {want['port']}")
|
||||||
|
new = lines[:i] + out + lines[j:]
|
||||||
|
return None if new == lines else new
|
||||||
|
|
||||||
|
|
||||||
|
def remove_block(alias, path=None):
|
||||||
|
def change(lines):
|
||||||
|
begin, end = begin_mark(alias), end_mark(alias)
|
||||||
|
if begin not in lines or end not in lines or lines.index(end) < lines.index(begin):
|
||||||
|
return None
|
||||||
|
return lines[:lines.index(begin)] + lines[lines.index(end) + 1:]
|
||||||
|
return _edit_config(Path(path or ssh_config()), change)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_port(alias, config):
|
||||||
|
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||||
|
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return 22
|
||||||
|
m = re.search(r"^port (\d+)$", out, re.M)
|
||||||
|
port = int(m.group(1)) if m else 22
|
||||||
|
return port if 1 <= port <= 65535 else 22
|
||||||
|
|
||||||
|
|
||||||
|
# ---- pinned host keys -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _keygen(*args):
|
||||||
|
try:
|
||||||
|
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
|
timeout=10)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pinned(device_id):
|
||||||
|
"""Whether a key is saved for the headset. Entries are plain text (ssh gets
|
||||||
|
HashKnownHosts=no), but ask ssh-keygen too in case one was hashed."""
|
||||||
|
target = known_hosts(device_id)
|
||||||
|
try:
|
||||||
|
lines = target.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return False
|
||||||
|
name = host_key_alias(device_id)
|
||||||
|
if any(line.split(None, 1)[0].split(",").count(name) for line in lines
|
||||||
|
if line.strip() and not line.startswith("#")):
|
||||||
|
return True
|
||||||
|
r = _keygen("-F", name, "-f", str(target))
|
||||||
|
return bool(r and r.returncode == 0 and r.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def seed_pin(device_id, hosts, port=22, sources=None):
|
||||||
|
"""Copy the host keys ssh already trusts for one of `hosts` into the headset's file
|
||||||
|
under its alias, so moving to per-headset pinning asks nobody to trust anything again.
|
||||||
|
-> True if a key is pinned."""
|
||||||
|
if pinned(device_id):
|
||||||
|
return True
|
||||||
|
sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"]
|
||||||
|
name = host_key_alias(device_id)
|
||||||
|
for host in hosts:
|
||||||
|
wanted = host if port == 22 else f"[{host}]:{port}"
|
||||||
|
keys = []
|
||||||
|
for src in sources:
|
||||||
|
if not Path(src).is_file():
|
||||||
|
continue
|
||||||
|
r = _keygen("-F", wanted, "-f", str(src))
|
||||||
|
for line in (r.stdout if r else "").splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
|
||||||
|
keys.append(f"{name} {f[1]} {f[2]}")
|
||||||
|
if keys:
|
||||||
|
target = known_hosts(device_id)
|
||||||
|
target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
|
||||||
|
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
|
||||||
|
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
|
||||||
|
os.replace(tmp, target) # whole file at once: ssh never sees half of it
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def forget_pin(device_id):
|
||||||
|
"""Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
|
||||||
|
trusts whatever key the headset shows, as a first connection does."""
|
||||||
|
try:
|
||||||
|
known_hosts(device_id).unlink()
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- address order --------------------------------------------------------------------
|
||||||
|
|
||||||
|
def order_addresses(addresses, network_id, tailscale_up):
|
||||||
|
"""The order to try a device's addresses in, each with why it's there:
|
||||||
|
known to work on this network, then mDNS, then Tailscale if it's up, then the rest
|
||||||
|
(addresses that only ever worked elsewhere last). The user's order breaks ties."""
|
||||||
|
def group(a):
|
||||||
|
nets = a.get("networks") or []
|
||||||
|
if network_id and network_id in nets:
|
||||||
|
return 0, "worked on this network before"
|
||||||
|
if a["kind"] == "mdns":
|
||||||
|
return 1, "mDNS name"
|
||||||
|
if a["kind"] == "tailscale":
|
||||||
|
return (2, "Tailscale") if tailscale_up else (5, "Tailscale isn't running")
|
||||||
|
if nets:
|
||||||
|
return 4, "worked on another network"
|
||||||
|
return 3, "not tried on this network yet"
|
||||||
|
ranked = sorted(enumerate(addresses), key=lambda p: (group(p[1])[0], p[0]))
|
||||||
|
return [(a, group(a)[1]) for _, a in ranked]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the registry ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def new_address(host, kind=None, label=""):
|
||||||
|
host = check_host(host)
|
||||||
|
return {"host": host, "kind": check_kind(kind) if kind else frame_network.guess_kind(host),
|
||||||
|
"label": check_text(label, "label"), "networks": [], "last_ok": None, "last_rtt_ms": None}
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
"""devices.json, loaded once and saved on every change. Thread-safe."""
|
||||||
|
|
||||||
|
def __init__(self, path=None, config=None):
|
||||||
|
self.path = Path(path or frame_host.data_dir("devices.json"))
|
||||||
|
self.config = Path(config) if config else None # None: ssh_config() at call time
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self._depth = 0 # nested _changing() calls
|
||||||
|
self._mtime = None # devices.json as last loaded or saved
|
||||||
|
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
# -- storage --
|
||||||
|
def load(self):
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self._mtime = self.path.stat().st_mtime_ns
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("devices"), list):
|
||||||
|
data.setdefault("networks", {})
|
||||||
|
data.setdefault("active", None)
|
||||||
|
data["devices"] = [d for d in data["devices"] if self._sane(d)]
|
||||||
|
# The headset in use is this server's own choice: another server picking a
|
||||||
|
# different one mustn't move commands (an install, say) under it. The file's
|
||||||
|
# choice is only where a server starts.
|
||||||
|
# Kept even if another server removed it, so the connector can see that
|
||||||
|
# (and not quietly move to another headset in the middle of an install).
|
||||||
|
mine = self.data.get("active")
|
||||||
|
if mine:
|
||||||
|
data["active"] = mine
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sane(d):
|
||||||
|
try:
|
||||||
|
check_alias(d["alias"])
|
||||||
|
d["addresses"] = [a for a in d.get("addresses") or [] if isinstance(a, dict) and HOST_RE.fullmatch(a.get("host", ""))
|
||||||
|
and a.get("kind") in KINDS]
|
||||||
|
for a in d["addresses"]:
|
||||||
|
a.setdefault("networks", [])
|
||||||
|
a.setdefault("label", "")
|
||||||
|
return NAME_RE.fullmatch(d.get("id", "")) is not None
|
||||||
|
except (KeyError, TypeError, DeviceError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
with self.lock:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
|
||||||
|
os.replace(tmp, self.path)
|
||||||
|
self._mtime = self.path.stat().st_mtime_ns
|
||||||
|
|
||||||
|
def _refresh(self):
|
||||||
|
"""Pick up what another Frame Control server saved (the app and a standalone
|
||||||
|
server can share devices.json)."""
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
if self.path.stat().st_mtime_ns != self._mtime:
|
||||||
|
self.load()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _changing(self):
|
||||||
|
"""Every change holds this registry's lock and a lock file shared with other
|
||||||
|
processes, and starts from what's on disk, so no server saves over another's
|
||||||
|
change. Nested calls (sync_from_config adding a device) share the outer one."""
|
||||||
|
with self.lock:
|
||||||
|
if self._depth:
|
||||||
|
self._depth += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._depth -= 1
|
||||||
|
return
|
||||||
|
with file_lock(self.path.with_name(self.path.name + ".lock")):
|
||||||
|
self.load()
|
||||||
|
self._depth = 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._depth = 0
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
self._refresh()
|
||||||
|
with self.lock:
|
||||||
|
return copy.deepcopy(self.data)
|
||||||
|
|
||||||
|
# -- lookups --
|
||||||
|
def devices(self):
|
||||||
|
self._refresh()
|
||||||
|
with self.lock:
|
||||||
|
return copy.deepcopy(self.data["devices"])
|
||||||
|
|
||||||
|
def _find(self, device_id):
|
||||||
|
for d in self.data["devices"]:
|
||||||
|
if d["id"] == device_id:
|
||||||
|
return d
|
||||||
|
raise DeviceError("No such headset (it may have been removed)")
|
||||||
|
|
||||||
|
def get(self, device_id):
|
||||||
|
self._refresh()
|
||||||
|
with self.lock:
|
||||||
|
return copy.deepcopy(self._find(device_id))
|
||||||
|
|
||||||
|
def by_alias(self, alias):
|
||||||
|
self._refresh()
|
||||||
|
with self.lock:
|
||||||
|
return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None)
|
||||||
|
|
||||||
|
def active(self):
|
||||||
|
self._refresh()
|
||||||
|
with self.lock:
|
||||||
|
return self.data.get("active")
|
||||||
|
|
||||||
|
def emptied(self):
|
||||||
|
self._refresh()
|
||||||
|
with self.lock:
|
||||||
|
return bool(self.data.get("emptied")) and not self.data["devices"]
|
||||||
|
|
||||||
|
def set_active(self, device_id):
|
||||||
|
with self._changing():
|
||||||
|
self._find(device_id)
|
||||||
|
self.data["active"] = device_id
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- devices --
|
||||||
|
def add_device(self, alias, name=None, user=DEFAULT_USER, port=22, hosts=(), identity_files=()):
|
||||||
|
with self._changing():
|
||||||
|
check_alias(alias)
|
||||||
|
if any(d["alias"] == alias for d in self.data["devices"]):
|
||||||
|
raise DeviceError(f"There's already a headset with the alias {alias}")
|
||||||
|
ids = {d["id"] for d in self.data["devices"]}
|
||||||
|
device_id = secrets.token_hex(4)
|
||||||
|
while device_id in ids:
|
||||||
|
device_id = secrets.token_hex(4)
|
||||||
|
d = {"id": device_id, "name": check_text(name or ("Steam Frame" if alias == "frame" else alias), "name"),
|
||||||
|
"alias": alias, "user": check_user(user or DEFAULT_USER), "port": check_port(port),
|
||||||
|
"identity_files": [str(f) for f in identity_files][:8], "addresses": [], "config_host": None,
|
||||||
|
"added": time.time()}
|
||||||
|
for host in hosts:
|
||||||
|
if host and not any(a["host"] == host for a in d["addresses"]):
|
||||||
|
d["addresses"].append(new_address(host))
|
||||||
|
self.data["devices"].append(d)
|
||||||
|
self.data.pop("emptied", None)
|
||||||
|
if not self.data.get("active"):
|
||||||
|
self.data["active"] = device_id
|
||||||
|
self.save()
|
||||||
|
return copy.deepcopy(d)
|
||||||
|
|
||||||
|
def update_device(self, device_id, name=None, user=None, port=None):
|
||||||
|
"""-> the device after the change. The caller mirrors user and port into ~/.ssh/config."""
|
||||||
|
with self._changing():
|
||||||
|
d = self._find(device_id)
|
||||||
|
# Check everything first: a rejected edit changes nothing.
|
||||||
|
name = None if name is None else (check_text(name, "name") or d["alias"])
|
||||||
|
user = None if user is None else check_user(user)
|
||||||
|
port = None if port is None else check_port(port)
|
||||||
|
d.update({k: v for k, v in (("name", name), ("user", user), ("port", port)) if v is not None})
|
||||||
|
self.save()
|
||||||
|
return copy.deepcopy(d)
|
||||||
|
|
||||||
|
def remove_device(self, device_id):
|
||||||
|
"""Forget a headset. Its ~/.ssh/config block (if kept) isn't imported again
|
||||||
|
unless Set Up Connection changes it."""
|
||||||
|
with self._changing():
|
||||||
|
d = self._find(device_id)
|
||||||
|
self.data["devices"].remove(d)
|
||||||
|
self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or ""
|
||||||
|
if not self.data["devices"]:
|
||||||
|
self.data["emptied"] = True # removed on purpose: don't fall back to the `frame` alias
|
||||||
|
if self.data.get("active") == device_id:
|
||||||
|
self.data["active"] = self.data["devices"][0]["id"] if self.data["devices"] else None
|
||||||
|
self.save()
|
||||||
|
return d
|
||||||
|
|
||||||
|
# -- addresses --
|
||||||
|
def _addr(self, d, host):
|
||||||
|
for a in d["addresses"]:
|
||||||
|
if a["host"] == host:
|
||||||
|
return a
|
||||||
|
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||||
|
|
||||||
|
def add_address(self, device_id, host, kind=None, label=""):
|
||||||
|
with self._changing():
|
||||||
|
d = self._find(device_id)
|
||||||
|
a = new_address(host, kind, label)
|
||||||
|
if any(x["host"] == a["host"] for x in d["addresses"]):
|
||||||
|
raise DeviceError(f"{a['host']} is already on the list")
|
||||||
|
if len(d["addresses"]) >= 32:
|
||||||
|
raise DeviceError("That's enough addresses for one headset")
|
||||||
|
d["addresses"].append(a)
|
||||||
|
self.save()
|
||||||
|
return copy.deepcopy(a)
|
||||||
|
|
||||||
|
def update_address(self, device_id, host, new_host=None, kind=None, label=None):
|
||||||
|
with self._changing():
|
||||||
|
d = self._find(device_id)
|
||||||
|
a = self._addr(d, host)
|
||||||
|
# Check everything first: a rejected edit changes nothing.
|
||||||
|
moved = new_host is not None and new_host != host
|
||||||
|
if moved:
|
||||||
|
new_host = check_host(new_host)
|
||||||
|
if any(x["host"] == new_host for x in d["addresses"]):
|
||||||
|
raise DeviceError(f"{new_host} is already on the list")
|
||||||
|
kind = None if kind is None else check_kind(kind)
|
||||||
|
label = None if label is None else check_text(label, "label")
|
||||||
|
if moved:
|
||||||
|
a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again
|
||||||
|
if kind is not None:
|
||||||
|
a["kind"] = kind
|
||||||
|
if label is not None:
|
||||||
|
a["label"] = label
|
||||||
|
self.save()
|
||||||
|
return copy.deepcopy(a)
|
||||||
|
|
||||||
|
def remove_address(self, device_id, host):
|
||||||
|
with self._changing():
|
||||||
|
d = self._find(device_id)
|
||||||
|
d["addresses"].remove(self._addr(d, host))
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def move_address(self, device_id, host, delta):
|
||||||
|
with self._changing():
|
||||||
|
d = self._find(device_id)
|
||||||
|
a = self._addr(d, host)
|
||||||
|
i = d["addresses"].index(a)
|
||||||
|
j = max(0, min(len(d["addresses"]) - 1, i + int(delta)))
|
||||||
|
d["addresses"].insert(j, d["addresses"].pop(i))
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def record_success(self, device_id, host, network_id, rtt_ms):
|
||||||
|
"""Learn: this address worked on this network."""
|
||||||
|
with self._changing():
|
||||||
|
try:
|
||||||
|
a = self._addr(self._find(device_id), host)
|
||||||
|
except DeviceError:
|
||||||
|
return
|
||||||
|
if network_id and network_id not in a["networks"]:
|
||||||
|
a["networks"] = (a["networks"] + [network_id])[-16:]
|
||||||
|
a["last_ok"] = time.time()
|
||||||
|
a["last_rtt_ms"] = rtt_ms
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def undismiss(self, alias):
|
||||||
|
"""Set Up Connection is about to run for this alias: import its block again."""
|
||||||
|
with self._changing():
|
||||||
|
if self.data.get("dismissed", {}).pop(alias, None) is not None:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def set_config_host(self, device_id, host):
|
||||||
|
with self._changing():
|
||||||
|
try:
|
||||||
|
self._find(device_id)["config_host"] = host
|
||||||
|
except DeviceError:
|
||||||
|
return
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- networks --
|
||||||
|
def record_network(self, net):
|
||||||
|
"""Remember a network we've seen (for naming it), keeping its user-given name."""
|
||||||
|
if not net or not net.get("id"):
|
||||||
|
return
|
||||||
|
with self._changing():
|
||||||
|
known = self.data["networks"].get(net["id"]) or {"name": ""}
|
||||||
|
changed = (known.get("ssid") != (net.get("ssid") or known.get("ssid")) or
|
||||||
|
time.time() - (known.get("last_seen") or 0) > 3600 or "gateway" not in known)
|
||||||
|
known.update(ssid=net.get("ssid") or known.get("ssid"), gateway=net.get("gateway"), wifi=net.get("wifi"),
|
||||||
|
gateway_mac=net.get("gateway_mac"), last_seen=time.time())
|
||||||
|
self.data["networks"][net["id"]] = known
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def name_network(self, network_id, name):
|
||||||
|
with self._changing():
|
||||||
|
if network_id not in self.data["networks"]:
|
||||||
|
raise DeviceError("That network hasn't been seen")
|
||||||
|
self.data["networks"][network_id]["name"] = check_text(name, "network name")
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def network_name(self, net):
|
||||||
|
"""What to call a network: the name given to it, its Wi-Fi name, or its router."""
|
||||||
|
if not net:
|
||||||
|
return "No network"
|
||||||
|
self._refresh()
|
||||||
|
with self.lock:
|
||||||
|
known = self.data["networks"].get(net.get("id") or "") or {}
|
||||||
|
if known.get("name"):
|
||||||
|
return known["name"]
|
||||||
|
ssid = net.get("ssid") or known.get("ssid")
|
||||||
|
if ssid:
|
||||||
|
return ssid
|
||||||
|
if net.get("gateway"):
|
||||||
|
return f"{'Wi-Fi' if net.get('wifi') else 'Network'} via {net['gateway']}"
|
||||||
|
return "No network"
|
||||||
|
|
||||||
|
# -- ~/.ssh/config --
|
||||||
|
def sync_from_config(self, seed=True):
|
||||||
|
"""Import managed blocks we don't know yet, and pick up a HostName that Set Up
|
||||||
|
Connection changed since we last looked. -> True if anything changed."""
|
||||||
|
blocks = parse_blocks(read_config(self.config))
|
||||||
|
for b in blocks:
|
||||||
|
if not b["port_set"]:
|
||||||
|
# No Port in the block: another Host entry may give one (ssh uses the first).
|
||||||
|
b["port"] = effective_port(b["alias"], self.config or ssh_config())
|
||||||
|
changed = False
|
||||||
|
with self._changing():
|
||||||
|
first = not self.data["devices"] and not self.data.get("active")
|
||||||
|
for b in blocks:
|
||||||
|
host = b["hostname"] if b["hostname"] and HOST_RE.fullmatch(b["hostname"]) else None
|
||||||
|
user = b["user"] if b["user"] and NAME_RE.fullmatch(b["user"]) else DEFAULT_USER
|
||||||
|
d = next((x for x in self.data["devices"] if x["alias"] == b["alias"]), None)
|
||||||
|
dismissed = self.data.get("dismissed", {})
|
||||||
|
if d is None and b["alias"] in dismissed:
|
||||||
|
if dismissed[b["alias"]] == (host or ""):
|
||||||
|
continue # removed on the Devices tab; unchanged since
|
||||||
|
del dismissed[b["alias"]]
|
||||||
|
if d is None:
|
||||||
|
try:
|
||||||
|
d = self._find(self.add_device(b["alias"], user=user, port=b["port"],
|
||||||
|
identity_files=b["identity_files"])["id"])
|
||||||
|
except DeviceError:
|
||||||
|
continue
|
||||||
|
if host:
|
||||||
|
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
|
||||||
|
d["config_host"] = host
|
||||||
|
changed = True
|
||||||
|
if seed and host:
|
||||||
|
seed_pin(d["id"], [host], b["port"])
|
||||||
|
elif host and host != d.get("config_host"):
|
||||||
|
# Set Up Connection ran again and found the headset somewhere new.
|
||||||
|
d["config_host"] = host
|
||||||
|
if not any(a["host"] == host for a in d["addresses"]):
|
||||||
|
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
|
||||||
|
if seed:
|
||||||
|
seed_pin(d["id"], [host], b["port"])
|
||||||
|
changed = True
|
||||||
|
if d.get("config_login") != [user, b["port"]]:
|
||||||
|
# Set Up Connection (or an edit) changed who to log in as, or the port.
|
||||||
|
if d.get("config_login") is not None and [d["user"], d["port"]] != [user, b["port"]]:
|
||||||
|
d["user"], d["port"] = user, b["port"] if 1 <= b["port"] <= 65535 else d["port"]
|
||||||
|
d["config_login"] = [user, b["port"]]
|
||||||
|
changed = True
|
||||||
|
if d["identity_files"] != b["identity_files"] and b["identity_files"]:
|
||||||
|
d["identity_files"] = b["identity_files"][:8]
|
||||||
|
changed = True
|
||||||
|
d["managed"] = True
|
||||||
|
aliases = {b["alias"] for b in blocks}
|
||||||
|
for d in self.data["devices"]:
|
||||||
|
d["managed"] = d["alias"] in aliases
|
||||||
|
if first:
|
||||||
|
# First import: the headset the app used before is `frame`, even if Set Up
|
||||||
|
# Connection put another block above it.
|
||||||
|
frame = next((d for d in self.data["devices"] if d["alias"] == "frame"), None)
|
||||||
|
if frame and self.data.get("active") != frame["id"]:
|
||||||
|
self.data["active"] = frame["id"]
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
return changed
|
||||||
+24
-5
@@ -56,13 +56,19 @@ def cache_dir(*parts):
|
|||||||
return base.joinpath(*parts)
|
return base.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
def control_path(*, private=False):
|
def control_path(tag="x", *, private=None):
|
||||||
"""ssh ControlPath for the shared connection, or None where it isn't supported.
|
"""ssh ControlPath for the shared connection, or None where it isn't supported.
|
||||||
|
|
||||||
|
`tag` names the headset: ssh's %C hashes only the address, user and port, so two
|
||||||
|
headsets reached at the same address (one of them moved) would otherwise share a
|
||||||
|
connection, and one's commands would run on the other.
|
||||||
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||||
"""
|
"""
|
||||||
|
# A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1.
|
||||||
|
if private is None:
|
||||||
|
private = os.environ.get("FRAME_PRIVATE_SSH") == "1"
|
||||||
suffix = f"-{os.getpid()}" if private else ""
|
suffix = f"-{os.getpid()}" if private else ""
|
||||||
return f"/tmp/frame-ui-{os.getuid()}{suffix}-%C" if MUX else None
|
return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None
|
||||||
|
|
||||||
|
|
||||||
def which(name, *extra):
|
def which(name, *extra):
|
||||||
@@ -147,6 +153,19 @@ def open_path(path):
|
|||||||
stderr=subprocess.DEVNULL, **DETACHED)
|
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
|
open_url = open_path # the same openers hand URLs to the default browser
|
||||||
|
|
||||||
|
|
||||||
@@ -258,9 +277,9 @@ def open_steam_link():
|
|||||||
return "Steam Link isn't installed; opened its download page"
|
return "Steam Link isn't installed; opened its download page"
|
||||||
|
|
||||||
|
|
||||||
def open_rdp(alias):
|
def open_rdp(alias, host=None):
|
||||||
"""Remote desktop to the Frame's xrdp (user steamos)."""
|
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||||
host = ssh_hostname(alias)
|
host = host or ssh_hostname(alias)
|
||||||
if MAC:
|
if MAC:
|
||||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||||
return "Opened Windows App"
|
return "Opened Windows App"
|
||||||
|
|||||||
+1232
File diff suppressed because it is too large.
Load diff
+36
-2
@@ -120,6 +120,10 @@ class MacView:
|
|||||||
self.tunnel_ssh = list(tunnel_ssh)
|
self.tunnel_ssh = list(tunnel_ssh)
|
||||||
self.run = run
|
self.run = run
|
||||||
self.frame = frame
|
self.frame = frame
|
||||||
|
self.host_opts = [] # the headset in use and how to reach it (see retarget)
|
||||||
|
# Short, never held across ssh: retarget() and publishing a new tunnel check and
|
||||||
|
# change the headset together (self.lock is held while a tunnel is being opened).
|
||||||
|
self.route_lock = threading.Lock()
|
||||||
self.track = track or (lambda proc: None) # the server ends these on exit
|
self.track = track or (lambda proc: None) # the server ends these on exit
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.token = secrets.token_urlsafe(24)
|
self.token = secrets.token_urlsafe(24)
|
||||||
@@ -239,13 +243,37 @@ class MacView:
|
|||||||
last = self._last_tunnel_error
|
last = self._last_tunnel_error
|
||||||
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
|
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
|
||||||
|
|
||||||
|
def retarget(self, alias, host_opts):
|
||||||
|
"""The server now reaches the headset as `alias` with `host_opts` (another address,
|
||||||
|
or another headset). Only the short route_lock, never self.lock: this runs while
|
||||||
|
the server routes, which a tunnel being opened may be waiting on."""
|
||||||
|
# host_opts is "-o", "Name=value" pairs; the tunnel keeps its own connection,
|
||||||
|
# not the shared master.
|
||||||
|
opts = [x for flag, value in zip(host_opts[::2], host_opts[1::2])
|
||||||
|
if not value.startswith("ControlPath=") for x in (flag, value)]
|
||||||
|
with self.route_lock:
|
||||||
|
moved = alias != self.frame
|
||||||
|
self.frame, self.host_opts = alias, opts
|
||||||
|
tunnel = self.tunnel
|
||||||
|
if moved and tunnel is not None:
|
||||||
|
# Another headset: its viewers can't be the old one's. The supervisor
|
||||||
|
# reopens a tunnel to the new one if anything is being shown.
|
||||||
|
self.tunnel, self.remote_port = None, None
|
||||||
|
if moved and tunnel is not None and tunnel.poll() is None:
|
||||||
|
tunnel.terminate()
|
||||||
|
|
||||||
def _open_tunnel(self, via, ports):
|
def _open_tunnel(self, via, ports):
|
||||||
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
|
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
|
||||||
last = ""
|
last = ""
|
||||||
for port in ports:
|
for port in ports:
|
||||||
proc = subprocess.Popen([*self.tunnel_ssh, *via, "-o", "ExitOnForwardFailure=yes",
|
with self.route_lock:
|
||||||
|
target = (self.frame, self.host_opts) # retarget() may change these meanwhile
|
||||||
|
# `via` first: ssh keeps the first value of an option, so USB-C's HostName wins
|
||||||
|
# while the headset's pinned identity (in host_opts) still checks it.
|
||||||
|
proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1],
|
||||||
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
|
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
|
||||||
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame],
|
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE, text=True)
|
stderr=subprocess.PIPE, text=True)
|
||||||
# A taken port makes ssh exit once it's connected; a working
|
# A taken port makes ssh exit once it's connected; a working
|
||||||
@@ -258,8 +286,12 @@ class MacView:
|
|||||||
if self._probe(port):
|
if self._probe(port):
|
||||||
ok = True
|
ok = True
|
||||||
break
|
break
|
||||||
|
if ok:
|
||||||
|
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:
|
if ok:
|
||||||
self.tunnel, self.remote_port = proc, port
|
self.tunnel, self.remote_port = proc, port
|
||||||
|
if ok:
|
||||||
self.track(proc)
|
self.track(proc)
|
||||||
self._supervise()
|
self._supervise()
|
||||||
return True
|
return True
|
||||||
@@ -294,6 +326,8 @@ class MacView:
|
|||||||
socket.create_connection((ip, 22), timeout=1).close()
|
socket.create_connection((ip, 22), timeout=1).close()
|
||||||
except OSError:
|
except OSError:
|
||||||
return [] # not plugged into this Mac
|
return [] # not plugged into this Mac
|
||||||
|
if any(o.startswith("HostKeyAlias=") for o in self.host_opts):
|
||||||
|
return ["-o", f"HostName={ip}"] # checked against the headset's own pinned key
|
||||||
alias = self.frame
|
alias = self.frame
|
||||||
try:
|
try:
|
||||||
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
|
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
|
||||||
|
|||||||
+78
-12
@@ -33,6 +33,15 @@ SLOTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayBusy(RuntimeError):
|
||||||
|
"""SetOverlayRaw's RequestFailed (23): SteamVR isn't taking frames, e.g. the
|
||||||
|
unworn headset is in standby (verified 2026-09-29). Transient, not fatal."""
|
||||||
|
|
||||||
|
|
||||||
|
# A screen that can't take a frame for this long is broken, not asleep.
|
||||||
|
BUSY_LIMIT = 300
|
||||||
|
|
||||||
|
|
||||||
class Overlay:
|
class Overlay:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.handles = []
|
self.handles = []
|
||||||
@@ -53,6 +62,8 @@ class Overlay:
|
|||||||
def call(self, name, *values):
|
def call(self, name, *values):
|
||||||
slot, args = SLOTS[name]
|
slot, args = SLOTS[name]
|
||||||
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
|
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
|
||||||
|
if rc == 23 and name == 'SetOverlayRaw':
|
||||||
|
raise OverlayBusy('SteamVR is not accepting frames (standby?)')
|
||||||
if rc:
|
if rc:
|
||||||
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
|
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
|
||||||
|
|
||||||
@@ -75,10 +86,13 @@ class Overlay:
|
|||||||
self.call('ShowOverlay', handle)
|
self.call('ShowOverlay', handle)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
try:
|
# Best effort: SteamVR removes a disconnected client's overlays anyway,
|
||||||
|
# and a teardown error must not overwrite a finished playback's status.
|
||||||
for h in reversed(self.handles):
|
for h in reversed(self.handles):
|
||||||
|
try:
|
||||||
self.call('DestroyOverlay', h)
|
self.call('DestroyOverlay', h)
|
||||||
finally:
|
except RuntimeError:
|
||||||
|
pass
|
||||||
self.vr.VR_ShutdownInternal()
|
self.vr.VR_ShutdownInternal()
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +128,11 @@ def decoder_command(path, info, width, height, audio, photo=False):
|
|||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def ignore_signals():
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||||
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||||
|
|
||||||
|
|
||||||
def write_status(path, **values):
|
def write_status(path, **values):
|
||||||
tmp = path.with_suffix('.tmp')
|
tmp = path.with_suffix('.tmp')
|
||||||
tmp.write_text(json.dumps(values))
|
tmp.write_text(json.dumps(values))
|
||||||
@@ -135,6 +154,46 @@ def play(args):
|
|||||||
photo = plan['kind'] == 'photo'
|
photo = plan['kind'] == 'photo'
|
||||||
command = decoder_command(path, info, width, height, audio, photo)
|
command = decoder_command(path, info, width, height, audio, photo)
|
||||||
vr, proc, frames, started = None, None, 0, time.monotonic()
|
vr, proc, frames, started = None, None, 0, time.monotonic()
|
||||||
|
dropped, busy_since, pending = 0, None, []
|
||||||
|
|
||||||
|
def show(handle, data, w, h, video=False):
|
||||||
|
"""Submit a frame. During standby return False; a video frame is dropped."""
|
||||||
|
nonlocal dropped, busy_since
|
||||||
|
try:
|
||||||
|
vr.pixels(handle, data, w, h)
|
||||||
|
except OverlayBusy:
|
||||||
|
if not video:
|
||||||
|
return False # stills and the surround just wait; nothing is lost
|
||||||
|
dropped += 1
|
||||||
|
busy_since = busy_since or time.monotonic()
|
||||||
|
if time.monotonic() - busy_since > BUSY_LIMIT:
|
||||||
|
raise RuntimeError('SteamVR stopped accepting frames for %d s' % BUSY_LIMIT)
|
||||||
|
return False
|
||||||
|
if video:
|
||||||
|
busy_since = None
|
||||||
|
drain()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def drain():
|
||||||
|
"""Re-send anything that arrived during standby (e.g. the theatre surround)."""
|
||||||
|
while pending:
|
||||||
|
item = pending.pop(0)
|
||||||
|
try:
|
||||||
|
vr.pixels(*item)
|
||||||
|
except OverlayBusy:
|
||||||
|
pending.insert(0, item)
|
||||||
|
return
|
||||||
|
|
||||||
|
def hold(handle, data, w, h):
|
||||||
|
"""Keep a still (photo or splat) up until Stop, retrying through standby."""
|
||||||
|
shown = False
|
||||||
|
while True:
|
||||||
|
if shown:
|
||||||
|
drain()
|
||||||
|
else:
|
||||||
|
shown = show(handle, data, w, h)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
|
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
|
||||||
# ownership; no unrelated Steam/SteamVR process or setting is touched.
|
# ownership; no unrelated Steam/SteamVR process or setting is touched.
|
||||||
def stop(signum, frame):
|
def stop(signum, frame):
|
||||||
@@ -146,14 +205,13 @@ def play(args):
|
|||||||
if args.theatre:
|
if args.theatre:
|
||||||
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
|
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
|
||||||
vr.call('SetOverlayAlpha', surround, .85)
|
vr.call('SetOverlayAlpha', surround, .85)
|
||||||
vr.pixels(surround, b'\x00\x00\x00\xff', 1, 1)
|
if not show(surround, b'\x00\x00\x00\xff', 1, 1):
|
||||||
|
pending.append((surround, b'\x00\x00\x00\xff', 1, 1))
|
||||||
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
|
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
|
||||||
plan['layout'] != 'mono', aspect)
|
plan['layout'] != 'mono', aspect)
|
||||||
if splat:
|
if splat:
|
||||||
vr.pixels(screen, data, width, height)
|
|
||||||
write_status(status, state='playing', file=path.name, frames=1, **plan)
|
write_status(status, state='playing', file=path.name, frames=1, **plan)
|
||||||
while True:
|
hold(screen, data, width, height)
|
||||||
time.sleep(1)
|
|
||||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||||
video_start = time.monotonic()
|
video_start = time.monotonic()
|
||||||
while True:
|
while True:
|
||||||
@@ -163,11 +221,14 @@ def play(args):
|
|||||||
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
|
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
|
||||||
if not photo:
|
if not photo:
|
||||||
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||||
vr.pixels(screen, data, outw, outh)
|
if photo:
|
||||||
|
still = data, outw, outh
|
||||||
|
else:
|
||||||
|
show(screen, data, outw, outh, video=True)
|
||||||
frames += 1
|
frames += 1
|
||||||
if frames == 1 or frames % 30 == 0:
|
if frames == 1 or frames % 30 == 0:
|
||||||
write_status(status, state='playing', file=path.name, frames=frames,
|
write_status(status, state='playing', file=path.name, frames=frames,
|
||||||
seconds=time.monotonic()-started, **plan)
|
dropped=dropped, seconds=time.monotonic()-started, **plan)
|
||||||
if not photo:
|
if not photo:
|
||||||
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||||
rc = proc.wait(timeout=10)
|
rc = proc.wait(timeout=10)
|
||||||
@@ -176,12 +237,17 @@ def play(args):
|
|||||||
if not frames:
|
if not frames:
|
||||||
raise RuntimeError('Decoder produced no frames')
|
raise RuntimeError('Decoder produced no frames')
|
||||||
if photo:
|
if photo:
|
||||||
while True:
|
hold(screen, *still)
|
||||||
time.sleep(1)
|
# From here on a Stop can't change the outcome; don't let it turn
|
||||||
write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started)
|
# 'ended' into an error while we write status and clean up.
|
||||||
|
ignore_signals()
|
||||||
|
write_status(status, state='ended', frames=frames, dropped=dropped,
|
||||||
|
seconds=time.monotonic()-started)
|
||||||
except InterruptedError:
|
except InterruptedError:
|
||||||
write_status(status, state='stopped', frames=frames)
|
ignore_signals()
|
||||||
|
write_status(status, state='stopped', frames=frames, dropped=dropped)
|
||||||
finally:
|
finally:
|
||||||
|
ignore_signals()
|
||||||
if proc:
|
if proc:
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def run(body):
|
|||||||
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
||||||
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
||||||
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
|
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
|
||||||
'--layout', plan['layout'], '--status', str(STATUS)]
|
'--layout', body.get('layout', 'auto'), '--status', str(STATUS)]
|
||||||
if body.get('theatre'):
|
if body.get('theatre'):
|
||||||
command.append('--theatre')
|
command.append('--theatre')
|
||||||
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Which network this computer is on, and whether Tailscale is up.
|
||||||
|
|
||||||
|
Frame Control remembers which of a headset's addresses worked on which network,
|
||||||
|
so it needs a stable name for "this network". The Wi-Fi name (SSID) is the
|
||||||
|
friendly one, but macOS 14+ hides it from apps without Location permission, and
|
||||||
|
wired networks have none. So every network is identified by a fingerprint of
|
||||||
|
its default gateway: the router's IP and MAC address, which stay the same for a
|
||||||
|
given home or office network. The user can give a fingerprint a name.
|
||||||
|
|
||||||
|
Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe
|
||||||
|
is a short command with a timeout, and each parser has fixtures in
|
||||||
|
tests/test_network.py.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
|
TIMEOUT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def run(argv, timeout=TIMEOUT):
|
||||||
|
"""A command's stdout, or "" if it's missing, fails or takes too long."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout,
|
||||||
|
**({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {}))
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def valid_ip(text):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(text)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def norm_mac(text):
|
||||||
|
""""b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC."""
|
||||||
|
parts = re.split(r"[:-]", (text or "").strip())
|
||||||
|
if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts):
|
||||||
|
return None
|
||||||
|
mac = ":".join(p.lower().zfill(2) for p in parts)
|
||||||
|
return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac
|
||||||
|
|
||||||
|
|
||||||
|
# ---- default gateway -------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_route_macos(text):
|
||||||
|
"""`route -n get default` -> (gateway, interface)."""
|
||||||
|
gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M)
|
||||||
|
iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M)
|
||||||
|
gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None
|
||||||
|
return gateway, iface.group(1) if iface else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_route_linux(text):
|
||||||
|
"""`ip -4 route show default` -> (gateway, interface) of the lowest-metric route."""
|
||||||
|
best = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = re.search(r"^default via (\S+) dev (\S+)", line.strip())
|
||||||
|
if not m or not valid_ip(m.group(1)):
|
||||||
|
continue
|
||||||
|
metric = re.search(r"\bmetric (\d+)", line)
|
||||||
|
key = int(metric.group(1)) if metric else 0
|
||||||
|
if best is None or key < best[0]:
|
||||||
|
best = (key, m.group(1), m.group(2))
|
||||||
|
return (best[1], best[2]) if best else (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_route_windows(text):
|
||||||
|
"""`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins."""
|
||||||
|
best = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit():
|
||||||
|
if best is None or int(f[4]) < best[0]:
|
||||||
|
best = (int(f[4]), f[2], f[3])
|
||||||
|
return (best[1], best[2]) if best else (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the gateway's MAC address ----------------------------------------------
|
||||||
|
|
||||||
|
def parse_arp_macos(text, ip):
|
||||||
|
"""`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]")."""
|
||||||
|
m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text)
|
||||||
|
return norm_mac(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_neigh_linux(text, ip):
|
||||||
|
"""`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE")."""
|
||||||
|
for line in text.splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if f and f[0] == ip and "lladdr" in f:
|
||||||
|
return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arp_windows(text, ip):
|
||||||
|
"""`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic")."""
|
||||||
|
for line in text.splitlines():
|
||||||
|
f = line.split()
|
||||||
|
if len(f) >= 2 and f[0] == ip:
|
||||||
|
return norm_mac(f[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Wi-Fi name --------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_summary_macos(text):
|
||||||
|
"""`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "<redacted>" without Location permission."""
|
||||||
|
kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M)
|
||||||
|
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
|
||||||
|
name = ssid.group(1) if ssid else None
|
||||||
|
if name in ("<redacted>", ""):
|
||||||
|
name = None
|
||||||
|
return name, (kind.group(1).lower() == "wifi") if kind else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nmcli(text):
|
||||||
|
"""`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:)."""
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("yes:"):
|
||||||
|
return line[4:].replace("\\:", ":") or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_netsh(text):
|
||||||
|
"""`netsh wlan show interfaces` -> the connected SSID (not the BSSID line)."""
|
||||||
|
state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M)
|
||||||
|
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
|
||||||
|
if not ssid or (state and state.group(1).lower() != "connected"):
|
||||||
|
return None
|
||||||
|
return ssid.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tailscale -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def tailscale_cli():
|
||||||
|
extra = []
|
||||||
|
if frame_host.MAC:
|
||||||
|
extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
|
||||||
|
elif frame_host.WINDOWS:
|
||||||
|
for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
|
||||||
|
if base:
|
||||||
|
extra.append(os.path.join(base, "Tailscale", "tailscale.exe"))
|
||||||
|
return frame_host.which("tailscale", *extra)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tailscale(text):
|
||||||
|
"""`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}.
|
||||||
|
|
||||||
|
Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except ValueError:
|
||||||
|
return {"up": False, "peers": []}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"up": False, "peers": []}
|
||||||
|
me = data.get("Self") or {}
|
||||||
|
tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None
|
||||||
|
out = {"up": data.get("BackendState") == "Running",
|
||||||
|
"ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None),
|
||||||
|
"name": (me.get("DNSName") or "").rstrip(".") or None,
|
||||||
|
"tailnet": tailnet, "peers": []}
|
||||||
|
for p in (data.get("Peer") or {}).values():
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."),
|
||||||
|
"ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)],
|
||||||
|
"os": p.get("OS") or "", "online": bool(p.get("Online"))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def tailscale_status():
|
||||||
|
cli = tailscale_cli()
|
||||||
|
if not cli:
|
||||||
|
return {"up": False, "installed": False, "peers": []}
|
||||||
|
out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}")
|
||||||
|
out["installed"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
|
||||||
|
|
||||||
|
|
||||||
|
def is_tailscale(host):
|
||||||
|
host = host.lower().rstrip(".")
|
||||||
|
if host.endswith(".ts.net"):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_kind(host):
|
||||||
|
"""What sort of address a host is: mdns, tailscale, lan or manual."""
|
||||||
|
h = host.lower().rstrip(".")
|
||||||
|
if h.endswith(".local"):
|
||||||
|
return "mdns"
|
||||||
|
if is_tailscale(h):
|
||||||
|
return "tailscale"
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(h.split("%")[0])
|
||||||
|
if ip.is_private or ip.is_link_local:
|
||||||
|
return "lan"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return "manual"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- putting it together ----------------------------------------------------------
|
||||||
|
|
||||||
|
def network_id(gateway, mac):
|
||||||
|
"""A short, stable id for a network: its gateway's IP and MAC. None until both are known."""
|
||||||
|
if not gateway or not mac:
|
||||||
|
return None
|
||||||
|
return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def local_ip(towards="192.0.2.1"):
|
||||||
|
"""This computer's address on the default route (UDP connect sends nothing)."""
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.connect((towards, 9))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gateway():
|
||||||
|
"""(gateway IP, interface) of the default route."""
|
||||||
|
if frame_host.MAC:
|
||||||
|
return parse_route_macos(run(["route", "-n", "get", "default"]))
|
||||||
|
if frame_host.WINDOWS:
|
||||||
|
return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"]))
|
||||||
|
return parse_route_linux(run(["ip", "-4", "route", "show", "default"]))
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_mac(ip):
|
||||||
|
if frame_host.MAC:
|
||||||
|
return parse_arp_macos(run(["arp", "-n", ip]), ip)
|
||||||
|
if frame_host.WINDOWS:
|
||||||
|
return parse_arp_windows(run(["arp", "-a", ip]), ip)
|
||||||
|
return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip)
|
||||||
|
|
||||||
|
|
||||||
|
def poke(ip):
|
||||||
|
"""Make the system look up the gateway's MAC (an ARP entry can expire)."""
|
||||||
|
try:
|
||||||
|
with socket.create_connection((ip, 53), timeout=0.3):
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def wifi(interface):
|
||||||
|
"""(ssid or None, is_wifi or None) for the default route's interface."""
|
||||||
|
if frame_host.MAC:
|
||||||
|
if interface:
|
||||||
|
ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface]))
|
||||||
|
if ssid or is_wifi is False:
|
||||||
|
return ssid, is_wifi
|
||||||
|
m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface]))
|
||||||
|
return (m.group(1).strip() if m else None), is_wifi
|
||||||
|
return None, None
|
||||||
|
if frame_host.WINDOWS:
|
||||||
|
ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"]))
|
||||||
|
return ssid, True if ssid else None
|
||||||
|
if frame_host.which("nmcli"):
|
||||||
|
ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]))
|
||||||
|
else:
|
||||||
|
ssid = run(["iwgetid", "-r"]).strip() or None
|
||||||
|
return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None)
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint():
|
||||||
|
"""The cheap part, polled every few seconds: (gateway, interface, gateway MAC)."""
|
||||||
|
gw, iface = gateway()
|
||||||
|
mac = None
|
||||||
|
if gw:
|
||||||
|
mac = gateway_mac(gw)
|
||||||
|
if not mac:
|
||||||
|
poke(gw)
|
||||||
|
mac = gateway_mac(gw)
|
||||||
|
return gw, iface, mac
|
||||||
|
|
||||||
|
|
||||||
|
def current_network(fp=None, with_tailscale=True):
|
||||||
|
"""Everything the connection status shows about this computer's network."""
|
||||||
|
gw, iface, mac = fp or fingerprint()
|
||||||
|
ssid, is_wifi = wifi(iface) if gw else (None, None)
|
||||||
|
net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface,
|
||||||
|
"ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()}
|
||||||
|
if with_tailscale:
|
||||||
|
ts = tailscale_status()
|
||||||
|
net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")}
|
||||||
|
return net
|
||||||
+67
-6
@@ -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
|
nothing is published. It is sent whatever the analytics settings are, because
|
||||||
the person sends it deliberately. Diagnostics are scrubbed first
|
the person sends it deliberately. Diagnostics are scrubbed first
|
||||||
(frame_telemetry.scrub); the person's own words are sent as written.
|
(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 os
|
||||||
import platform
|
import platform
|
||||||
@@ -13,6 +17,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import frame_contact
|
||||||
import frame_host
|
import frame_host
|
||||||
import frame_telemetry
|
import frame_telemetry
|
||||||
|
|
||||||
@@ -107,18 +112,25 @@ def send(body):
|
|||||||
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
||||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||||
title, text, diag = compose(body)
|
title, text, diag = compose(body)
|
||||||
|
followup = bool(body.get('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')
|
||||||
ref = uuid.uuid4().hex[:8].upper()
|
ref = uuid.uuid4().hex[:8].upper()
|
||||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
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,
|
||||||
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
|
'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.
|
# 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()),
|
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
|
||||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
|
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
|
||||||
|
started = time.time()
|
||||||
try:
|
try:
|
||||||
frame_telemetry.post([event], timeout=30)
|
frame_telemetry.post([event], timeout=30)
|
||||||
except frame_telemetry.SendError as e:
|
except frame_telemetry.SendError as e:
|
||||||
raise ReportError(str(e))
|
raise ReportError(str(e))
|
||||||
try:
|
try:
|
||||||
|
with frame_telemetry._lock: # the lock a removal holds while wiping its address
|
||||||
|
frame_contact.redact_removed(event, started)
|
||||||
frame_telemetry.record_sent([event])
|
frame_telemetry.record_sent([event])
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # it was sent; failing to log it here mustn't make the person send it again
|
pass # it was sent; failing to log it here mustn't make the person send it again
|
||||||
@@ -135,22 +147,71 @@ def inbox(days=30):
|
|||||||
import frame_compat_db
|
import frame_compat_db
|
||||||
res = frame_compat_db._posthog_query(
|
res = frame_compat_db._posthog_query(
|
||||||
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
||||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
|
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
|
||||||
|
"properties.contact_followup "
|
||||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
||||||
"ORDER BY timestamp DESC LIMIT 200")
|
"ORDER BY timestamp DESC LIMIT 200")
|
||||||
return res.get('results') or []
|
return res.get('results') or []
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def main():
|
||||||
cmd, *args = sys.argv[1:] or ['inbox']
|
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':
|
if cmd != 'inbox':
|
||||||
sys.exit('usage: frame_report.py inbox [days]')
|
sys.exit(USAGE)
|
||||||
for row in inbox(*(args[:1] or [30])):
|
for row in inbox(*(args[:1] or [30])):
|
||||||
if not isinstance(row, list) or len(row) != 10:
|
if not isinstance(row, list) or len(row) != 11:
|
||||||
continue
|
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"== {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 ''}")
|
||||||
print(' ' + text.replace('\n', '\n '))
|
print(' ' + text.replace('\n', '\n '))
|
||||||
if diag:
|
if diag:
|
||||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||||
|
|||||||
+3
-1
@@ -369,8 +369,10 @@ def apply(gs, event, panel):
|
|||||||
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
|
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
|
||||||
STALE[0] = True
|
STALE[0] = True
|
||||||
return panel
|
return panel
|
||||||
if STALE[0] and not stale:
|
release = event.get("down") is False and ("button" in event or "key" in event)
|
||||||
|
if STALE[0] and not stale and not (release and "window" not in event):
|
||||||
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
|
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
|
||||||
|
# A bare release doesn't: the page leaves the panel off releases, so it says nothing about focus.
|
||||||
STALE[0] = False
|
STALE[0] = False
|
||||||
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
|
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
|
||||||
if "fx" in event and panel and panel.get("window"):
|
if "fx" in event and panel and panel.get("window"):
|
||||||
|
|||||||
+942
-57
File diff suppressed because it is too large.
Load diff
+508
-57
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||||
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
||||||
up by scripts/connect.sh or ui/frame_connect.py.
|
up by scripts/connect.sh or ui/frame_connect.py, or another headset picked on the
|
||||||
|
Devices tab: frame_link.py finds it at one of its addresses (frame_devices.py).
|
||||||
|
|
||||||
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||||
Env: FRAME_ALIAS (default frame)
|
Env: FRAME_ALIAS (default frame)
|
||||||
@@ -14,6 +15,7 @@ Env: FRAME_ALIAS (default frame)
|
|||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
@@ -45,9 +47,12 @@ import frame_android # noqa: E402
|
|||||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||||
import frame_apk_versions # noqa: E402
|
import frame_apk_versions # noqa: E402
|
||||||
import frame_catalog # noqa: E402
|
import frame_catalog # noqa: E402
|
||||||
|
import frame_devices # noqa: E402
|
||||||
import frame_steamgriddb
|
import frame_steamgriddb
|
||||||
import frame_comfort # noqa: E402
|
import frame_comfort # noqa: E402
|
||||||
|
import frame_contact # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
|
import frame_link # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
import frame_media # noqa: E402
|
import frame_media # noqa: E402
|
||||||
import frame_panels # noqa: E402
|
import frame_panels # noqa: E402
|
||||||
@@ -55,6 +60,7 @@ import frame_report # noqa: E402
|
|||||||
import frame_store # noqa: E402
|
import frame_store # noqa: E402
|
||||||
import frame_telemetry # noqa: E402
|
import frame_telemetry # noqa: E402
|
||||||
import frame_titles # noqa: E402
|
import frame_titles # noqa: E402
|
||||||
|
import frame_touch # noqa: E402
|
||||||
import frame_webinstall # noqa: E402
|
import frame_webinstall # noqa: E402
|
||||||
import frame_vr # noqa: E402
|
import frame_vr # noqa: E402
|
||||||
import frame_utilities # noqa: E402
|
import frame_utilities # noqa: E402
|
||||||
@@ -74,18 +80,92 @@ DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
|
|||||||
# What the Frame's KDE Connect calls this device (keyboard and trackpad).
|
# What the Frame's KDE Connect calls this device (keyboard and trackpad).
|
||||||
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
|
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
|
||||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
|
FRAME_FROM_ENV = "FRAME_ALIAS" in os.environ
|
||||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||||
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||||
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||||
# supports it (not on Windows: there every command connects on its own).
|
# supports it (not on Windows: there every command connects on its own).
|
||||||
CONTROL = None if LOCAL else frame_host.control_path(private=os.environ.get("FRAME_PRIVATE_SSH") == "1")
|
# A private server (the MCP adapter starts one per session) keeps its own SSH masters, and
|
||||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
# uses the headsets without editing them.
|
||||||
|
PRIVATE = os.environ.get("FRAME_PRIVATE_SSH") == "1"
|
||||||
|
CONTROL = None if LOCAL else frame_host.control_path(private=PRIVATE)
|
||||||
|
# The ControlPath itself is per headset: the connector puts it in HOST_OPTS.
|
||||||
|
MUX = ["ssh", "-o", "BatchMode=yes"]
|
||||||
|
MUX_BASE = list(MUX)
|
||||||
# Commands use the master when it's up and connect directly when it isn't.
|
# Commands use the master when it's up and connect directly when it isn't.
|
||||||
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
SSH_TAIL = [*(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||||
|
SSH = [*MUX, *SSH_TAIL]
|
||||||
|
# The address the connector picked (-o HostName=... and friends), in every ssh command.
|
||||||
|
HOST_OPTS = []
|
||||||
|
|
||||||
# Android helpers share the multiplexed connection when it's up.
|
# Android helpers share the multiplexed connection when it's up.
|
||||||
frame_android.SSH_OPTS = SSH[1:]
|
frame_android.SSH_OPTS = SSH[1:]
|
||||||
|
|
||||||
|
|
||||||
|
_route_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def route(alias, host_opts):
|
||||||
|
"""Point every ssh, scp and rsync at `alias` with `host_opts` (frame_link calls this
|
||||||
|
when it picks a headset and an address). The lists change in place, so code holding
|
||||||
|
them follows; frame_titles reads frame_android.SSH_OPTS at call time."""
|
||||||
|
global FRAME, HOST_OPTS
|
||||||
|
with _route_lock:
|
||||||
|
moved = alias != FRAME
|
||||||
|
if moved:
|
||||||
|
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
|
||||||
|
FRAME = frame_android.FRAME = alias
|
||||||
|
HOST_OPTS = list(host_opts)
|
||||||
|
MUX[:] = [*MUX_BASE, *HOST_OPTS]
|
||||||
|
SSH[:] = [*MUX, *SSH_TAIL]
|
||||||
|
frame_android.SSH_OPTS = SSH[1:]
|
||||||
|
# Long-lived ssh processes started on the old route (outside the lock: they take their own).
|
||||||
|
mv = globals().get("macview")
|
||||||
|
if mv:
|
||||||
|
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
|
||||||
|
agent = globals().get("_input")
|
||||||
|
if moved and agent:
|
||||||
|
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
|
||||||
|
|
||||||
|
|
||||||
|
LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||||
|
|
||||||
|
# Installs and other changes in progress. Switching headsets waits for them: they
|
||||||
|
# read the ssh settings step by step, so a switch could send the rest (or a failed
|
||||||
|
# install's clean-up) to the other headset.
|
||||||
|
_work_lock = threading.Lock()
|
||||||
|
_work = [0]
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def working(meant=None):
|
||||||
|
"""Counts as running work. `meant`: the headset the page made this change for; if the
|
||||||
|
app has switched away from it, refuse (checked together with counting, so a switch
|
||||||
|
can't slip in between)."""
|
||||||
|
with _work_lock:
|
||||||
|
if LINK and meant and meant != LINK.active_device()["id"]:
|
||||||
|
raise Failure("Frame Control switched headsets; try again on this one", 409)
|
||||||
|
_work[0] += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
with _work_lock:
|
||||||
|
_work[0] -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def busy_thread(fn, *args):
|
||||||
|
"""A thread that counts as work from before it starts until it ends."""
|
||||||
|
with _work_lock:
|
||||||
|
_work[0] += 1
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
fn(*args)
|
||||||
|
finally:
|
||||||
|
with _work_lock:
|
||||||
|
_work[0] -= 1
|
||||||
|
return threading.Thread(target=run, daemon=True)
|
||||||
|
|
||||||
APPID = re.compile(r"^\d{1,10}$")
|
APPID = re.compile(r"^\d{1,10}$")
|
||||||
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
|
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
|
||||||
MAX_UPLOAD = 8 * 1024**3
|
MAX_UPLOAD = 8 * 1024**3
|
||||||
@@ -228,7 +308,7 @@ def start_job(label, work, progress=False):
|
|||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
_jobs[job].update(fields, done=True, time=time.time())
|
_jobs[job].update(fields, done=True, time=time.time())
|
||||||
|
|
||||||
threading.Thread(target=run, daemon=True).start()
|
busy_thread(run).start()
|
||||||
return {"message": f"{label}…", "job": job}
|
return {"message": f"{label}…", "job": job}
|
||||||
|
|
||||||
|
|
||||||
@@ -241,42 +321,14 @@ def job_status(query):
|
|||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
_master_lock = threading.Lock()
|
|
||||||
_master = None
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_master():
|
def ensure_master():
|
||||||
"""Start the shared SSH connection if it isn't up (one attempt at a time).
|
"""Make sure the connection to the headset is up, or being tried (frame_link.Link.ensure)."""
|
||||||
|
if LINK:
|
||||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
LINK.ensure()
|
||||||
"""
|
|
||||||
global _master
|
|
||||||
if not CONTROL:
|
|
||||||
return
|
|
||||||
|
|
||||||
def up():
|
|
||||||
try:
|
|
||||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
|
|
||||||
timeout=5).returncode == 0
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return False
|
|
||||||
|
|
||||||
with _master_lock:
|
|
||||||
if up() or (_master and _master.poll() is None):
|
|
||||||
return
|
|
||||||
# Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s,
|
|
||||||
# so the next request starts a fresh master.
|
|
||||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
|
||||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
|
||||||
for _ in range(60):
|
|
||||||
if up() or _master.poll() is not None:
|
|
||||||
return
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
|
|
||||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||||
|
route_gen = LINK.gen if LINK else None # which headset this command is for
|
||||||
try:
|
try:
|
||||||
ensure_master()
|
ensure_master()
|
||||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||||
@@ -288,6 +340,8 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
raise Failure(f"Timed out talking to {FRAME}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||||
|
if r.returncode == 255 and LINK and unreachable(err):
|
||||||
|
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||||
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
||||||
raise failure
|
raise failure
|
||||||
@@ -350,6 +404,12 @@ print(r.stdout, end='')
|
|||||||
|
|
||||||
def headset_view():
|
def headset_view():
|
||||||
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
||||||
|
# Counts as work: the copy and clean-up must reach the headset that took the capture.
|
||||||
|
with working():
|
||||||
|
return _headset_view()
|
||||||
|
|
||||||
|
|
||||||
|
def _headset_view():
|
||||||
# `timeout`: VR_Init can block if SteamVR is restarting.
|
# `timeout`: VR_Init can block if SteamVR is restarting.
|
||||||
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
|
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
|
||||||
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
|
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
|
||||||
@@ -946,12 +1006,274 @@ def licenses():
|
|||||||
return notices
|
return notices
|
||||||
|
|
||||||
|
|
||||||
|
# KDE Connect's specialKey numbers -> Linux key codes (KEY_BACKSPACE, KEY_TAB, ...).
|
||||||
|
PAD_KEYS = {1: 14, 2: 15, 4: 105, 5: 103, 6: 106, 7: 108, 8: 104, 9: 109, 10: 102, 11: 107, 12: 28, 13: 111, 14: 1,
|
||||||
|
**{21 + i: code for i, code in enumerate([59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88])}}
|
||||||
|
PAD_MODS = (("ctrl", 29), ("alt", 56), ("shift", 42), ("super", 125))
|
||||||
|
PAD_CLICKS = {"singleclick": "left", "rightclick": "right", "middleclick": "middle"}
|
||||||
|
PAD_NOTCH = 15 # scroll units for one wheel notch
|
||||||
|
|
||||||
|
|
||||||
|
def typed_in_kde(events):
|
||||||
|
"""Whether these events must go the KDE Connect way: they hold an accent or emoji, or
|
||||||
|
earlier ones are queued, being sent or still being typed there (typing must stay in
|
||||||
|
order, so it can't be split between the two ways)."""
|
||||||
|
with _kde_lock:
|
||||||
|
waiting = bool(_kde_queue) or time.time() < _kde_until
|
||||||
|
return waiting or any(ch not in frame_touch.ASCII for e in events for ch in e.get("key", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def pad_events(events, kde=False):
|
||||||
|
"""The trackpad's and key row's events as (gamescope events, KDE Connect events).
|
||||||
|
|
||||||
|
Gamescope's own input reaches every panel and needs nothing installed, but types only
|
||||||
|
US-keyboard characters. With kde, the keyboard goes to KDE Connect as it was sent, so
|
||||||
|
text stays in order; the pointer always goes through gamescope.
|
||||||
|
"""
|
||||||
|
ascii_keys = frame_touch.ASCII
|
||||||
|
out, slow = [], []
|
||||||
|
|
||||||
|
def tap(code, mods):
|
||||||
|
down = [{"key": c, "down": True} for c in mods]
|
||||||
|
out.extend(down + [{"key": code, "down": True}, {"key": code, "down": False}]
|
||||||
|
+ [{"key": c, "down": False} for c in reversed(mods)])
|
||||||
|
for e in events:
|
||||||
|
mods = [code for name, code in PAD_MODS if e.get(name)]
|
||||||
|
if "dx" in e or "dy" in e:
|
||||||
|
if not e.get("scroll"):
|
||||||
|
out.append({k: e[k] for k in ("dx", "dy") if k in e})
|
||||||
|
if e.get("scroll"):
|
||||||
|
out.append({"scroll": [0, -(e.get("dy") or 0) * PAD_NOTCH]})
|
||||||
|
for flag, button in PAD_CLICKS.items():
|
||||||
|
if e.get(flag):
|
||||||
|
out += [{"button": button, "down": True}, {"button": button, "down": False}]
|
||||||
|
if e.get("doubleclick"):
|
||||||
|
out += [{"button": "left", "down": d} for d in (True, False, True, False)]
|
||||||
|
if e.get("singlehold"):
|
||||||
|
out.append({"button": "left", "down": True})
|
||||||
|
if e.get("singlerelease"):
|
||||||
|
out.append({"button": "left", "down": False})
|
||||||
|
if kde and ("key" in e or "specialKey" in e):
|
||||||
|
slow.append({k: e[k] for k in ("key", "specialKey", "ctrl", "alt", "shift", "super") if k in e})
|
||||||
|
continue
|
||||||
|
if "specialKey" in e and e["specialKey"] in PAD_KEYS:
|
||||||
|
tap(PAD_KEYS[e["specialKey"]], mods)
|
||||||
|
if "key" in e:
|
||||||
|
for ch in e["key"]:
|
||||||
|
if ch not in ascii_keys:
|
||||||
|
continue # only reached when kde is wrong; the caller checks typed_in_kde first
|
||||||
|
elif mods:
|
||||||
|
code, shifted = ascii_keys[ch]
|
||||||
|
tap(code, mods + ([42] if shifted and 42 not in mods else []))
|
||||||
|
else:
|
||||||
|
if out and "text" in out[-1] and len(out[-1]["text"]) < 100:
|
||||||
|
out[-1]["text"] += ch
|
||||||
|
else:
|
||||||
|
out.append({"text": ch})
|
||||||
|
return out, slow
|
||||||
|
|
||||||
|
|
||||||
def remote_input(body):
|
def remote_input(body):
|
||||||
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent."""
|
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent.
|
||||||
|
|
||||||
|
"sent" in the answer means the whole batch is taken (the page keeps it and tries again
|
||||||
|
if not), so the keyboard's share is queued only once that's so.
|
||||||
|
"""
|
||||||
events = body.get("events", [])
|
events = body.get("events", [])
|
||||||
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
|
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
|
||||||
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
|
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
|
||||||
return _input.send([input_event(e) for e in events])
|
events = [input_event(e) for e in events]
|
||||||
|
touch, slow = pad_events(events, typed_in_kde(events))
|
||||||
|
status = _touch.send(touch)
|
||||||
|
if touch and not status.get("sent"):
|
||||||
|
return status
|
||||||
|
if touch:
|
||||||
|
_note_typed(touch)
|
||||||
|
if slow:
|
||||||
|
_queue_for_kde(slow)
|
||||||
|
return {**status, "sent": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Typing KDE Connect is to do: it starts the first time it's needed, so this waits for it.
|
||||||
|
# Nothing on the Frame says when typed text has landed, so each way holds the other back
|
||||||
|
# for as long as what it was given should take (a short margin on a time estimate).
|
||||||
|
_kde_queue = []
|
||||||
|
_kde_lock = threading.Lock()
|
||||||
|
_kde_worker = [None]
|
||||||
|
_kde_until = 0.0 # keys typed through KDE Connect should have landed by then
|
||||||
|
_typed_until = 0.0 # when gamescope's agent will have typed everything it's been given
|
||||||
|
KDE_QUEUE_LIMIT = 200
|
||||||
|
KDE_WAIT = 90 # seconds to wait for KDE Connect before giving up on what's queued
|
||||||
|
TYPING_SETTLE = 0.6 # seconds past the estimate, once
|
||||||
|
|
||||||
|
|
||||||
|
def typing_seconds(touch):
|
||||||
|
"""How long gamescope's agent takes over these: it sleeps 8 ms after every key transition."""
|
||||||
|
transitions = 0
|
||||||
|
for e in touch:
|
||||||
|
if "text" in e:
|
||||||
|
transitions += sum(2 + 2 * frame_touch.ASCII[ch][1] for ch in e["text"] if ch in frame_touch.ASCII)
|
||||||
|
elif "key" in e:
|
||||||
|
transitions += 1
|
||||||
|
return transitions * 0.008
|
||||||
|
|
||||||
|
|
||||||
|
def _note_typed(touch):
|
||||||
|
global _typed_until
|
||||||
|
seconds = typing_seconds(touch)
|
||||||
|
if seconds:
|
||||||
|
with _kde_lock: # the end of the work, which queues up; the margin is added once, where it's checked
|
||||||
|
_typed_until = max(_typed_until, time.time()) + seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_for_kde(events):
|
||||||
|
with _kde_lock:
|
||||||
|
_kde_queue.extend(events)
|
||||||
|
del _kde_queue[:-KDE_QUEUE_LIMIT]
|
||||||
|
if _kde_worker[0] is None:
|
||||||
|
_kde_worker[0] = threading.Thread(target=_drain_kde, daemon=True)
|
||||||
|
_kde_worker[0].start()
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_kde():
|
||||||
|
"""The only sender, in order. A batch leaves the queue while it's being sent (the queue
|
||||||
|
can be trimmed meanwhile) and goes back to the front if it didn't go."""
|
||||||
|
global _kde_until
|
||||||
|
deadline = time.time() + KDE_WAIT
|
||||||
|
while True:
|
||||||
|
with _kde_lock:
|
||||||
|
batch = _kde_queue[:INPUT_BATCH_LIMIT]
|
||||||
|
del _kde_queue[:len(batch)]
|
||||||
|
if not batch:
|
||||||
|
_kde_worker[0] = None # retired under the lock, so a new request starts another
|
||||||
|
return
|
||||||
|
wait = _typed_until + TYPING_SETTLE - time.time()
|
||||||
|
_kde_until = time.time() + 3600 # held while in flight
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait) # gamescope's typing, from just before, goes first
|
||||||
|
status = _input.send(batch)
|
||||||
|
with _kde_lock:
|
||||||
|
if status.get("sent"):
|
||||||
|
_kde_until = time.time() + len(batch) * 0.03 + TYPING_SETTLE
|
||||||
|
deadline = time.time() + KDE_WAIT
|
||||||
|
continue
|
||||||
|
_kde_until = 0.0
|
||||||
|
if status.get("state") == "error" or time.time() > deadline:
|
||||||
|
_kde_queue.clear() # not coming; don't hold it forever
|
||||||
|
_kde_worker[0] = None
|
||||||
|
return
|
||||||
|
_kde_queue[:0] = batch
|
||||||
|
del _kde_queue[:-KDE_QUEUE_LIMIT] # as when queuing: the oldest go first
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
|
||||||
|
|
||||||
|
PANEL_WINDOW = re.compile(r"^\d{1,10}$")
|
||||||
|
PANEL_DISPLAY = re.compile(r"^:\d{1,2}$")
|
||||||
|
TOUCH_BUTTONS = ("left", "right", "middle")
|
||||||
|
|
||||||
|
|
||||||
|
def panels():
|
||||||
|
"""The headset's app panels (window, display, name, size) and which has focus."""
|
||||||
|
return json.loads(ssh("python3 - panels", stdin=(HERE / "frame_touch.py").read_text(), timeout=20))
|
||||||
|
|
||||||
|
|
||||||
|
def panel_target(q):
|
||||||
|
window, display = (q.get("window") or [""])[0], (q.get("display") or [""])[0]
|
||||||
|
if not PANEL_WINDOW.match(window) or not PANEL_DISPLAY.match(display):
|
||||||
|
raise Failure("window must be a window id and display an X display such as :1", 400)
|
||||||
|
return window, display
|
||||||
|
|
||||||
|
|
||||||
|
def panel_capture(query):
|
||||||
|
"""One frame of a panel's own window, as PNG (its pixels, without the room around it)."""
|
||||||
|
window, display = panel_target(parse_qs(query))
|
||||||
|
return ssh(f"DISPLAY={display} timeout 10 ffmpeg -hide_banner -loglevel error -nostdin -f x11grab "
|
||||||
|
f"-window_id {window} -i {display} -frames:v 1 -f image2pipe -c:v png -", timeout=20, text=False)
|
||||||
|
|
||||||
|
|
||||||
|
def touch_event(event):
|
||||||
|
"""A frame_touch.py event with only the fields it knows, in range."""
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
raise Failure("each touch event must be an object", 400)
|
||||||
|
|
||||||
|
def num(name, limit):
|
||||||
|
value = event.get(name)
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
|
||||||
|
raise Failure(f"{name} must be a number", 400)
|
||||||
|
return max(-limit, min(limit, round(float(value), 4)))
|
||||||
|
out = {}
|
||||||
|
if "fx" in event or "fy" in event:
|
||||||
|
if "window" not in event:
|
||||||
|
raise Failure("a position needs the panel's window and display", 400)
|
||||||
|
out.update(fx=num("fx", 1), fy=num("fy", 1))
|
||||||
|
# Any event can name the panel it's meant for; the Frame drops it if another has focus.
|
||||||
|
if "window" in event:
|
||||||
|
window, display = event.get("window"), event.get("display")
|
||||||
|
if isinstance(window, bool) or not isinstance(window, int) or window <= 0:
|
||||||
|
raise Failure("window must be the panel's window id", 400)
|
||||||
|
if not isinstance(display, str) or not PANEL_DISPLAY.match(display):
|
||||||
|
raise Failure("display must be an X display such as :1", 400)
|
||||||
|
out.update(window=window, display=display)
|
||||||
|
for name in ("dx", "dy"):
|
||||||
|
if name in event:
|
||||||
|
out[name] = num(name, INPUT_MOVE_LIMIT)
|
||||||
|
if "button" in event:
|
||||||
|
if event["button"] not in TOUCH_BUTTONS:
|
||||||
|
raise Failure(f"button must be one of {', '.join(TOUCH_BUTTONS)}", 400)
|
||||||
|
out["button"], out["down"] = event["button"], event.get("down") is not False
|
||||||
|
if "scroll" in event:
|
||||||
|
sc = event["scroll"]
|
||||||
|
if not isinstance(sc, list) or len(sc) != 2:
|
||||||
|
raise Failure("scroll must be [dx, dy]", 400)
|
||||||
|
out["scroll"] = [num_value(v, 5000) for v in sc]
|
||||||
|
if "key" in event:
|
||||||
|
key = event["key"]
|
||||||
|
if isinstance(key, bool) or not isinstance(key, int) or not 0 < key < 768:
|
||||||
|
raise Failure("key must be a Linux key code", 400)
|
||||||
|
out["key"], out["down"] = key, event.get("down") is not False
|
||||||
|
if "text" in event:
|
||||||
|
text = event["text"]
|
||||||
|
if not isinstance(text, str) or not 0 < len(text) <= INPUT_TEXT_LIMIT:
|
||||||
|
raise Failure(f"text must be 1 to {INPUT_TEXT_LIMIT} characters", 400)
|
||||||
|
out["text"] = text
|
||||||
|
if not set(out) - {"window", "display"}:
|
||||||
|
raise Failure("touch event has nothing to do", 400)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def num_value(value, limit):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
|
||||||
|
raise Failure("scroll values must be numbers", 400)
|
||||||
|
return max(-limit, min(limit, round(float(value), 2)))
|
||||||
|
|
||||||
|
|
||||||
|
class TouchAgent(InputAgent):
|
||||||
|
"""frame_touch.py on the Frame, fed events over one long-lived ssh. Nothing to install:
|
||||||
|
it uses gamescope's own input socket and the libei that's on the image."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(source=HERE / "frame_touch.py", packages=[])
|
||||||
|
|
||||||
|
def deliver(self, report, force=False):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def command(self, folder=""):
|
||||||
|
code = base64.b64encode(self.source.read_bytes()).decode()
|
||||||
|
return "python3 -u -c " + shlex.quote(
|
||||||
|
f"import base64;exec(compile(base64.b64decode('{code}'),'frame_touch','exec'))")
|
||||||
|
|
||||||
|
|
||||||
|
_touch = TouchAgent()
|
||||||
|
|
||||||
|
|
||||||
|
def remote_touch(body):
|
||||||
|
"""{"events": [...]} points, clicks, scrolls and types into the focused panel."""
|
||||||
|
events = body.get("events", [])
|
||||||
|
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
|
||||||
|
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
|
||||||
|
return _touch.send([touch_event(e) for e in events])
|
||||||
|
|
||||||
|
|
||||||
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
|
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
|
||||||
@@ -1111,23 +1433,35 @@ def open_thing(body):
|
|||||||
if what in ("reboot", "poweroff", "suspend"):
|
if what in ("reboot", "poweroff", "suspend"):
|
||||||
return power(what, body.get("password"))
|
return power(what, body.get("password"))
|
||||||
raise Failure("open that from the app", 400)
|
raise Failure("open that from the app", 400)
|
||||||
|
# The headset and address in use, as every other command gets them (one snapshot).
|
||||||
|
with _route_lock:
|
||||||
|
alias, opts = LINK.named_route() if LINK else (FRAME, list(HOST_OPTS))
|
||||||
|
host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None)
|
||||||
|
if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"):
|
||||||
|
raise Failure("No headset address to use: add one on the Devices tab", 400)
|
||||||
try:
|
try:
|
||||||
if what == "terminal":
|
if what == "terminal":
|
||||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
return {"message": f"Opened an SSH session in {terminal(['ssh', *opts, alias])}"}
|
||||||
if what in ("reboot", "poweroff", "suspend"):
|
if what in ("reboot", "poweroff", "suspend"):
|
||||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||||
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
where = terminal(["ssh", "-t", *opts, alias, "sudo", "systemctl", what])
|
||||||
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||||
if what == "steamlink":
|
if what == "steamlink":
|
||||||
return {"message": frame_host.open_steam_link()}
|
return {"message": frame_host.open_steam_link()}
|
||||||
if what == "rdp":
|
if what == "rdp":
|
||||||
return {"message": frame_host.open_rdp(FRAME)}
|
return {"message": frame_host.open_rdp(alias, host)}
|
||||||
if what == "sftp":
|
if what == "sftp":
|
||||||
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
return {"message": f"Opened an SFTP session in {terminal(['sftp', *opts, alias])}"}
|
||||||
if what == "shots":
|
if what == "shots":
|
||||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
frame_host.open_path(SHOTS_DIR)
|
frame_host.open_path(SHOTS_DIR)
|
||||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
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.HostError as e:
|
except frame_host.HostError as e:
|
||||||
raise Failure(str(e), 500)
|
raise Failure(str(e), 500)
|
||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
@@ -1257,7 +1591,8 @@ def stage_title(path, temp_dir=None, name=None):
|
|||||||
raise
|
raise
|
||||||
token = secrets.token_hex(12)
|
token = secrets.token_hex(12)
|
||||||
with _titles_lock:
|
with _titles_lock:
|
||||||
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
|
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time(),
|
||||||
|
"device": LINK.active_device()["id"] if LINK else None}
|
||||||
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
|
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
|
||||||
"token": token, "plan": frame_titles.public(plan)}
|
"token": token, "plan": frame_titles.public(plan)}
|
||||||
|
|
||||||
@@ -1302,13 +1637,15 @@ def titles(body):
|
|||||||
if action == "discard":
|
if action == "discard":
|
||||||
_drop_staged(entry)
|
_drop_staged(entry)
|
||||||
return {"message": "Discarded"}
|
return {"message": "Discarded"}
|
||||||
|
if LINK and entry.get("device") != LINK.active_device()["id"]:
|
||||||
|
_drop_staged(entry) # it was checked against the other headset's titles
|
||||||
|
raise Failure("Frame Control switched headsets since this was read; drop the file again", 409)
|
||||||
with _titles_lock:
|
with _titles_lock:
|
||||||
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
|
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
|
||||||
"message": None, "title": None, "time": time.time()}
|
"message": None, "title": None, "time": time.time()}
|
||||||
ensure_master()
|
ensure_master()
|
||||||
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
|
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
|
||||||
threading.Thread(target=_run_title_install, daemon=True,
|
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
|
||||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
|
||||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||||
if action not in ("launch", "remove", "refresh-art"):
|
if action not in ("launch", "remove", "refresh-art"):
|
||||||
raise Failure("unknown action", 400)
|
raise Failure("unknown action", 400)
|
||||||
@@ -1421,7 +1758,7 @@ class AdbTunnel:
|
|||||||
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
|
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
|
||||||
# Its own connection (ControlPath=none), so killing it drops the forwards.
|
# Its own connection (ControlPath=none), so killing it drops the forwards.
|
||||||
self.proc = _proc = subprocess.Popen(
|
self.proc = _proc = subprocess.Popen(
|
||||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none",
|
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none", *HOST_OPTS,
|
||||||
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
|
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
_live_tunnels.add(_proc)
|
_live_tunnels.add(_proc)
|
||||||
@@ -1696,7 +2033,7 @@ def webinstall_start(body):
|
|||||||
_web_jobs.clear()
|
_web_jobs.clear()
|
||||||
_web_jobs[pid] = job
|
_web_jobs[pid] = job
|
||||||
# Started under the lock, so shutdown never sees a thread it can't join.
|
# Started under the lock, so shutdown never sees a thread it can't join.
|
||||||
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
|
worker = busy_thread(_webinstall_run, plan, job)
|
||||||
_web_workers.add(worker)
|
_web_workers.add(worker)
|
||||||
worker.start()
|
worker.start()
|
||||||
return {"job": pid}
|
return {"job": pid}
|
||||||
@@ -2084,7 +2421,56 @@ POST = {
|
|||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
"/api/webinstall/cancel": webinstall_cancel,
|
"/api/webinstall/cancel": webinstall_cancel,
|
||||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action}
|
"/api/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 ------------------------------------------------------------------
|
# ---- HTTP ------------------------------------------------------------------
|
||||||
@@ -2205,6 +2591,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||||
|
elif path == "/api/connection":
|
||||||
|
self.send_json(connection_state())
|
||||||
|
elif path == "/api/connection/events":
|
||||||
|
self.connection_events()
|
||||||
|
elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"):
|
||||||
|
self.send_json(devices_get(path, url.query))
|
||||||
elif path.startswith("/source-image/"):
|
elif path.startswith("/source-image/"):
|
||||||
from apk_sources import _images
|
from apk_sources import _images
|
||||||
try:
|
try:
|
||||||
@@ -2243,7 +2635,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif path == "/api/touch":
|
elif path == "/api/touch":
|
||||||
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
|
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
|
||||||
elif path == "/api/input":
|
elif path == "/api/input":
|
||||||
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
|
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
|
||||||
elif path == "/api/job":
|
elif path == "/api/job":
|
||||||
self.send_json(job_status(url.query))
|
self.send_json(job_status(url.query))
|
||||||
elif path == "/api/android/displays":
|
elif path == "/api/android/displays":
|
||||||
@@ -2259,6 +2651,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(macview_state(parse_qs(url.query)))
|
self.send_json(macview_state(parse_qs(url.query)))
|
||||||
elif path == "/api/telemetry":
|
elif path == "/api/telemetry":
|
||||||
self.send_json(frame_telemetry.state())
|
self.send_json(frame_telemetry.state())
|
||||||
|
elif path == "/api/contact":
|
||||||
|
self.send_json(frame_contact.state())
|
||||||
elif path == "/api/computer/state":
|
elif path == "/api/computer/state":
|
||||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||||
elif path == "/api/status":
|
elif path == "/api/status":
|
||||||
@@ -2298,9 +2692,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not self.local_request():
|
if not self.local_request():
|
||||||
return
|
return
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
|
meant = self.headers.get("X-Frame-Device")
|
||||||
body = None
|
body = None
|
||||||
try:
|
try:
|
||||||
if path == "/api/upload":
|
if path == "/api/upload":
|
||||||
|
with working(meant):
|
||||||
self.send_json(self.upload())
|
self.send_json(self.upload())
|
||||||
return
|
return
|
||||||
handler = POST.get(path)
|
handler = POST.get(path)
|
||||||
@@ -2313,7 +2709,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
body = json.loads(self.rfile.read(length) or b"{}")
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
raise Failure("request body must be a JSON object", 400)
|
raise Failure("request body must be a JSON object", 400)
|
||||||
self.send_json(handler(body))
|
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
||||||
|
result = handler(body)
|
||||||
|
self.send_json(result)
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
if e.status >= 500:
|
if e.status >= 500:
|
||||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||||
@@ -2327,6 +2725,30 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||||
|
|
||||||
|
def connection_events(self):
|
||||||
|
"""Server-sent events: the connection state each time it changes, until the page goes.
|
||||||
|
(The page reads it with fetch, which can send the X-Frame-UI header; EventSource can't.)"""
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.end_headers()
|
||||||
|
self.close_connection = True
|
||||||
|
version = -1
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
snap = connection_state() if version < 0 else (LINK.wait(version, 15) if LINK else None)
|
||||||
|
if snap is None:
|
||||||
|
if not LINK and version >= 0:
|
||||||
|
time.sleep(15)
|
||||||
|
self.wfile.write(b": still here\n\n") # keeps proxies and the page's watchdog happy
|
||||||
|
else:
|
||||||
|
version = max(snap["version"], 0)
|
||||||
|
self.wfile.write(b"data: " + json.dumps(snap).encode() + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
except OSError:
|
||||||
|
pass # the page went away
|
||||||
|
|
||||||
def stream_video(self, query):
|
def stream_video(self, query):
|
||||||
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
||||||
global _stream_proc
|
global _stream_proc
|
||||||
@@ -2466,6 +2888,24 @@ class LoopbackServer(ThreadingHTTPServer):
|
|||||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
||||||
|
|
||||||
|
|
||||||
|
_ONE_SERVER = None
|
||||||
|
|
||||||
|
|
||||||
|
def one_server():
|
||||||
|
"""Only one Frame Control server per user: two would each connect, reconnect and
|
||||||
|
edit the headsets on their own, and could move each other's installs to another
|
||||||
|
headset. Held until this process exits. (FRAME_CONTROL_DATA_DIR gives a second,
|
||||||
|
separate one, as the tests do. A private server, the MCP adapter's, runs alongside:
|
||||||
|
it can't add, remove or switch headsets.)"""
|
||||||
|
lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=float(os.environ.get("FRAME_CONTROL_SERVER_WAIT") or 20)) # while the app restarts it
|
||||||
|
try:
|
||||||
|
lock.__enter__()
|
||||||
|
except OSError:
|
||||||
|
sys.exit("Frame Control is already running on this computer (the app, or a server started "
|
||||||
|
"from a terminal). Quit it, then try again.")
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||||
@@ -2477,16 +2917,29 @@ def main():
|
|||||||
sweep_tmp()
|
sweep_tmp()
|
||||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||||
frame_telemetry.start()
|
frame_telemetry.start()
|
||||||
|
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:
|
if not frame_host.WINDOWS:
|
||||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||||
if args.exit_on_eof:
|
if args.exit_on_eof:
|
||||||
def watch_stdin():
|
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=httpd.shutdown, daemon=True).start()
|
||||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||||
|
try:
|
||||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
# 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)
|
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||||
try:
|
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
@@ -2498,10 +2951,8 @@ def main():
|
|||||||
webinstall_shutdown()
|
webinstall_shutdown()
|
||||||
macview.shutdown() # close the headset's viewers before the agent goes
|
macview.shutdown() # close the headset's viewers before the agent goes
|
||||||
# The master was started with -N, so it stays up until told to exit.
|
# The master was started with -N, so it stays up until told to exit.
|
||||||
if CONTROL:
|
if LINK:
|
||||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
LINK.stop()
|
||||||
if _master and _master.poll() is None:
|
|
||||||
_master.terminate()
|
|
||||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
|||||||
Reference in new issue
Block a user