Files
saphid--frame-control/app/main.js
T
saphidandClaude Opus 5.5 0f770dc88a Tabs, one offline banner, background installs, drop anywhere
The page was one 6,800px scroll with nine nav links (hidden below 1150px).
It is now four tabs, Home, Games, Android and Tools, switched with 1-4; old
section links still land on the right tab.

When the Frame can't be reached, the server turns ssh's connection errors into
one plain message (503, offline: true), the page shows a single banner with
Retry and Set Up Connection, retries every 8 s, and reloads every panel when
the Frame answers. Panels say "Waiting for the Frame" instead of raw ssh text.

Flatpak and Android catalogue installs run as background jobs the page polls,
so a slow install no longer holds a request for up to 15 minutes or reports a
false failure; the bottom bar counts running installs.

Files can be dropped anywhere in the window, as the README already said.
Recent reports show the newest five, with Show all. Android display explains
an empty or failed read. A topped-up headset on a charger reads as not
charging rather than "still draining, using 0.0 W".

Fixes a race where the catalogue and reports loads wrote the compat-db
mirror's .tmp file at once and one failed with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 10:23:24 +10:00

396 lines
17 KiB
JavaScript

// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const run = promisify(execFile);
const IS_MAC = process.platform === "darwin";
const IS_WIN = process.platform === "win32";
// Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const TOOLS = path.join(ROOT, "tools"); // bundled adb and CA certificates
const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts");
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
: path.join(app.getPath("userData"), "logs");
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
let server = null;
let url = null;
let win = null;
let quitting = false;
let python = null;
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare).
// Take PATH from the login shell instead. Windows has no login shell to ask.
// Runs asynchronously so a slow shell profile can't freeze the window.
let cachedPath = null;
async function loginPath() {
if (IS_WIN) return process.env.PATH || "";
if (cachedPath) return cachedPath;
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
let fromShell = "";
try {
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
{ encoding: "utf8", timeout: 5000 });
fromShell = (stdout.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
} catch {}
const parts = [...fromShell.split(":"), ...(process.env.PATH || "").split(":"), ...extra];
const joined = [...new Set(parts.filter(Boolean))].join(":");
if (fromShell) cachedPath = joined; // retry next time if the shell didn't answer
return joined;
}
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
// -I ignores PYTHON* variables and user site-packages, so a PYTHONHOME or
// PYTHONPATH set for another Python can't break the bundled one. That makes these
// flags stand in for PYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE (no __pycache__
// inside the signed app) and PYTHONUTF8.
const PY_FLAGS = ["-I", "-u", "-B", "-X", "utf8"];
async function findPython(env) {
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
// The packaged app bundles Python (app/build/fetch-deps.js); a checkout uses PATH.
const candidates = [path.join(ROOT, "python", ...(IS_WIN ? ["python.exe"] : ["bin", "python3"]))];
for (const dir of env.PATH.split(path.delimiter)) {
// The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
for (const name of names) candidates.push(path.join(dir, name));
}
for (const p of candidates) {
try {
fs.accessSync(p, fs.constants.X_OK);
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
await run(p, [...PY_FLAGS, "-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
{ timeout: 10000, env, windowsHide: true });
return p;
} catch {}
}
return null;
}
async function hasSsh(env) {
try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
}
const PYTHON_HELP = app.isPackaged ? "The bundled Python is missing; reinstall Frame Control."
: "Install Python 3.8 or later, then reopen the app.";
const SSH_HELP = IS_WIN
? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client."
: "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
function freePort() {
return new Promise((resolve, reject) => {
const s = net.createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
});
}
// Ready once the port answers with our Server header.
function ping(target) {
return new Promise((resolve) => {
const req = http.get(target, { timeout: 1000 }, (res) => {
res.resume();
resolve(/^FrameControl/.test(res.headers.server || ""));
});
req.on("error", () => resolve(false));
req.on("timeout", () => { req.destroy(); resolve(false); });
});
}
async function startServer() {
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
const port = await freePort();
fs.mkdirSync(LOG_DIR, { recursive: true });
const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
// stdin stays open while the app runs; the server exits cleanly when it closes.
const child = spawn(python, [...PY_FLAGS, SERVER, "--port", String(port), "--exit-on-eof"],
{ env, stdio: ["pipe", log, log], windowsHide: true });
child.stdin.on("error", () => {});
fs.closeSync(log);
server = child;
let exited = null;
child.once("error", (err) => {
exited = err.message;
if (server === child) { server = null; if (!quitting && url) serverDied(err.message); }
});
child.once("exit", (code, signal) => {
exited = signal || code;
if (server !== child) return; // replaced by Restart Server
server = null;
if (!quitting && url) serverDied(exited);
});
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
endServer(child);
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
}
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
function endServer(child) {
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
}
function stopServer() {
if (server) endServer(server);
}
function errorPage(message) {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
function serverDied(why) {
url = null;
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
async function restartServer() {
const old = server;
server = null;
url = null;
if (old) endServer(old);
await load();
}
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
`;
// Restart Server can start a new load while an older one is still waiting for
// its server; only the newest load may touch the window.
let loadGen = 0;
async function load() {
const gen = ++loadGen;
try {
if (!url) await startServer();
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
}
}
// `ssh -G` prints the effective config. An alias nobody configured keeps its
// own name as HostName; connect.sh always writes a HostName.
async function aliasConfigured(env) {
try {
const { stdout } = await run("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env });
return (stdout.match(/^hostname (.*)$/m) || [])[1] !== FRAME;
} catch {
return true; // can't tell; don't nag
}
}
let setupOffered = false;
async function firstRunCheck() {
if (setupOffered || !url) return; // not on the error page, and once per launch
if (await aliasConfigured({ ...process.env, PATH: await loginPath() })) return;
if (!win || setupOffered) return;
setupOffered = true;
const { response } = await dialog.showMessageBox(win, {
type: "info",
message: "Connect to your Steam Frame",
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the "
+ "headset, creates a key, and asks for that password once in a terminal window.",
buttons: ["Set Up Connection…", "Later"],
defaultId: 0, cancelId: 1,
});
if (response === 0) setUpConnection();
}
// IPC only from our own page in our own window.
function fromUi(e) {
if (!win || e.sender !== win.webContents || !url || !e.senderFrame) return false;
try {
return new URL(e.senderFrame.url).origin === new URL(url).origin;
} catch { return false; }
}
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
// frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch),
// so they wait here until the page asks for them. The page checks the link with
// the server and installs nothing until the user confirms in its dialog.
const pendingLinks = [];
let linkPage = null; // the webContents whose current page is listening
function openInstallLink(raw) {
const req = parseInstallLink(raw);
if (!req) {
app.whenReady().then(() => dialog.showErrorBox("Frame Control can't use this link",
"Install links look like frame-control://install?manifest=https://… or frame-control://install?url=https://…"));
return;
}
pendingLinks.push(req);
if (pendingLinks.length > 5) pendingLinks.shift(); // a page opening links in a loop
deliverLinks();
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
}
function deliverLinks() {
if (!win || !linkPage || linkPage !== win.webContents) return;
while (pendingLinks.length) win.webContents.send("install-link", pendingLinks.shift());
}
ipcMain.on("install-link:ready", (e) => {
if (!fromUi(e)) return;
linkPage = e.sender;
deliverLinks();
});
function registerScheme() {
// A checkout runs as `electron .`, so the OS must be told the script too.
// (macOS takes the scheme from Info.plist, which only the built app has.)
if (process.defaultApp) {
if (process.argv.length >= 2) app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]);
} else {
app.setAsDefaultProtocolClient(SCHEME);
}
}
function createWindow() {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
: { icon: path.join(__dirname, "build", "icon.png") }),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show());
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
// External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => {
if (/^https?:\/\//.test(target)) shell.openExternal(target);
return { action: "deny" };
});
win.webContents.on("will-navigate", (e, target) => {
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
});
// A reload or a new page must ask for links again before it gets any.
win.webContents.on("did-start-loading", () => { linkPage = null; });
win.on("closed", () => { win = null; linkPage = null; });
load();
}
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
// Developer Mode password there.
async function runInTerminal(argv) {
try {
const env = { ...process.env, PATH: await loginPath() };
const py = python || await findPython(env);
if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`);
await run(py, [...PY_FLAGS, path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv],
{ env, timeout: 15000, windowsHide: true });
} catch (err) {
dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim());
}
}
async function setUpConnection() {
const alias = `FRAME_ALIAS=${FRAME}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
// A new console inherits our environment on Windows; Linux terminals may not.
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
}
function buildMenu() {
const template = [
...(IS_MAC ? [{ role: "appMenu" }] : []),
{ role: "fileMenu" },
{ role: "editMenu" },
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: setUpConnection },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
],
},
{
label: "View",
submenu: [
{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" },
{ type: "separator" }, { role: "togglefullscreen" },
],
},
...(IS_MAC ? [{ role: "windowMenu" }] : []),
{
role: "help",
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
// macOS delivers install links here, even before the app is ready.
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
// Windows and Linux start a second instance with the link as an argument.
app.on("second-instance", (_e, argv) => {
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
const link = linkFromArgv(argv);
if (link) openInstallLink(link);
});
const firstLink = IS_MAC ? null : linkFromArgv(process.argv);
if (firstLink) openInstallLink(firstLink);
app.whenReady().then(() => {
registerScheme();
buildMenu();
createWindow();
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
}