mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
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:
1 parent
6ccf562756
commit
d4486a7681
56 files changed
+19298
-11
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -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 |
@@ -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
@@ -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) => ({ "&": "&", "<": "<", ">": ">" }[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);
|
||||
}
|
||||
Generated
+3591
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user