// 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 run = promisify(execFile); const IS_MAC = process.platform === "darwin"; const IS_WIN = process.platform === "win32"; // Packaged: /{ui,scripts,python}. Dev: the repo checkout. const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, ".."); const TOOLS = path.join(ROOT, "tools"); // bundled adb 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) => ({ "&": "&", "<": "<", ">": ">" }[c])); const html = `

Frame Control couldn't start

${esc(message)}

Fix it, then choose Frame → Restart Server.

`; 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(); } ipcMain.handle("clipboard:read", (e) => { if (!win || e.sender !== win.webContents || !url || new URL(e.senderFrame.url).origin !== new URL(url).origin) return ""; return clipboard.readText(); }); 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(); }); win.on("closed", () => { win = 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 { app.on("second-instance", () => { if (win) { if (win.isMinimized()) win.restore(); win.focus(); } }); app.whenReady().then(() => { 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); }