Frame Control: Mac app, web UI, Android and Steam tooling

Package the Frame Control web UI as an installable Electron Mac app and
bring in the tooling built alongside it.

- app/: Electron wrapper that starts ui/server.py on a free loopback port,
  hardened window (sandbox, no navigation, runAsNode fuse off), login-shell
  PATH so Homebrew tools work from Finder, first-run offer to run
  connect.sh, ad-hoc signed DMG/zip via electron-builder.
- ui/: headset view (OpenVR screenshots), device status, library, Steam
  "Get games" (owned games, install, store search), Android apps as
  persistent Lepton instances with a rated F-Droid catalogue and a private
  compatibility database, Android display controls over ADB, file and
  clipboard transfer, Flatpaks, remote and power actions.
- apk-catalog/, compat-db/, frame/: catalogue build pipeline, Lakebed
  capsule for compatibility reports, Frame-side launchers.
- tests/ and CI: server guard and validation tests plus Steam helper tests,
  run on Python 3.9 with script and app syntax checks.
- Docs: README leads with the Mac app; new Android, panels, Steam games and
  field-notes docs; security notes on LAN-exposed ADB ports.

Screenshot values for the headset's IP and Wi-Fi name are placeholders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-25 22:21:20 +10:00
1 parent 6ccf562756
commit d4486a7681
56 files changed
+19298 -11

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1a9fff"/>
<stop offset="1" stop-color="#6f42c1"/>
</linearGradient>
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dfe8f5"/>
</linearGradient>
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
<mask id="nose">
<rect width="1024" height="1024" fill="#fff"/>
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
</mask>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
</filter>
</defs>
<g clip-path="url(#tile)">
<rect x="100" y="100" width="824" height="824" fill="url(#bg)"/>
</g>
<g filter="url(#shadow)">
<rect x="222" y="350" width="580" height="320" rx="130" fill="url(#visor)" mask="url(#nose)"/>
</g>
<rect x="300" y="430" width="160" height="124" rx="50" fill="#13233a"/>
<rect x="564" y="430" width="160" height="124" rx="50" fill="#13233a"/>
<rect x="320" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
<rect x="584" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+32
View File
@@ -0,0 +1,32 @@
// Renders build/icon.svg to icon.png (1024px) and icon.icns. Run: npm run icon
const { app, BrowserWindow } = require("electron");
const { execFileSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
app.dock?.hide();
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 1024, height: 1024, show: false, transparent: true, frame: false,
useContentSize: true, webPreferences: { offscreen: true } });
const svg = fs.readFileSync(path.join(__dirname, "icon.svg"), "utf8");
await win.loadURL("data:text/html," + encodeURIComponent(
`<body style="margin:0;background:transparent">${svg}</body>`));
await new Promise((r) => setTimeout(r, 300));
const png = (await win.webContents.capturePage({ x: 0, y: 0, width: 1024, height: 1024 }))
.resize({ width: 1024, height: 1024 }).toPNG();
fs.writeFileSync(path.join(__dirname, "icon.png"), png);
const set = fs.mkdtempSync(path.join(os.tmpdir(), "icon-")) + "/icon.iconset";
fs.mkdirSync(set);
for (const size of [16, 32, 128, 256, 512]) {
for (const scale of [1, 2]) {
const px = size * scale, name = `icon_${size}x${size}${scale === 2 ? "@2x" : ""}.png`;
execFileSync("sips", ["-z", String(px), String(px), path.join(__dirname, "icon.png"),
"--out", path.join(set, name)], { stdio: "ignore" });
}
}
execFileSync("iconutil", ["-c", "icns", set, "-o", path.join(__dirname, "icon.icns")]);
console.log("wrote build/icon.png and build/icon.icns");
app.quit();
});
+270
View File
@@ -0,0 +1,270 @@
// Frame Control as a Mac app: 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, dialog, shell } = require("electron");
const { execFile, execFileSync, spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
// Packaged: Contents/Resources/{ui,scripts}. Dev: the repo checkout.
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
const SERVER = path.join(ROOT, "ui", "server.py");
const SCRIPTS = path.join(ROOT, "scripts");
const LOG_DIR = path.join(os.homedir(), "Library", "Logs", "Frame Control");
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;
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
// Homebrew's python3, rsync and adb. Take PATH from the login shell instead.
let cachedPath = null;
function loginPath() {
if (cachedPath) return cachedPath;
const shellPath = process.env.SHELL || "/bin/zsh";
const extra = ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")];
let fromShell = "";
try {
const out = execFileSync(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
fromShell = (out.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
} catch {}
const parts = [...fromShell.split(":"), ...(process.env.PATH || "").split(":"), ...extra];
cachedPath = [...new Set(parts.filter(Boolean))].join(":");
return cachedPath;
}
function findPython(env) {
for (const dir of env.PATH.split(":")) {
const p = path.join(dir, "python3");
try {
fs.accessSync(p, fs.constants.X_OK);
// /usr/bin/python3 is a stub until the Command Line Tools are installed.
execFileSync(p, ["-c", "import http.server"], { timeout: 10000, stdio: "ignore", env });
return p;
} catch {}
}
return null;
}
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: loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1" };
const python = findPython(env);
if (!python) {
throw new Error("Frame Control needs python3. Install the Xcode Command Line Tools "
+ "(xcode-select --install) or Homebrew's python, then reopen the app.");
}
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`);
const child = spawn(python, [SERVER, "--port", String(port)], { env, stdio: ["ignore", log, log] });
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));
}
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
}
function stopServer() {
// server.py handles SIGTERM by closing its shared SSH connection.
if (server) server.kill("SIGTERM");
}
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) old.kill("SIGTERM");
await load();
}
// The page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = `
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);
} 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 and the Mac user as User; connect.sh sets both.
function aliasConfigured(env) {
try {
const out = execFileSync("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env,
stdio: ["ignore", "pipe", "ignore"] });
const get = (k) => (out.match(new RegExp(`^${k} (.*)$`, "m")) || [])[1];
return get("hostname") !== FRAME || get("user") !== os.userInfo().username;
} catch {
return true; // can't tell; don't nag
}
}
async function firstRunCheck() {
if (aliasConfigured({ ...process.env, PATH: loginPath() })) return;
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 Developer Mode and set `
+ "a user password (Steam Settings → System). Then run the setup script: it finds the "
+ "headset, creates a key, and asks for that password once in Terminal.",
buttons: ["Set Up Connection…", "Later"],
defaultId: 0, cancelId: 1,
});
if (response === 0) setUpConnection();
}
function createWindow() {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 },
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
win.once("ready-to-show", () => { win.show(); firstRunCheck(); });
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();
}
// Runs in Terminal because ssh-copy-id asks for the Developer Mode password.
function runInTerminal(command) {
const quoted = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
execFile("osascript", ["-e", 'tell application "Terminal"', "-e", `do script "${quoted}"`,
"-e", "activate", "-e", "end tell"], (err) => {
if (err) dialog.showErrorBox("Couldn't open Terminal", String(err.message || err));
});
}
const sh = (s) => `'${s.replace(/'/g, "'\\''")}'`;
function setUpConnection() {
runInTerminal(`FRAME_ALIAS=${sh(FRAME)} ${sh(path.join(SCRIPTS, "connect.sh"))}`);
}
function buildMenu() {
const template = [
{ role: "appMenu" },
{ role: "fileMenu" },
{ role: "editMenu" },
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: setUpConnection },
{ label: "Open SSH in Terminal", click: () => runInTerminal(`ssh ${sh(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) },
{ 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" },
],
},
{ 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);
}
+3591
View File
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.1.0",
"description": "Mac app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
"license": "UNLICENSED",
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "electron-builder --mac --arm64 --publish never",
"dist:dir": "electron-builder --mac --arm64 --dir"
},
"devDependencies": {
"electron": "^44.4.5",
"electron-builder": "^26.15.3"
},
"build": {
"appId": "com.saphid.frame-control",
"productName": "Frame Control",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"main.js",
"package.json"
],
"extraResources": [
{
"from": "../ui",
"to": "ui",
"filter": [
"*.py",
"*.html"
]
},
{
"from": "../scripts",
"to": "scripts",
"filter": [
"*.sh"
]
},
{
"from": "../frame/android",
"to": "frame/android",
"filter": [
"*.sh",
"*.py"
]
},
{
"from": "../apk-catalog",
"to": "apk-catalog",
"filter": [
"*.py",
"pins.json",
"site/apps.js"
]
}
],
"mac": {
"category": "public.app-category.utilities",
"icon": "build/icon.icns",
"identity": "-",
"hardenedRuntime": false,
"target": [
"dmg",
"zip"
],
"extendInfo": {
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
}
},
"dmg": {
"title": "Frame Control ${version}"
},
"electronFuses": {
"runAsNode": false,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false
}
}
}