mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Flatpak installs record their outcome inside main's background job; failed jobs are diagnostics too. The Privacy panel lives on the Tools page (#privacy opens it), tab analytics use the four page names, and "Test it now?" reads the install job's result. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
492 lines
21 KiB
JavaScript
492 lines
21 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 updater = require("./updater");
|
|
|
|
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() {
|
|
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
|
// sends nothing from a source checkout.
|
|
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
|
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
|
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "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 = `<!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(); });
|
|
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:install", (e) => { if (fromUi(e)) installUpdate(); });
|
|
|
|
// 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();
|
|
});
|
|
|
|
// ---- updates (app/updater.js, docs/releasing.md) ----
|
|
// Checked shortly after launch and every few hours; the page shows a banner and
|
|
// the Update button calls installUpdate.
|
|
const UPDATE_EVERY = 6 * 3600 * 1000;
|
|
const update = { status: "idle", current: app.getVersion(), latest: null, error: null, progress: 0, how: null };
|
|
|
|
function publicUpdate() {
|
|
const r = update.latest;
|
|
return { status: update.status, current: update.current, error: update.error, progress: update.progress,
|
|
latest: r && { version: r.version, notes: r.notes, page: r.page },
|
|
canInstall: !!update.how && update.how.method !== "manual", why: update.how && update.how.why };
|
|
}
|
|
|
|
function setUpdate(fields) {
|
|
Object.assign(update, fields);
|
|
if (win && linkPage === win.webContents) win.webContents.send("update:state", publicUpdate());
|
|
}
|
|
|
|
async function checkForUpdate({ manual = false } = {}) {
|
|
if (["checking", "downloading", "ready"].includes(update.status)) return;
|
|
setUpdate({ status: "checking", error: null });
|
|
try {
|
|
const latest = await updater.latestRelease();
|
|
const how = updater.updateMethod({ platform: process.platform, isPackaged: app.isPackaged,
|
|
execPath: process.execPath, env: process.env,
|
|
exists: fs.existsSync, writable: updater.writable });
|
|
if (updater.isNewer(latest.version, update.current)) {
|
|
setUpdate({ status: "available", latest, how });
|
|
if (manual) offerUpdateDialog();
|
|
} else {
|
|
setUpdate({ status: "none", latest, how });
|
|
if (manual) dialog.showMessageBox(win, { type: "info", message: "Frame Control is up to date",
|
|
detail: `You have ${update.current}, the newest version.` });
|
|
}
|
|
} catch (e) {
|
|
// A failed check: nothing to install, and never an older release kept from before.
|
|
setUpdate({ status: "check-failed", error: e.message, latest: null });
|
|
if (manual) dialog.showMessageBox(win, { type: "warning", message: "Couldn't check for updates", detail: e.message });
|
|
}
|
|
}
|
|
|
|
async function offerUpdateDialog() {
|
|
const r = update.latest;
|
|
const { response } = await dialog.showMessageBox(win, {
|
|
type: "info", message: `Frame Control ${r.version} is available`,
|
|
detail: `You have ${update.current}.` + (update.how.method === "manual" ? ` Download it from the release page (${update.how.why}).` : ""),
|
|
buttons: [update.how.method === "manual" ? "Open Release Page" : "Update and Restart", "Later"], defaultId: 0, cancelId: 1,
|
|
});
|
|
if (response === 0) installUpdate();
|
|
}
|
|
|
|
async function installUpdate() {
|
|
// "error" here only ever means an install failed, so trying again is safe.
|
|
if (update.status !== "available" && update.status !== "error") return;
|
|
if (!update.latest || !updater.isNewer(update.latest.version, update.current)) return;
|
|
if (!update.how || update.how.method === "manual") { shell.openExternal(update.latest.page); return; }
|
|
setUpdate({ status: "downloading", progress: 0, error: null });
|
|
try {
|
|
const start = await updater.prepare(update.latest, update.how,
|
|
(done, total) => { if (total) setUpdate({ progress: done / total }); }, update.current);
|
|
setUpdate({ status: "ready", progress: 1 });
|
|
start();
|
|
quitting = true;
|
|
app.quit();
|
|
} catch (e) {
|
|
setUpdate({ status: "error", error: e.message });
|
|
}
|
|
}
|
|
|
|
function scheduleUpdateChecks() {
|
|
if (process.env.FRAME_CONTROL_NO_UPDATE_CHECK === "1") return;
|
|
setTimeout(checkForUpdate, 8000);
|
|
setInterval(checkForUpdate, UPDATE_EVERY).unref();
|
|
}
|
|
|
|
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 ? [{ label: app.name, submenu: [
|
|
{ role: "about" }, { label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) },
|
|
{ type: "separator" }, { role: "services" }, { type: "separator" },
|
|
{ role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" },
|
|
] }] : []),
|
|
{ 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: [
|
|
...(IS_MAC ? [] : [{ label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) }]),
|
|
{ label: "Report a Problem…", click: () => {
|
|
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
|
|
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
|
|
} },
|
|
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
|
|
{ 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();
|
|
scheduleUpdateChecks();
|
|
});
|
|
app.on("activate", () => { if (!win) createWindow(); });
|
|
app.on("window-all-closed", () => app.quit());
|
|
app.on("before-quit", () => { quitting = true; stopServer(); });
|
|
process.on("exit", stopServer);
|
|
}
|