/** * MeshBay desktop client — the Electron main process. * * The reason this exists, stated once so nobody has to rediscover it: **the * interface ships inside this package and loads from disk.** A shell that * points a WebView at the hub's /app/ is a browser with a different icon and * fixes nothing — the hub could still send whatever code it liked, which is * finding T3. The hub is used for its API and for nothing else. * * What that does and does not buy is worth being honest about: it does not make * the hub untrusted. A build downloaded from meshbay.org and signed with a key * its operator holds relocates the trust rather than removing it. What changes * is **detectability** — an attack has to ship as an artifact that can be * hashed and compared, instead of being one HTTP response aimed at one person. * That value is realised by reproducible builds and published hashes (18.7), * not by the packaging format. * * See docs/MESHBAY_DESIGN.md §8.1, §8.2. */ 'use strict'; const { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage, shell, Tray } = require('electron'); const { execFile, spawn } = require('node:child_process'); const crypto = require('node:crypto'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); // Linux window managers/desktop shells (GNOME's dash included) group and // icon-match a running window by its WM_CLASS, resolved against an installed // .desktop file's StartupWMClass — not against BrowserWindow's `icon` option, // which only sets the window's own icon (titlebar/alt-tab). Unpackaged, the // class defaults to "electron" and nothing matches it, so the shell falls // back to the generic Electron icon. A packaged build gets a correct // StartupWMClass from electron-builder automatically; this switch keeps dev // runs (`electron .`) consistent with it. app.commandLine.appendSwitch('class', 'MeshBay'); // Chromium reads `--enable-features` and `--disable-features` as one // comma-joined list each, and `appendSwitch` **replaces** that list rather // than adding to it. Two call sites therefore cancel the first silently: no // error, nothing in a log, just a feature that is quietly not on. The // hardware-decode section further down is the second caller, so both go // through this — and so must the third. function addFeatures(kind, names) { const current = app.commandLine.getSwitchValue(kind); const merged = new Set(current ? current.split(',').filter(Boolean) : []); for (const name of names) merged.add(name); app.commandLine.appendSwitch(kind, [...merged].join(',')); } // Chromium publishes host candidates as random `.local` mDNS names rather // than as the real local IP. Every peer this client talks to is a node running // aiortc/aioice, and aioice has no mDNS resolver on any platform: it logs // `Remote candidate ".local" could not be resolved` and drops the // candidate outright. Concealment therefore does not degrade here, it removes // the only LAN-routable candidate and leaves reflexive pairs — which fail // whenever both peers sit behind the same NAT, since that pair needs the // router to hairpin. Measured: a Linux client and a node in a libvirt guest // formed exactly one pair, host -> srflx on a shared public address, and it // answered none of five binding requests. // // This was previously scoped to win32, from a session where the guest ran the // *client*; the platform that matters is the peer's, not ours, and the peer is // always a node. The trade is that our private address reaches the hub and the // node in the SDP — both the user's own infrastructure, not an arbitrary page. addFeatures('disable-features', ['WebRtcHideLocalIpsWithMdns']); const UI_DIR = path.join(__dirname, '..', 'ui'); const SCHEME = 'app'; // ── Platform directories ──────────────────────────────────────────────────── function meshbayConfigDir() { if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || os.homedir(), 'meshbay'); return path.join(os.homedir(), '.config', 'meshbay'); } function meshbayDataDir() { if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || os.homedir(), 'meshbay', 'data'); return path.join(os.homedir(), '.local', 'share', 'meshbay'); } // The policy, sent as a header on every response. // // Not a tag: `frame-ancestors` is ignored there — Chromium says so in // the console — and a policy with a directive that silently does nothing is // worse than one without it. A header is authoritative for every directive, // and this handler is the only thing that serves the interface, so there is one // source rather than two that can drift. // // `'wasm-unsafe-eval'` is required and must stay: the bundle key is Argon2id in // WebAssembly, and a policy without it does not degrade anything — it locks // every user out of their keys. // // The hub is reachable under connect-src, for its API and its signaling socket. // It is deliberately absent from script-src: nothing it returns is executed, // which is the whole reason this application exists (T3). // // The one exception is reCAPTCHA, used to gate sign-up (and password reset) the // same way it gates them in the browser. Its script comes from www.google.com, // its challenge is a www.google.com iframe, and its assets sit on // www.gstatic.com. These two hosts — and only these two — are allowed under // `script-src`, `frame-src` and `img-src` for that purpose. It is a real, if // small, dent in "no third-party code runs here": Google's reCAPTCHA script // executes in the renderer. It is accepted deliberately so a native sign-up is // gated like a web one without asking the user to do anything extra, and it is // the *same* dependency the hub-served SPA already carries. If sign-up ever // moves to a proof-of-work challenge, delete RECAPTCHA_SRC and the three // directives that spread it, and the widget in auth-page.js with them. const RECAPTCHA_SRC = 'https://www.google.com https://www.gstatic.com'; const CSP = [ "default-src 'none'", `script-src 'self' 'wasm-unsafe-eval' ${RECAPTCHA_SRC}`, "style-src 'self' 'unsafe-inline'", `img-src 'self' data: blob: ${RECAPTCHA_SRC}`, "media-src 'self' blob:", "font-src 'self'", "connect-src 'self' https: wss:", "worker-src 'self'", // `blob:` here and in `frame-src` are one thing, not two: the PDF preview. // // `files-app.js` decrypts a PDF in the page, wraps it in a Blob and hands it // to ``, so the bytes never leave the // renderer. Chromium serves that with its own viewer, and the viewer takes // TWO permissions — it loads the resource as plugin data (`object-src`, // which was falling back to `default-src 'none'`) and then renders it in an // internal frame (`frame-src`). Fixing either alone still shows the "this // browser will not display the PDF inline" fallback, which is how this // looked like a missing feature rather than a policy. // // `'self'` does not cover it: a same-origin `blob:` URL is NOT matched by // `'self'` in either directive — measured on Chromium 152, this build's and // Chrome's alike — so the token has to be `blob:` in both. Only page script // can mint a blob URL and the type is set by our code, so what this admits // is PDFium parsing bytes that came from a node — exactly what a browser // does with the same file. "object-src blob:", `frame-src blob: ${RECAPTCHA_SRC}`, "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'", ].join('; '); // ── The scheme the interface is served from ───────────────────────────────── // // Not file://. Service workers, ES modules and IndexedDB all misbehave there, // and the streamed-download path needs a *controlled* page — a worker that is // merely active is not enough, which this codebase has already learned once. // // `secure` is what makes it a secure context, and without it the whole of // `crypto.subtle` is undefined — measured on Electron 42 / Chromium 148, where // a page on a non-secure scheme failed every algorithm including AES-GCM. // `standard` gives it a real origin, so IndexedDB survives an update instead of // being keyed to something that moves. // // What it does NOT buy: service workers. Chromium refuses to register one on a // custom scheme whatever its privileges ("The URL protocol of the current // origin is not supported"). So `sw.js` never runs here, and the streamed // download it exists for is replaced by the native save dialog below — which is // the better path anyway. It stays in the package because the same files serve // the browser, where it is one of only three ways to write a large file. protocol.registerSchemesAsPrivileged([{ scheme: SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, // Range requests, for playing a film corsEnabled: true, }, }]); /** * Serve the packaged interface, and refuse to leave it. * * Every path is resolved and checked against the UI directory before anything * is read: the renderer is the least trusted part of this process, and a * traversal here would hand it the user's filesystem. */ function registerUiProtocol() { protocol.handle(SCHEME, async (request) => { const url = new URL(request.url); const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html'; const target = path.resolve(UI_DIR, rel); const root = path.resolve(UI_DIR); if (target !== root && !target.startsWith(root + path.sep)) { return new Response('Not found', { status: 404 }); } try { const body = await fsp.readFile(target); return new Response(body, { headers: { 'Content-Type': contentType(target), 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', // Without this, a response carrying no cache header at all is a // response Chromium is free to reuse by heuristic freshness — the // same trap CLAUDE.md already records for the hub-served SPA // (`Cache-Control: no-cache` only binds a browser that asks). // Here every file is read fresh from disk on each request // already (sync-ui during development, a fresh install // otherwise), so nothing is ever served from the renderer's own // HTTP cache instead — a plain reload is enough after `npm run // sync-ui`, not just a full app restart. 'Cache-Control': 'no-store', }, }); } catch { return new Response('Not found', { status: 404 }); } }); } function contentType(file) { const ext = path.extname(file).toLowerCase(); return { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', }[ext] || 'application/octet-stream'; } // ── Configuration ─────────────────────────────────────────────────────────── function configPath() { return path.join(app.getPath('userData'), 'config.json'); } function readConfig() { try { return JSON.parse(fs.readFileSync(configPath(), 'utf8')); } catch { // No default hub. A client that picks its own is a client that can be // pointed at one, so the first run asks and the answer is remembered. return { hubBase: '', window: null }; } } function writeConfig(next) { const dir = path.dirname(configPath()); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 }); } let config = readConfig(); // ── Hardware video decoding (Linux) ───────────────────────────────────────── // // Chromium ships VA-API off on Linux; macOS and Windows decode H.264 in // hardware without being asked, so everything here is Linux-only. // // It pays off exactly where it is needed. A 1080p H.264 film is the streaming // path's normal case — the node hands the bytes over untouched (`-c:v copy`, // webrtc_server.py) and all the decoding happens here — and every Intel iGPU // of the last decade decodes that in fixed-function silicon, while an // Atom/Celeron-class CPU cannot hold 25 fps at 1080p in software. // // Three decisions, written down because each replaces something a person // would otherwise have to do by hand: // // - **Detected, not configured.** One package installs on every machine, so // the switches cannot be chosen at build time and there is no second // "low-power" package. The probe is two filesystem checks and runs before // `whenReady`, because Chromium reads its command line exactly once. // // - **The feature name is not stable, so it is not trusted.** // `VaapiVideoDecodeLinuxGL` has been renamed across Chromium releases, and // `build-client.sh` deliberately rebuilds against the *latest* Electron // every time — a name pinned here would stop matching one bump later with // nothing failing to say so. Both known names are passed (Chromium ignores // an unknown one) and the outcome is measured rather than assumed. // // - **The verdict is measured, remembered, and re-opened by a Chromium // bump.** `measureVideoDecoding` asks the renderer whether the codec the // node really streams decodes `powerEfficient`ly — Chromium's own answer to // "is this hardware", which is what reading it out of devtools would have // told you. A no moves to the next set of switches on the next launch; when // the list runs out they are dropped altogether, so `--ignore-gpu-blocklist` // cannot leave a machine with a broken GL stack paying for a feature it // never got. // // `videoAcceleration` in config.json overrides the lot: "off" never applies // anything, "on" applies it even where the probe finds no driver. const VAAPI_FEATURES = ['VaapiVideoDecodeLinuxGL', 'AcceleratedVideoDecodeLinuxGL']; // One per launch, in order, until the measurement says hardware. These are not // a guess about *this* Chromium — they are the ways its GL backend has had to // be named, and the machine picks which one is true for it. const VAAPI_ATTEMPTS = [ [], [['use-gl', 'egl']], [['use-gl', 'angle'], ['use-angle', 'gl']], ]; function vaapiDriverInstalled() { const dirs = [process.env.LIBVA_DRIVERS_PATH, '/usr/lib/x86_64-linux-gnu/dri', '/usr/lib/aarch64-linux-gnu/dri', '/usr/lib64/dri', '/usr/lib/dri']; for (const dir of dirs) { if (!dir) continue; try { if (fs.readdirSync(dir).some((f) => f.endsWith('_drv_video.so'))) return true; } catch { /* no such directory on this distribution */ } } return false; } // Readable *and* writable: VA-API maps buffers on the render node. On a // desktop session logind grants that through an ACL rather than through group // membership, so asking the kernel answers "can this process use it", which is // the question — rather than "is this user in `render`", which is not. function renderNode() { try { for (const name of fs.readdirSync('/dev/dri')) { if (!name.startsWith('renderD')) continue; const dev = `/dev/dri/${name}`; try { fs.accessSync(dev, fs.constants.R_OK | fs.constants.W_OK); return dev; } catch { /* another GPU may still be usable */ } } } catch { /* no /dev/dri at all */ } return null; } // Which attempt this launch is running, or null when no switches were applied // — which is also what stops a second window measuring the same launch twice. let vaapiAttempt = null; function configureVideoDecoding() { if (process.platform !== 'linux') return; const mode = config.videoAcceleration || 'auto'; if (mode === 'off') return; const forced = mode === 'on'; if (!forced && !(renderNode() && vaapiDriverInstalled())) { console.log('[video] no usable VA-API driver — decoding in software'); return; } const probe = config.videoDecodeProbe || {}; const known = probe.chrome === process.versions.chrome; let attempt = (known && typeof probe.attempt === 'number') ? probe.attempt : 0; if (attempt >= VAAPI_ATTEMPTS.length) { if (!forced) { console.log('[video] VA-API never took on this machine — decoding in software'); return; } // "on" is the person overruling the measurement, so the list running out // is not an answer here — it keeps the last set rather than climbing an // index nothing will ever read again. attempt = VAAPI_ATTEMPTS.length - 1; } addFeatures('enable-features', VAAPI_FEATURES); app.commandLine.appendSwitch('ignore-gpu-blocklist'); for (const [name, value] of VAAPI_ATTEMPTS[attempt]) app.commandLine.appendSwitch(name, value); vaapiAttempt = attempt; } // `avc1.640029` is H.264 High 4.1 — what the node announces for a re-encode // (webrtc_server.py) and the profile a copied 1080p film carries. Asking about // the codec that is actually streamed is the point: a machine can decode // H.264 in hardware and still answer no for HEVC, and the reverse. const DECODE_PROBE = `navigator.mediaCapabilities.decodingInfo({ type: 'media-source', video: { contentType: 'video/mp4; codecs="avc1.640029"', width: 1920, height: 1080, bitrate: 5000000, framerate: 25, }, }).then((r) => !!r.powerEfficient).catch(() => false)`; async function measureVideoDecoding(win) { const attempt = vaapiAttempt; vaapiAttempt = null; if (attempt === null) return; let hardware; try { hardware = await win.webContents.executeJavaScript(DECODE_PROBE); } catch { return; // the window went away; the question is asked again next launch } const gpu = app.getGPUFeatureStatus().video_decode; console.log(`[video] hardware decoding: ${hardware} ` + `(attempt ${attempt}, gpu video_decode: ${gpu})`); config = { ...config, videoDecodeProbe: { chrome: process.versions.chrome, attempt: hardware ? attempt : attempt + 1, hardware, gpu, }, }; writeConfig(config); if (!hardware && attempt + 1 < VAAPI_ATTEMPTS.length) console.log('[video] another GL backend will be tried on the next launch'); } configureVideoDecoding(); // ── Secrets ───────────────────────────────────────────────────────────────── // // The OS keychain, through safeStorage. What it protects and what it does not // is reported rather than assumed: on Linux, safeStorage falls back to a fixed // key when no keyring is running — a headless session, a minimal desktop — and // it does so silently. Someone who believes the OS is holding their keys should // be told when it is not. function secretsFile() { return path.join(app.getPath('userData'), 'secrets.bin'); } function readSecrets() { try { const raw = fs.readFileSync(secretsFile()); if (!safeStorage.isEncryptionAvailable()) return {}; return JSON.parse(safeStorage.decryptString(raw)); } catch { return {}; } } function writeSecrets(all) { if (!safeStorage.isEncryptionAvailable()) { throw new Error('No OS key storage is available on this system'); } fs.mkdirSync(path.dirname(secretsFile()), { recursive: true }); fs.writeFileSync(secretsFile(), safeStorage.encryptString(JSON.stringify(all)), { mode: 0o600 }); } function secretsBackend() { if (!safeStorage.isEncryptionAvailable()) return 'unavailable'; if (process.platform !== 'linux') return process.platform === 'darwin' ? 'keychain' : 'dpapi'; const backend = safeStorage.getSelectedStorageBackend ? safeStorage.getSelectedStorageBackend() : 'unknown'; // "basic_text" is Electron's fixed-key fallback: encrypted on disk, but by a // key that is not a secret. Named plainly so the interface can say so. return backend === 'basic_text' ? 'unprotected_fallback' : backend; } // ── The device's hub key ──────────────────────────────────────────────────── // // Ed25519, generated here on first sign-in, registered with the hub, and used // from then on instead of deriving a key from the passphrase every time. The // passphrase remains the account's credential and its only recovery path. // // **The renderer never holds it.** It parses decrypted content from nodes — // video, images, filenames — which is attacker-controlled input, so it asks for // a signature rather than being handed a key it could leak. This is the same // rule as the save dialog: the renderer asks, this process acts. // // Note what this key is *not*: it is not a per-node identity key. Those are // generated per node, pinned there, and never leave that relationship // (docs/MESHBAY_DESIGN.md §3.2). Nothing here correlates a person across // operators, and nothing wraps a group key for it. const DEVICE_KEY = 'device_auth_ed25519'; function deviceKey() { const stored = readSecrets()[DEVICE_KEY]; if (!stored) return null; return crypto.createPrivateKey({ key: Buffer.from(stored, 'base64'), format: 'der', type: 'pkcs8', }); } function ensureDeviceKey() { const existing = deviceKey(); if (existing) return publicKeyB64(existing); const { privateKey } = crypto.generateKeyPairSync('ed25519'); const all = readSecrets(); all[DEVICE_KEY] = privateKey.export({ format: 'der', type: 'pkcs8' }) .toString('base64'); writeSecrets(all); return publicKeyB64(crypto.createPrivateKey({ key: Buffer.from(all[DEVICE_KEY], 'base64'), format: 'der', type: 'pkcs8' })); } function publicKeyB64(privateKey) { // Raw 32 bytes, as the hub stores and as `pk_to_b64` produces: the DER // SubjectPublicKeyInfo for Ed25519 is a fixed 12-byte prefix and the key. const der = crypto.createPublicKey(privateKey) .export({ format: 'der', type: 'spki' }); return der.subarray(der.length - 32).toString('base64'); } // Everything the interface is allowed to ask Chromium for. Watching a film // full-screen is the whole list. const GRANTED_PERMISSIONS = new Set(['fullscreen']); // No hub call is still going to be answered after this. The hub's longest is // signaling a WebRTC offer, which gives up at fifteen seconds of its own. const HUB_FETCH_TIMEOUT_MS = 30000; // ── Window ────────────────────────────────────────────────────────────────── let mainWindow = null; // ── System tray ────────────────────────────────────────────────────────────── // Linux and Windows. // // Created at launch, not on the first "minimise to tray". An indicator that // only appears once you have already hidden the window is one you cannot use // to find the application, which is most of what a tray is for -- and on // Windows it made the app look like it had no tray presence at all until you // went looking for one. // // The context menu is not optional. Under libappindicator -- which is how GNOME // shows a tray at all, via the AppIndicator extension -- `tray.on('click')` // never fires: the indicator only opens its menu. A tray whose only affordance // was a click would be inert there -- Windows does send it, so the same handler // restores the window on a plain left click. // // Labels arrive from the renderer rather than being translated here. The locale // files are the interface's, the main process has no i18n, and a second string // table is how two of them start disagreeing. let tray = null; let trayLabels = null; let trayTimer = null; // The same test preload.js publishes as `capabilities.tray`. macOS is excluded // deliberately: it has a menu bar rather than a tray, and the window controls // there already do what hiding to an indicator does elsewhere. const trayOS = () => process.platform === 'linux' || process.platform === 'win32'; let nodeService = null; // assigned by registerBridge() const TRAY_FALLBACK = { show: 'Show MeshBay', quit: 'Quit', start_node: 'Start the node', stop_node: 'Stop the node', }; // libappindicator offers no "menu is about to open" event, so a menu built once // would show a stale Start/Stop for as long as the app runs. `systemctl --user // show` is a few milliseconds and this only ticks while an indicator exists. const TRAY_POLL_MS = 5000; function showFromTray() { if (!mainWindow) return; if (!mainWindow.isVisible()) mainWindow.show(); if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } async function buildTrayMenu() { const text = { ...TRAY_FALLBACK, ...(trayLabels || {}) }; const items = [{ label: text.show, click: showFromTray }]; // Only when there is a daemon to act on: not installed means no entry at all, // rather than a control that reports failure when used. let status = null; try { status = nodeService ? await nodeService.status() : null; } catch { status = null; // unreadable state is the same as no control } if (status && status.supported && status.installed) { const running = status.activeState === 'active'; items.push({ type: 'separator' }); items.push({ label: running ? text.stop_node : text.start_node, click: async () => { try { // `restart` starts a stopped unit, which is what systemd's restart // means; there is no separate start verb to call. if (running) await nodeService.stop(); else await nodeService.restart(); } catch (err) { console.error('[MeshBay] tray: node action failed', err); } refreshTrayMenu(); // reflect the new state without waiting a tick }, }); } items.push({ type: 'separator' }, { label: text.quit, click: () => app.quit() }); return Menu.buildFromTemplate(items); } async function refreshTrayMenu() { if (!tray) return; tray.setContextMenu(await buildTrayMenu()); } function ensureTray(labels) { if (labels) trayLabels = labels; // the person may have changed language if (tray) { refreshTrayMenu(); return tray; } // In src/, not build/: electron-builder packages only `src/**` and `ui/**` // (package.json `files`), so an icon under build/ exists in a dev run and is // missing from every installed one. tray = new Tray(path.join(__dirname, 'tray-icon.png')); tray.setToolTip('MeshBay'); refreshTrayMenu(); // No-op on GNOME (never fires there), the left-click restore on Windows. tray.on('click', showFromTray); if (!trayTimer) trayTimer = setInterval(refreshTrayMenu, TRAY_POLL_MS); return tray; } function createWindow() { const bounds = (config.window && config.window.width) ? config.window : { width: 1200, height: 800, }; const win = new BrowserWindow({ ...bounds, minWidth: 320, show: false, icon: path.join(__dirname, '..', 'build', 'icon.png'), webPreferences: { // The three that matter. `sandbox` keeps the Chromium renderer sandbox — // the strongest one available, and the reason Electron is not the // trade-off the earlier design recorded against "native". Without // `contextIsolation` the preload's objects are reachable and mutable from // page script, which would make the bridge below decorative. sandbox: true, contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, 'preload.js'), // The hub address, handed over as a process argument rather than fetched. // `platform.hubBase()` runs while the module graph is loading — before // anything can await — so it has to be synchronous, and synchronous IPC // would block the renderer on every call for a value that never changes // within a run. Changing it restarts the window. additionalArguments: [`--meshbay-hub=${config.hubBase || ''}`], // The page is loaded over app:// and talks to the hub over https. Neither // needs to reach the local filesystem. webSecurity: true, }, }); win.once('ready-to-show', () => win.show()); // Once per launch, and only when the section above applied switches. win.webContents.once('did-finish-load', () => { measureVideoDecoding(win); }); // Some Wayland compositors (observed under GNOME/Mutter on a VM with a // virtio-gpu device whose command-buffer creation fails) never schedule a // first paint for a surface that isn't mapped yet — but Electron won't map // it (show()) until `ready-to-show` fires, which waits for that paint. The // two conditions deadlock the window invisible forever. This bounded // fallback breaks the cycle. Guarded on isVisible(): show() also raises // and refocuses an already-visible window, so once the event has fired // normally (real GPU/X11 hosts, well under 2s) this must stay a no-op // rather than yank focus back from whatever the person switched to. setTimeout(() => { if (!win.isDestroyed() && !win.isVisible()) win.show(); }, 2000); // The hub must never become the document origin. Anything that would navigate // away from the packaged interface is refused, and an external link opens in // the user's own browser rather than inside a window holding their keys. const isOurs = (target) => { try { return new URL(target).protocol === `${SCHEME}:`; } catch { return false; } }; win.webContents.on('will-navigate', (event, target) => { if (!isOurs(target)) event.preventDefault(); }); win.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url); return { action: 'deny' }; }); // Deny by default, with one exception, and the exception is the point of the // comment. A blanket `callback(false)` here is what stopped a film going // fullscreen: Chromium's own video controls ask for `fullscreen`, and a // **denial does not reject** — `requestFullscreen()` returns a promise that // never settles, so the button simply does nothing and there is no error // anywhere to find. Measured, not deduced: the probe reported `NEVER SETTLED` // and the main process logged `PERMISSION ASKED: fullscreen`. // // So: enumerate what is granted rather than what is refused. A camera, a // microphone, a location, notifications and MIDI are all still refused, and // anything Chromium adds later arrives refused rather than quietly allowed. win.webContents.session.setPermissionRequestHandler( (_wc, permission, callback) => callback(GRANTED_PERMISSIONS.has(permission))); // `Permissions.query` takes the other handler; same answer, or the two can // disagree about what the page is allowed to do. win.webContents.session.setPermissionCheckHandler( (_wc, permission) => GRANTED_PERMISSIONS.has(permission)); win.on('close', () => { if (!win.isMinimized() && !win.isFullScreen()) { config = { ...config, window: win.getBounds() }; writeConfig(config); } }); // `win`, not `mainWindow`. Changing the hub closes one window and opens // another, and `closed` arrives *after* the replacement has been assigned — // so a handler reading the module variable nulls out the new window, and the // new window's `ready-to-show` then crashes on it. Every handler here belongs // to the window it was created with, and only the current one may clear the // reference. win.on('closed', () => { if (mainWindow === win) mainWindow = null; }); mainWindow = win; win.loadURL(`${SCHEME}://meshbay/index.html`); return win; } // ── Bridge ────────────────────────────────────────────────────────────────── // // Everything the interface may ask of this process, enumerated. A handler that // takes a path from the renderer and acts on it is the shape to avoid: the // renderer parses decrypted content from nodes, which is attacker-controlled // input, so it is treated as hostile even though it is our own code. const CastRelay = require('./cast-relay.js'); const castRelay = new CastRelay(); const CastChromecast = require('./cast-chromecast.js'); const castChromecast = new CastChromecast(); function registerBridge() { ipcMain.handle('hub:set', async (_e, base) => { const url = String(base || '').trim().replace(/\/+$/, ''); if (url && !/^https:\/\//.test(url) && !/^http:\/\/(localhost|127\.)/.test(url)) { // http is allowed only to a loopback address, for someone running a hub // on their own machine. Anywhere else it would put the session token on // the wire in clear. throw new Error('The hub address must be https'); } // Ask the hub whether it is one, before writing the address down. // // Without this the first-run screen accepts anything shaped like a URL and // the application is then broken with no way back — there was no way to // change the hub once it was set, so a typo meant editing a JSON file by // hand. `https` typed at an `http` hub is the obvious case and it fails // with a TLS error that says nothing to anyone. let version; try { const probe = await fetch(`${url}/v1/hub/version`, { signal: AbortSignal.timeout(10000) }); if (!probe.ok) throw new Error(`answered ${probe.status}`); version = await probe.json(); if (!version || !version.hub) throw new Error('did not answer as a hub'); } catch (e) { throw new Error(describeUnreachable(url, e)); } config = { ...config, hubBase: url }; writeConfig(config); // The renderer reads the address from a process argument, so the window has // to be rebuilt for a change to take. Reloading in place would leave the // interface talking to the old hub with no sign of it. // Build the replacement first, then close the old one: the new window is // what `mainWindow` points at, so the outgoing window's `closed` handler // finds a reference that is no longer its own and leaves it alone. const outgoing = mainWindow; createWindow(); if (outgoing && !outgoing.isDestroyed()) outgoing.close(); return url; }); // Every call to the hub leaves from here, not from the renderer. // // Not a preference. The page's origin is `app://meshbay`, and a browser fetch // from it is refused by CORS — the hub has no CORS middleware at all, which is // a posture worth keeping: its API is reachable from no web origin whatever. // Widening it for `app://meshbay` would be worse than it looks, because that // origin is not a credential — any Electron application on any machine can // claim the same scheme and host. // // So the renderer asks and this process goes, exactly as it does for saving a // file. Node's fetch has no origin and no CORS, the hub stays closed to the // web, and there is one place where network egress happens. ipcMain.handle('hub:fetch', async (_e, url, init) => { const target = new URL(String(url)); const base = config.hubBase ? new URL(config.hubBase) : null; // The renderer may only reach the hub it is signed in to. A path it // controls must not become a request to somewhere else. if (!base || target.origin !== base.origin) { throw new Error('Refused: not this hub'); } let response; try { response = await fetch(target, { method: (init && init.method) || 'GET', headers: (init && init.headers) || {}, body: (init && init.body) || undefined, // Node's fetch waits as long as the OS lets it, which for a host that // accepts a connection and then says nothing is minutes. The hub's own // longest call is signaling, which gives up at fifteen seconds, so // anything past this is not an answer that is still coming. signal: AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS), }); } catch (e) { if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) { throw new Error( `${target.origin} accepted the connection but did not answer within ` + `${Math.round(HUB_FETCH_TIMEOUT_MS / 1000)}s.`); } // Node's fetch says "fetch failed" for everything from a refused // connection to a TLS mismatch, which tells a person nothing at all. throw new Error(describeUnreachable(target.origin, e)); } return { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers), body: await response.text(), }; }); ipcMain.handle('ice:resolve-stun', async (_e, urls) => { const dns = require('node:dns').promises; const list = Array.isArray(urls) ? urls : []; const out = []; for (const u of list) { const m = /^(stuns?):(\[?[^\]]+\]?|[^:]+):(\d+)$/.exec(String(u)); if (!m) { out.push(String(u)); continue; } const [, scheme, host, port] = m; if (/^[\d.]+$/.test(host) || host.includes(':')) { out.push(String(u)); continue; } try { const [ip] = await dns.resolve4(host); if (ip) out.push(`${scheme}:${ip}:${port}`); } catch { /* unresolvable (e.g. a decommissioned host) — drop it */ } } return out; }); ipcMain.handle('hub:probe', async (_e, url) => { const target = String(url || config.hubBase || '').replace(/\/+$/, ''); if (!target) return null; try { const r = await fetch(`${target}/v1/hub/version`, { signal: AbortSignal.timeout(10000) }); return r.ok ? await r.json() : null; } catch { return null; } }); // Hide, never close: `window-all-closed` quits the app, and closing here would // make "minimise to tray" mean "exit". A hidden window keeps the session, the // transfers and the node connection exactly as they were. ipcMain.handle('window:minimize-to-tray', (_e, labels) => { if (!trayOS() || !mainWindow) return false; ensureTray(labels); mainWindow.hide(); return true; }); // The renderer sends these once it has a locale, and again after a language // change (which reloads the page, so the same call covers both). Until then // the tray created at launch shows TRAY_FALLBACK, in English, for the // fraction of a second the catalogue takes to arrive -- the alternative, // waiting for the renderer before creating it at all, is the behaviour this // replaces. ipcMain.handle('tray:labels', (_e, labels) => { if (!trayOS()) return false; ensureTray(labels); return true; }); ipcMain.handle('device:ensure', () => ensureDeviceKey()); ipcMain.handle('device:public', () => { const key = deviceKey(); return key ? publicKeyB64(key) : null; }); ipcMain.handle('device:sign', (_e, username) => { const key = deviceKey(); if (!key) return null; const timestamp = Math.floor(Date.now() / 1000); // The same bytes `POST /v1/users/auth` verifies. The username is inside the // signature, so one collected for a different account is not usable. const message = Buffer.from( `meshbay:user_auth:${String(username)}:${timestamp}`); return { timestamp, signature: crypto.sign(null, message, key).toString('base64'), }; }); ipcMain.handle('device:forget', () => { const all = readSecrets(); delete all[DEVICE_KEY]; writeSecrets(all); return true; }); ipcMain.handle('secrets:backend', () => secretsBackend()); ipcMain.handle('secrets:get', (_e, name) => readSecrets()[String(name)] ?? null); ipcMain.handle('secrets:set', (_e, name, value) => { const all = readSecrets(); all[String(name)] = String(value); writeSecrets(all); return true; }); ipcMain.handle('secrets:clear', (_e, name) => { const all = readSecrets(); delete all[String(name)]; writeSecrets(all); return true; }); // Downloads are written to disk as they arrive — never collected in memory // and handed over at the end. // // That is what the browser does through the File System Access API or a // service worker, and **this application has neither**: `showDirectoryPicker` // is absent, and Chromium refuses to register a worker on a custom scheme. So // the chain fell through to its floor, which accumulates the whole file in // the page and hands Chromium a blob — a gigabyte of RAM for a gigabyte of // film, and a Save As dialog at the *end*, which is how it was noticed. // // The renderer still never names a path. It asks; the user chooses once; the // main process holds the handle and the renderer refers to it by an opaque id. const sinks = new Map(); const completedPaths = new Map(); let sinkId = 0; // A clean quit still has to tidy up: the `.part` convention above means a // crash leaves an obviously-unfinished file rather than a plausible one, but // quitting deliberately should leave nothing at all. Synchronous on purpose — // `before-quit` does not wait for promises, and an async cleanup here would // race the process exiting and finish nothing. app.on('before-quit', () => { for (const [id, sink] of sinks) { try { sink.stream.destroy(); } catch { /* already closed */ } try { fs.unlinkSync(sink.partial); } catch { /* already gone */ } sinks.delete(id); } }); /** `name`, or the first "name (n).ext" that is not taken — never an overwrite. */ function freeName(dir, filename) { if (!fs.existsSync(path.join(dir, filename))) return filename; const ext = path.extname(filename); const stem = path.basename(filename, ext); for (let n = 2; n < 1000; n++) { const candidate = `${stem} (${n})${ext}`; if (!fs.existsSync(path.join(dir, candidate))) return candidate; } throw new Error(`No free name for ${filename}`); } ipcMain.handle('folder:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || !result.filePaths.length) return null; config = { ...config, downloadDir: result.filePaths[0] }; writeConfig(config); return config.downloadDir; }); // Where downloads land when nobody has chosen anywhere. A browser does not // make you pick a folder before it will save a file, and neither should this // — "save automatically" that opens a dialog is not automatic. function defaultDownloadDir() { try { return app.getPath('downloads'); } catch { return os.homedir(); } } function chosenDownloadDir() { const dir = config.downloadDir; // A folder that has been removed or unmounted is not a folder any more, // and saying so beats failing on the first chunk of a download. if (!dir) return null; try { return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; } } ipcMain.handle('folder:get', () => { const chosen = chosenDownloadDir(); // `name` is what the settings row already renders, for the browser's // directory handle as much as for this. `isDefault` is how it knows not to // offer "forget" for a folder nobody chose. return { name: chosen || defaultDownloadDir(), isDefault: !chosen }; }); ipcMain.handle('folder:forget', () => { config = { ...config, downloadDir: '' }; writeConfig(config); return true; }); ipcMain.handle('root:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || !result.filePaths.length) return null; const chosen = result.filePaths[0]; return { path: chosen, name: path.basename(chosen) }; }); ipcMain.handle('save:begin', async (_e, suggestedName, opts) => { const wanted = path.basename(String(suggestedName || 'download')); const chosen = chosenDownloadDir(); let target = null; // "Save automatically" means exactly that: no dialog. Into the chosen // folder if there is one, otherwise the system's Downloads folder — the // first version required a folder to have been picked first, so the very // first automatic download opened a dialog, which is the one thing the // setting says it will not do. // // The one case that still asks: a folder *was* chosen and has since gone. // Redirecting those files somewhere else without saying so is worse than a // dialog — someone who picked an external drive wants to know it is not // there, not to find the film in their home directory a week later. if (opts && opts.auto && !(config.downloadDir && !chosen)) { const dir = chosen || defaultDownloadDir(); try { fs.mkdirSync(dir, { recursive: true }); target = path.join(dir, freeName(dir, wanted)); } catch { target = null; } } if (!target) { const dir = chosen || defaultDownloadDir(); const result = await dialog.showSaveDialog(mainWindow, { defaultPath: path.join(dir, wanted), }); if (result.canceled || !result.filePath) return null; target = result.filePath; } // Written to `.part` and renamed on completion, never straight to // the final name. `save:abort` already deleted a cancelled download, but // nothing covered the app being quit, killed or crashing mid-transfer: the // stream was simply abandoned and a truncated file kept the final name, // which is the exact thing save:abort's own comment says is worse than no // file at all — it looks complete to whoever opens it next. A leftover // `.part` is self-evidently unfinished, and it is the same convention the // node already uses for uploads (`_do_file_upload`). const id = String(++sinkId); const partial = target + '.part'; sinks.set(id, { stream: fs.createWriteStream(partial), path: target, partial }); return { id, name: path.basename(target), path: target }; }); ipcMain.handle('save:write', async (_e, id, chunk) => { const sink = sinks.get(String(id)); if (!sink) throw new Error('No such download'); // Awaiting the callback is what applies backpressure: without it the // renderer would outrun the disk and queue the file in memory anyway, // which is the thing this exists to avoid. await new Promise((resolve, reject) => sink.stream.write(Buffer.from(chunk), (err) => (err ? reject(err) : resolve()))); return true; }); ipcMain.handle('save:end', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); await new Promise((resolve) => sink.stream.end(resolve)); // The rename is what publishes the download. Only after the stream has // flushed, or the file bearing the final name would still be short. try { fs.renameSync(sink.partial, sink.path); } catch (err) { console.error('[MeshBay] could not finalise download:', err.message); return false; } completedPaths.set(String(id), sink.path); return true; }); ipcMain.handle('save:open', async (_e, id) => { const p = completedPaths.get(String(id)); if (!p) return false; await shell.openPath(p); return true; }); ipcMain.handle('save:abort', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); await new Promise((resolve) => sink.stream.close(resolve)); // A cancelled download leaves a truncated file, which is worse than none: // it looks like a complete one to whoever opens it next. Only the `.part` // exists at this stage — the final name is only taken by the rename in // save:end — so this removes that. try { fs.unlinkSync(sink.partial); } catch { /* already gone */ } return true; }); // ── Node loopback bridge ───────────────────────────────────────────────── // // The renderer never sees the session token. It names an operation and this // process executes it — the same pattern as hub:fetch. The token is read // from the daemon's data directory, cached for the lifetime of this process, // and never exposed through the preload. let _nodeToken = null; let _nodePort = 18000; let _nodePairingCode = null; function nodeConfigPath() { return path.join(meshbayConfigDir(), 'node.toml'); } function readNodeConfig() { try { const text = fs.readFileSync(nodeConfigPath(), 'utf8'); let dataDir = meshbayDataDir(); let uiPort = 18000; const dataMatch = text.match(/^\s*data_dir\s*=\s*"([^"]+)"/m); if (dataMatch) { dataDir = dataMatch[1].replace(/^~/, os.homedir()); } const portMatch = text.match(/^\s*ui_port\s*=\s*(\d+)/m); if (portMatch) uiPort = parseInt(portMatch[1], 10); return { dataDir, uiPort }; } catch { return null; } } function readNodeToken(dataDir) { try { return fs.readFileSync(path.join(dataDir, 'ui-token'), 'utf8').trim(); } catch { return null; } } ipcMain.handle('node:detect', async () => { const nc = readNodeConfig(); if (!nc) return { detected: false, configured: false }; const token = readNodeToken(nc.dataDir); if (!token) return { detected: false, configured: true }; _nodeToken = token; _nodePort = nc.uiPort; try { const r = await fetch( `http://127.0.0.1:${_nodePort}/api/status?t=${_nodeToken}`, { signal: AbortSignal.timeout(3000) }); if (!r.ok) return { detected: false, configured: true }; const status = await r.json(); return { detected: true, configured: true, status: status.status, pk_node_ed25519: status.pk_node_ed25519 || '', }; } catch { return { detected: false, configured: true }; } }); // Does THIS build ship its own node, as opposed to one merely reachable on // PATH (findNodeBinary's fallback, meant for a dev venv -- not "this // installer provisioned a node"). The "Light" target (electron-builder // .light.yml) ships no node-runtime extraResource at all; the renderer // uses this to fall back to the browser-only "create a group" form // instead of the wizard that assumes a local node it can link right there // (create-group-page.js). // Unaffected off win32 and in dev: only a packaged Windows build can even // be Light, so everything else keeps today's behaviour unconditionally. function hasBundledNode() { if (process.platform === 'win32' && app.isPackaged) { return fs.existsSync(path.join(process.resourcesPath, 'node-runtime', 'meshbay-node.exe')); } return true; } // build/installer.nsh's customInstall adds node-runtime\ to the per-user // PATH at install time (HKCU\Environment, no elevation needed for that -- // it never was the elevation that blocked it here). An AppX/MSIX install // has no install-time hook at all, so `meshbay-node` in a terminal simply // never got added for that target -- a real regression found by actually // running a sideloaded build, not a theoretical gap. Idempotent (the script // itself checks first) and harmless to call on every launch, NSIS Full // included, where it is normally already a no-op. Fire-and-forget: a // terminal convenience is not worth blocking startup or surfacing an error // dialog over. function winEnsureNodeOnPath() { if (process.platform !== 'win32' || !hasBundledNode()) return; const script = path.join(process.resourcesPath, 'ensure-node-path.ps1'); if (!fs.existsSync(script)) return; // dev run, or an older build without it const nodeDir = path.join(process.resourcesPath, 'node-runtime'); execFile(MB_PWSH, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-NodeDir', nodeDir], (err, stdout) => { if (err) { console.error('[path] ensure-node-path.ps1 failed:', err.message); return; } console.log('[path] node-runtime on PATH:', (stdout || '').trim()); }); } function findNodeBinary() { if (process.platform === 'win32') { // A packaged Windows build carries the frozen daemon as an // extraResource (package.json build.win, packaging/win/). Prefer it — // it is the version that shipped with this client. if (app.isPackaged) { const bundled = path.join(process.resourcesPath, 'node-runtime', 'meshbay-node.exe'); if (fs.existsSync(bundled)) return bundled; } } else { const local = path.join(os.homedir(), '.local', 'bin', 'meshbay-node'); if (fs.existsSync(local)) return local; } const cmd = process.platform === 'win32' ? 'where.exe' : 'which'; return new Promise((resolve) => { execFile(cmd, ['meshbay-node'], (err, stdout) => { if (err) { resolve(null); return; } // where.exe/which can list more than one match on PATH, and each // line keeps its own trailing \r on Windows -- `stdout.trim()` only // strips the ends of the *whole* string, so with 2+ matches a stray // \r stayed glued to the end of the first line. That \r then landed // inside the quoted path this function's caller writes into the // Startup .vbs, breaking VBScript's parser with "Unterminated // string constant" the next time Windows tried to run it at sign-in. const first = stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean); resolve(first || null); }); }); } // ── Windows: the Startup-folder launcher that stands in for the systemd unit ── // A logon-triggered Task Scheduler task needs elevation to create, which an // ordinary user does not have, so autostart is a `.vbs` in the per-user // Startup folder instead: wscript runs it hidden at every sign-in — no admin, // no console window. Kept in step with meshbay_node.platform._startup_vbs(). const WIN_STARTUP_VBS = path.join( app.getPath('appData'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'MeshBay Node.vbs'); function winAutostartInstalled() { try { return fs.existsSync(WIN_STARTUP_VBS); } catch { return false; } } function winAutostartInstall(bin) { fs.mkdirSync(path.dirname(WIN_STARTUP_VBS), { recursive: true }); // Chr(34) is a literal " — wraps the path so a space in it doesn't split // the command. 0 = hidden window, False = don't wait. Kept in step with // meshbay_node.platform.autostart_install(). fs.writeFileSync(WIN_STARTUP_VBS, `CreateObject("WScript.Shell").Run Chr(34) & "${bin}" & Chr(34), 0, False\r\n`); } function winAutostartRemove() { try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ } } // Prefers a graceful stop: `autostart stop` now tries CTRL_BREAK_EVENT // against the pid autostart_run() recorded first (meshbay_node.platform. // autostart_end()), which daemon.py's SIGBREAK handler turns into a real // _shutdown() -- closed WebRTC sessions, killed ffmpeg -- before that same // function falls back to a hard `taskkill /F` itself. Keeping the // graceful-then-forceful logic in that one place, rather than this // function *also* going straight to taskkill, is what actually fixed it: // two independent hard-kill call sites would still bypass shutdown one of // the times. Only genuinely falls back to taskkill here when the binary // cannot even be located. async function killNodeProcesses() { const bin = await findNodeBinary(); return new Promise((resolve) => { if (bin) { execFile(bin, ['autostart', 'stop'], () => resolve()); } else { execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve()); } }); } // ── Windows: the opt-in Scheduled Task "service mode" ────────────────────── // Set up once, elevated, at install time (build/installer.nsh + packaging/win // /service.ps1 + /service-mode.ps1) or via `meshbay-node service install` // from an elevated prompt — this process never creates or deletes it, only // queries and drives an existing one, which needs no elevation (Task // Scheduler grants the owning user that much itself). Kept in step with // meshbay_node.platform.TASK_NAME / service_status(). const WIN_SERVICE_TASK = 'MeshBay Node'; function winServiceTaskStatus() { return new Promise((resolve) => { execFile('schtasks', ['/query', '/tn', WIN_SERVICE_TASK, '/fo', 'list'], (err, stdout) => { if (err) return resolve({ installed: false, state: '' }); const m = (stdout || '').split(/\r?\n/).find((l) => /^status:/i.test(l.trim())); resolve({ installed: true, state: m ? m.split(':')[1].trim() : '' }); }); }); } function winServiceTaskRun() { return new Promise((resolve) => { execFile('schtasks', ['/run', '/tn', WIN_SERVICE_TASK], () => resolve()); }); } function winServiceTaskEnd() { return new Promise((resolve) => { execFile('schtasks', ['/end', '/tn', WIN_SERVICE_TASK], () => resolve()); }); } // ── Windows: switching INTO or OUT OF service mode after install ─────────── // build/installer.nsh's mode question is effectively one-shot: it skips // itself the moment the firewall rules already exist, and per-user mode // sets those up on its own, with no Scheduled Task involved. So declining // once (or the rules existing for any other reason) is a dead end through // the installer alone — this is the other door in, driven from the Node // page instead of setup. It runs the exact same packaging/win/service-mode.ps1 // the installer does (task + firewall, one elevation), so the two paths // can never disagree about what "service mode" means. const MB_PWSH = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; function winElevateServiceMode(action) { return new Promise((resolve, reject) => { const script = path.join(process.resourcesPath, 'service-mode.ps1'); if (!fs.existsSync(script)) { // service-mode.ps1 is an extraResource -- only present once installed // (package.json build.win.extraResources); nothing under `npm start`. // The Node page already disables the "background service" option // when node:service-status reports canElevate: false, so this should // only ever be reached if that guard is bypassed somehow -- keep the // message actionable regardless. reject(new Error( 'Switching to a background service needs an installed build. For ' + 'local testing, run "meshbay-node service install" from an ' + 'elevated PowerShell instead.')); return; } // Start-Process -Verb RunAs is the one UAC prompt; -Wait -PassThru hands // its exit code back to this unelevated process, so a decline ("The // operation was canceled by the user") surfaces as a rejection here // instead of silently doing nothing. Written to a temp .ps1 and run via // -File (not -Command) so the target path and its own arguments bind // through real PowerShell parameters instead of nested string quoting. const elevator = path.join(os.tmpdir(), 'meshbay-elevate-service-mode.ps1'); const elevatorSrc = [ 'param([string]$Target, [string]$TargetArgs)', '$ErrorActionPreference = "Stop"', '$p = Start-Process -FilePath $Target -ArgumentList $TargetArgs -Verb RunAs -Wait -PassThru', 'exit $p.ExitCode', '', ].join('\r\n'); fs.writeFileSync(elevator, elevatorSrc); const targetArgs = `-NoProfile -ExecutionPolicy Bypass -File "${script}" -Action ${action}`; execFile(MB_PWSH, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', elevator, '-Target', MB_PWSH, '-TargetArgs', targetArgs], (err) => { if (err) { reject(new Error('Elevation was declined, or the operation failed.')); return; } resolve(); }); }); } // A daemon that crashes immediately (a port already in use -- reproduced // live: a second node instance found 18000 taken by the first -- a corrupt // config, antivirus interference) used to fail silently: stdio was // 'ignore', so its stderr was thrown away, and the only failure path left // was the caller's waitForNode() timing out after a generic 60s ("did not // start within 60s"). The real reason was sitting on stderr the whole time, // just never read. This watches for a few seconds -- long enough for any // startup crash, reproduced consistently well under one second -- and // rejects with the daemon's own tail of stderr if it exits in that window. // If it survives the window, stdio is released and it is left fully // detached, same as before this existed. const NODE_CRASH_WATCH_MS = 2500; function spawnNodeDetachedWatched(bin, args = []) { return new Promise((resolve, reject) => { const child = spawn(bin, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let stderr = ''; let settled = false; child.stderr.on('data', (d) => { stderr += d.toString(); }); // spawn() failures (bad path, a stale PATH entry, antivirus // interference) land on the ChildProcess as an 'error' event, // asynchronously -- with no listener, Node rethrows it as an uncaught // exception and takes the whole main process down with it. child.on('error', (err) => { if (settled) return; settled = true; reject(err); }); child.on('exit', (code, signal) => { if (settled) return; settled = true; // By lines (last 8) at first cut the actual OSError -- a real crash // captured live logged the bind failure, then two separate uvicorn/ // asyncio tracebacks *after* it, which pushed it out of a short tail. // Character-bounded instead: Python's own daemon rarely writes more // than a couple of screens on a startup crash, so keeping the last // stretch of raw text is far more likely to still include the one // line that actually says what went wrong than guessing a line count. let tail = stderr.trim(); if (tail.length > 4000) tail = `…${tail.slice(-4000)}`; reject(new Error( `meshbay-node exited immediately (code ${code}${signal ? `, signal ${signal}` : ''})` + (tail ? `:\n${tail}` : ''))); }); setTimeout(() => { if (settled) return; settled = true; child.stdout.destroy(); child.stderr.destroy(); child.unref(); resolve(); }, NODE_CRASH_WATCH_MS); }); } async function spawnNodeDetached() { const bin = await findNodeBinary(); if (!bin) throw new Error('meshbay-node not found on PATH'); await spawnNodeDetachedWatched(bin); } async function waitForNode(deadline) { while (Date.now() < deadline) { const p = await probeNode(); if (p) return p; await new Promise((r) => setTimeout(r, 800)); } return null; } // The daemon is reachable but sitting at 'waiting_for_node_key' / // 'waiting_for_account': its Ed25519 key is not linked to the hub account it // runs as (a fresh node, or that account still carries a previous machine's // node key). Link it with the signed-in user's token, then wait out the // daemon's own 5s hub-auth retry until it reports 'running'. `PUT // /me/node_key` overwrites unconditionally, so this also recovers an account // whose linked key belongs to a node that is gone. // // The Linux branch of `node:start` does the same thing inline; Windows went // without it, so the daemon never left 'waiting_for_account' and the Create // Group wizard spun on "Detecting local node…" for ever. async function linkNodeKeyAndAwaitRunning(opts, deadline) { let linked = false; let last = null; while (Date.now() < deadline) { last = await probeNode(); if (last && last.status === 'running') return last; if (last && !linked && opts && opts.token && opts.hubUrl && last.pk_node_ed25519 && (last.status === 'waiting_for_node_key' || last.status === 'waiting_for_account')) { try { const r = await fetch(`${opts.hubUrl}/v1/users/me/node_key`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${opts.token}` }, body: JSON.stringify({ pk_node_ed25519: last.pk_node_ed25519 }), signal: AbortSignal.timeout(5000), }); if (r.ok) linked = true; } catch { /* transient — retried on the next tick */ } } await new Promise((r) => setTimeout(r, 500)); } return last; } ipcMain.handle('node:installed', async () => { if (process.platform === 'win32') { const [bin, svc] = await Promise.all([findNodeBinary(), winServiceTaskStatus()]); return { installed: Boolean(bin), autostart: winAutostartInstalled(), service: svc.installed, }; } if (process.platform !== 'linux') return { installed: false }; const unit = await new Promise((resolve) => { execFile('systemctl', ['--user', 'show', 'meshbay-node.service', '--property=LoadState'], (err, stdout) => { if (err) return resolve(false); resolve(stdout.trim() === 'LoadState=loaded'); }); }); if (unit) return { installed: true }; const bin = await findNodeBinary(); return { installed: Boolean(bin) }; }); ipcMain.handle('node:bundled', () => hasBundledNode()); // The systemd unit's own view of the node, for the status panel at the top // of the Node page. Deliberately not `probeNode()`: that asks the daemon's // own HTTP API, which cannot answer while the daemon is stopped or crash- // looping — exactly the states this panel exists to show and act on. // The tray menu drives the same daemon controls as the Node page. Declared // here because these close over registerBridge's helpers; the tray is created // long after, so it reads them through this binding rather than duplicating // the systemctl and Task Scheduler branches. nodeService = { status: nodeServiceStatus, stop: nodeServiceStop, restart: nodeServiceRestart }; ipcMain.handle('node:service-status', () => nodeServiceStatus()); // service-mode.ps1 is an extraResource present in a packaged Full build, // absent from a packaged Light one (nothing to run as a service) and from // an unpackaged dev run. app.isPackaged alone used to gate this, which is // wrong for Light: it would enable the "background service" option and // only fail when actually clicked (winElevateServiceMode's own existsSync // check below, with an actionable error) -- correct but a dead click the // Node page should not offer in the first place. function winCanElevateServiceMode() { return app.isPackaged && fs.existsSync(path.join(process.resourcesPath, 'service-mode.ps1')); } async function nodeServiceStatus() { if (process.platform === 'win32') { const svc = await winServiceTaskStatus(); if (svc.installed) { // Service mode: Task Scheduler already tracks running/not, directly — // no need to probe the daemon's own API for this panel. const running = /running/i.test(svc.state); return { supported: true, mode: 'service', installed: true, activeState: running ? 'active' : 'inactive', subState: svc.state, // Whether switching startup mode can actually elevate right now — // service-mode.ps1 is an extraResource, present in a packaged Full // build but not a Light one (no node to run as a service at all). // Already installed here, so removing it always works regardless; // this only gates the Node page offering to switch *into* service // mode. canElevate: winCanElevateServiceMode(), }; } // Per-user Startup mode. `installed` used to be winAutostartInstalled(), // which is wrong: it answers "does the Startup launcher exist", not "is // there a daemon to manage". The Node page's Stop/Restart buttons are // gated on `installed`, so with no autostart configured they silently // vanished — the daemon was perfectly manageable, just not launchable // at sign-in. `autostart` carries that state as its own field instead. const [p, bin] = await Promise.all([probeNode(), findNodeBinary()]); return { supported: true, // Only claim "startup mode" once a node was actually found (bundled // or on PATH) -- a Light install with none at all would otherwise // show a working-looking autostart dropdown for a node that does // not exist. `showStartupRow` in node-page.js is gated on this being // a string, so `null` here hides that whole row. mode: bin ? 'startup' : null, installed: Boolean(bin), autostart: winAutostartInstalled(), activeState: p ? 'active' : 'inactive', subState: p ? 'running' : '', canElevate: winCanElevateServiceMode(), }; } if (process.platform !== 'linux') return { supported: false }; return new Promise((resolve) => { execFile('systemctl', ['--user', 'show', 'meshbay-node.service', '--property=LoadState,ActiveState,SubState'], (err, stdout) => { if (err) { resolve({ supported: true, installed: false, activeState: 'unknown', subState: '' }); return; } const props = {}; for (const line of stdout.split('\n')) { const i = line.indexOf('='); if (i > 0) props[line.slice(0, i)] = line.slice(i + 1); } resolve({ supported: true, installed: props.LoadState === 'loaded', activeState: props.ActiveState || 'unknown', subState: props.SubState || '', }); }); }); } ipcMain.handle('node:service-stop', () => nodeServiceStop()); async function nodeServiceStop() { if (process.platform === 'win32') { const svc = await winServiceTaskStatus(); if (svc.installed) await winServiceTaskEnd(); await killNodeProcesses(); // graceful-then-forceful; also the // belt-and-suspenders in case /end left the process running return { stopped: true }; } if (process.platform !== 'linux') { throw new Error('Service control is only supported on Linux'); } await new Promise((resolve, reject) => { execFile('systemctl', ['--user', 'stop', 'meshbay-node'], (err, _stdout, stderr) => { if (err) return reject(new Error(stderr.trim() || err.message)); resolve(); }); }); return { stopped: true }; } ipcMain.handle('node:service-restart', () => nodeServiceRestart()); async function nodeServiceRestart() { if (process.platform === 'win32') { const svc = await winServiceTaskStatus(); if (svc.installed) await winServiceTaskEnd(); await killNodeProcesses(); if (svc.installed) { await winServiceTaskRun(); } else { await spawnNodeDetached(); } const p = await waitForNode(Date.now() + 30000); if (!p) throw new Error('node did not come back up within 30s'); return { restarted: true, ...p }; } if (process.platform !== 'linux') { throw new Error('Service control is only supported on Linux'); } await new Promise((resolve, reject) => { execFile('systemctl', ['--user', 'restart', 'meshbay-node'], (err, _stdout, stderr) => { if (err) return reject(new Error(stderr.trim() || err.message)); resolve(); }); }); return { restarted: true }; } // Install / remove the Windows Startup-folder launcher, and query it. ipcMain.handle('node:autostart', async (_e, action) => { if (process.platform !== 'win32') return { supported: false }; if (action === 'install') { const bin = await findNodeBinary(); if (!bin) throw new Error('meshbay-node not found on PATH'); winAutostartInstall(bin); return { supported: true, installed: true }; } if (action === 'remove') { winAutostartRemove(); return { supported: true, installed: false }; } return { supported: true, installed: winAutostartInstalled() }; }); // Turn service mode on or off after install — one elevation, task + firewall // together, via the same service-mode.ps1 the installer runs. See // winElevateServiceMode() above for why this is needed at all. ipcMain.handle('node:service-mode', async (_e, action) => { if (process.platform !== 'win32') return { supported: false }; if (action !== 'install' && action !== 'remove') { throw new Error(`unknown service-mode action: ${action}`); } await winElevateServiceMode(action); const svc = await winServiceTaskStatus(); return { supported: true, installed: svc.installed }; }); async function probeNode() { const nc = readNodeConfig(); const dataDir = nc ? nc.dataDir : meshbayDataDir(); const port = nc ? nc.uiPort : 18000; const token = readNodeToken(dataDir); if (!token) return null; try { const r = await fetch( `http://127.0.0.1:${port}/api/status?t=${token}`, { signal: AbortSignal.timeout(3000) }); if (!r.ok) return null; const status = await r.json(); const READY = ['running', 'waiting_for_node_key', 'waiting_for_account', 'starting']; if (!READY.includes(status.status)) return null; _nodeToken = token; _nodePort = port; return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status }; } catch { return null; } } // A path inside a TOML basic string: forward slashes only. A raw Windows // path there (`C:\Users\...`) is a parse error — `\U`, `\a`, ... are escape // sequences. pathlib on the node reads the `/` form fine. const tomlPath = (p) => p.split(path.sep).join('/'); function provisionNode(hubUrl, username) { const configDir = meshbayConfigDir(); const dataDir = meshbayDataDir(); fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(dataDir, { recursive: true }); const configFile = nodeConfigPath(); if (fs.existsSync(configFile)) { const content = fs.readFileSync(configFile, 'utf8'); const updated = content .replace(/^url\s*=\s*"[^"]*"/m, `url = "${hubUrl}"`) .replace(/^username\s*=\s*"[^"]*"/m, `username = "${username}"`); fs.writeFileSync(configFile, updated, { mode: 0o600 }); } else { const toml = [ '[hub]', `url = "${hubUrl}"`, `username = "${username}"`, '', '[node]', 'quic_enabled = false # QUIC direct path; no client uses it yet', 'quic_port = 19010', 'ui_port = 18000', '', '[keystore]', `unlock_file = "${tomlPath(path.join(configDir, 'unlock.key'))}"`, '', ].join('\n'); fs.writeFileSync(configFile, toml, { mode: 0o600 }); } const unlockFile = path.join(configDir, 'unlock.key'); if (!fs.existsSync(unlockFile)) { const key = crypto.randomBytes(32).toString('base64url'); fs.writeFileSync(unlockFile, key + '\n', { mode: 0o600 }); } } ipcMain.handle('node:start', async (_e, opts) => { const already = await probeNode(); if (already && already.status === 'running') { return { started: true, ...already }; } if (process.platform === 'win32') { if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username); const svc = await winServiceTaskStatus(); if (svc.installed) { // A service-mode daemon runs under the task's own S4U logon session, // not this (interactive) one -- killNodeProcesses()'s taskkill and // CTRL_BREAK both target it by image name/pid from here, and both // fail with "Access is denied" across that session boundary // (confirmed live 2026-09-14: an already-elevated `schtasks /end` // succeeds against the exact same pid taskkill just refused). // Silently, too -- killNodeProcesses() never surfaces the failure, // so a stuck instance was never actually replaced: re-running the // task below is then a no-op too, since Windows still considers it // Running (default "do not start a new instance" policy). Task Scheduler can // stop what it started; go through it, the way nodeServiceStop/ // nodeServiceRestart already correctly do, instead of reaching past it. await winServiceTaskEnd(); await winServiceTaskRun(); } else { await killNodeProcesses(); // clear a crash-looping one (same session) await spawnNodeDetached(); } const p = await waitForNode(Date.now() + 60000); if (!p) throw new Error('the node did not start within 60s — run it from a ' + 'terminal (`meshbay-node`) to see why'); // Up, but almost never 'running' on a first launch: link the node key to // the hub account and wait for the daemon to authenticate. Without this // it stays at 'waiting_for_account' and nothing here ever tells the hub // about the node. const ready = p.status === 'running' ? p : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 45000); if (!ready || ready.status !== 'running') { // "Link Node" is on the Settings page, not this one -- pointing here // at the Node page sent whoever read this hunting for a control that // is not on it (reproduced live 2026-09-14). throw new Error( 'the node started but could not link to your hub account. Open ' + 'Settings and use "Link Node", or check you are signed in to ' + 'the hub this node is configured for.'); } return { started: true, ...ready }; } if (process.platform !== 'linux') { throw new Error('Automatic node start is only supported on Linux'); } if (opts && opts.hubUrl && opts.username) { provisionNode(opts.hubUrl, opts.username); } const deadline = Date.now() + 60000; const configFile = nodeConfigPath(); let launched = false; // Clear any failed state from a prior crash loop. await new Promise((r) => { execFile('systemctl', ['--user', 'reset-failed', 'meshbay-node'], () => r()); }); // If a daemon is running in a bad state, restart it with the fresh config. if (already) { try { await new Promise((resolve, reject) => { execFile('systemctl', ['--user', 'restart', 'meshbay-node'], (err, _stdout, stderr) => { if (err) return reject(new Error(stderr.trim() || err.message)); resolve(); }); }); launched = true; } catch { /* not systemd-managed — fall through to start */ } } // Try systemctl first — the production path. if (!launched) try { await new Promise((resolve, reject) => { execFile('systemctl', ['--user', 'enable', '--now', 'meshbay-node'], (err, _stdout, stderr) => { if (err) return reject(new Error(stderr.trim() || err.message)); resolve(); }); }); // Give the service a moment, then check if it stayed up. for (let i = 0; i < 6 && Date.now() < deadline; i++) { await new Promise((r) => setTimeout(r, 500)); const result = await probeNode(); if (result) { launched = true; break; } } if (!launched) { const isActive = await new Promise((resolve) => { execFile('systemctl', ['--user', 'is-active', 'meshbay-node'], (err) => resolve(!err)); }); if (isActive) { launched = true; } else { await new Promise((resolve) => { execFile('systemctl', ['--user', 'stop', 'meshbay-node'], () => resolve()); }); } } } catch { // systemctl itself failed (e.g. no unit file). } // Fallback: start the binary directly (dev mode, or no unit installed). if (!launched) { const binPath = await findNodeBinary(); if (!binPath) { throw new Error( 'meshbay-node is not installed'); } const child = spawn(binPath, ['--config', configFile], { detached: true, stdio: 'ignore', }); // Same reason as spawnNodeDetached(): an unhandled 'error' event here // would crash the whole main process instead of letting the polling // loop below report "never came up". child.on('error', (err) => console.error('[node] failed to start:', err.message)); child.unref(); } // Wait for the daemon to reach 'running'. Along the way, auto-link the // node key on the hub so the daemon can authenticate. let keyLinked = false; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 500)); const result = await probeNode(); if (!result) continue; if (result.status === 'running') { return { started: true, ...result }; } // Daemon is up but stuck on hub auth — link the key so it can proceed. if (!keyLinked && opts && opts.token && result.pk_node_ed25519 && (result.status === 'waiting_for_node_key' || result.status === 'waiting_for_account')) { try { const lr = await fetch( `${opts.hubUrl}/v1/users/me/node_key`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${opts.token}` }, body: JSON.stringify({ pk_node_ed25519: result.pk_node_ed25519 }), signal: AbortSignal.timeout(5000), }); if (lr.ok) keyLinked = true; } catch { /* best effort */ } } } throw new Error( 'meshbay-node was started but did not become ready within 60 seconds'); }); ipcMain.handle('node:call', async (_e, method, apiPath, body) => { if (!_nodeToken) throw new Error('Node not detected'); const sep = apiPath.includes('?') ? '&' : '?'; const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`; const init = { method: String(method).toUpperCase() }; if (body !== undefined && body !== null) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); } init.signal = AbortSignal.timeout(30000); const r = await fetch(url, init); const text = await r.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!r.ok) { const msg = (data && data.error) || (data && data.detail) || text; throw new Error(`Node ${r.status}: ${msg}`); } return data; }); ipcMain.handle('node:pairing-code', async () => { const code = _nodePairingCode; _nodePairingCode = null; return code; }); ipcMain.handle('node:set-pairing-code', async (_e, code) => { _nodePairingCode = code || null; return true; }); // ── LAN cast relay ────────────────────────────────────────────────────── // // A local HTTP server that re-serves decrypted fMP4 segments so a // Chromecast or Smart TV on the same Wi-Fi can stream the video. The // renderer feeds it segments via IPC; the relay serves them over HTTP. // Same trust boundary as the MSE player and the download-to-disk path. ipcMain.handle('cast:start', async (_e, opts) => { return castRelay.start({ codec: opts.codec, initSegment: opts.initSegment ? Buffer.from(opts.initSegment) : null, subtitle: opts.subtitle || null, }); }); // Carried separately from `cast:start` for the viewer who turns subtitles on // without seeking: the relay keeps serving the same video while the receiver // is told to load again with the new track. ipcMain.handle('cast:subtitle', async (_e, sub) => { return castRelay.setSubtitle(sub || null); }); ipcMain.handle('cast:push', async (_e, data) => { castRelay.pushSegment(Buffer.from(data)); return true; }); ipcMain.handle('cast:stop', async () => { await castRelay.stop(); return true; }); ipcMain.handle('cast:finish', async () => { castRelay.finish(); return true; }); ipcMain.handle('cast:status', async () => ({ active: castRelay.active, url: castRelay.url, subtitle: castRelay.subtitle, chromecast: castChromecast.getStatus(), })); // ── Chromecast discovery + control ────────────────────────────────────── ipcMain.handle('cast:discover', async () => { return castChromecast.discover(); }); ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { return castChromecast.connect(deviceId, mediaUrl, subtitle === undefined ? castRelay.subtitle : subtitle); }); ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => { return castChromecast.reload(mediaUrl, subtitle === undefined ? castRelay.subtitle : subtitle); }); ipcMain.handle('cast:chromecast:disconnect', async () => { await castChromecast.disconnect(); return true; }); winEnsureNodeOnPath(); } /** * Why the hub could not be reached, in words somebody can act on. * * `TypeError: fetch failed` is what Node says for a refused connection, a DNS * failure and a TLS mismatch alike. The most common mistake by far is `https` * typed at a hub speaking plain `http`, so that one is named outright. */ function describeUnreachable(url, error) { const cause = (error && error.cause) || {}; const code = cause.code || ''; const detail = cause.message || error.message || String(error); if (/^https:/.test(url) && (code === 'ECONNRESET' || /wrong version|SSL|TLS|EPROTO/i.test(detail))) { return `${url} does not speak https. If this hub is on your own machine, ` + `it is probably http — try http:// instead.`; } if (code === 'ECONNREFUSED') { return `Nothing is listening at ${url}. Is the hub running?`; } if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { return `${url} could not be found. Check the address.`; } if (error && error.name === 'TimeoutError') { return `${url} did not answer in time.`; } return `Could not reach ${url}: ${detail}`; } // ── The version gate ──────────────────────────────────────────────────────── /** Compare two dotted versions. -1, 0 or 1; unreadable sorts as equal. */ function compareVersions(a, b) { const parse = (v) => String(v || '').split('.').map((n) => parseInt(n, 10)); const [x, y] = [parse(a), parse(b)]; if (x.some(Number.isNaN) || y.some(Number.isNaN)) return 0; for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } /** * Refuse to start when this build is older than the hub will talk to. * * The reason this exists rather than letting the handshake do it: the SPA is * served by the hub and picks up a new client on reload, but **this * application ships its own interface**. On the MNP 3.0 flag day an * un-updated one can still sign in, still list groups, and then fail every * connection with `version_too_old` — a refusal in a protocol vocabulary, * surfacing as a node that will not talk, with nothing anyone can act on. * * So the question is asked once, up front, of `/v1/hub/version`, which has * carried `client.minimum` since before there was a client to check it. * * **Unreachable is not too old.** A hub that is down, a laptop with no network, * a captive portal: none of those are a reason to refuse to open the * application, and treating them as one would make an offline start impossible * for ever. Only a definite answer, saying in so many words that this version * is below the minimum, stops anything. */ async function refuseIfTooOld() { const base = String(config.hubBase || '').replace(/\/+$/, ''); if (!base) return false; // First run: there is no hub to ask yet. let info; try { const r = await fetch(`${base}/v1/hub/version`, { signal: AbortSignal.timeout(10000) }); if (!r.ok) return false; info = await r.json(); } catch { return false; } const minimum = info && info.client && info.client.minimum; if (!minimum) return false; const mine = app.getVersion(); if (compareVersions(mine, minimum) >= 0) return false; const { response } = await dialog.showMessageBox({ type: 'warning', title: 'Update required', message: 'This version of MeshBay can no longer connect', detail: `This application is version ${mine}, and ${base} now requires ` + `${minimum} or later.\n\nDownload the current version and install it ` + 'over this one — your groups, keys and settings are kept.', buttons: ['Download the update', 'Quit'], defaultId: 0, cancelId: 1, }); if (response === 0) await shell.openExternal(base); return true; } // ── Lifecycle ─────────────────────────────────────────────────────────────── // One instance. Two would fight over the config file and the secrets blob, and // the second would look like the first had lost its state. if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on('second-instance', () => { showFromTray(); }); app.whenReady().then(async () => { // Before anything else is built. A window that opens and then cannot // connect is the failure this replaces. if (await refuseIfTooOld()) { app.quit(); return; } registerUiProtocol(); // Before ensureTray: buildTrayMenu reads `nodeService`, which registerBridge // assigns, so creating the tray after it means the Start/Stop entry is on // the very first menu rather than appearing one poll later. registerBridge(); if (trayOS()) ensureTray(); createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); } module.exports = { contentType, secretsBackend, CSP };