/** * 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/desktop-client-v1.md §2 and §3. */ 'use strict'; const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } = 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'); // TEST-ENV WORKAROUND (Windows libvirt/KVM guest) — REVISIT BEFORE RELEASE. // In that guest Chromium hides the host candidate behind a random `.local` // mDNS name that the node's ICE stack cannot resolve across the KVM bridge, // so the one working candidate pair is present on some attempts and missing // on others (60 s ICE timeouts, "2nd connection hangs"). Publishing the real // local IP removes the dependency. Scoped to win32 so it changes nothing on // Linux/macOS, where mDNS concealment works and should stay on. if (process.platform === 'win32') { app.commandLine.appendSwitch('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'", `frame-src ${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(); // ── 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/per-node-identity-v1.md). 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; 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()); // 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; } }); 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; /** `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; } const id = String(++sinkId); sinks.set(id, { stream: fs.createWriteStream(target), path: target }); 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)); completedPaths.set(String(id), sink.path); await new Promise((resolve) => sink.stream.end(resolve)); 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. try { fs.unlinkSync(sink.path); } 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 }; } }); function findNodeBinary() { if (process.platform !== 'win32') { 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) => { resolve(err ? null : stdout.trim().split('\n')[0]); }); }); } ipcMain.handle('node:installed', async () => { 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) }; }); // 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. ipcMain.handle('node:service-status', async () => { 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', async () => { 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', async () => { 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 }; }); 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; } } 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 = "${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 !== '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', }); 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, }); }); 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, chromecast: castChromecast.getStatus(), })); // ── Chromecast discovery + control ────────────────────────────────────── ipcMain.handle('cast:discover', async () => { return castChromecast.discover(); }); ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl }) => { return castChromecast.connect(deviceId, mediaUrl); }); ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl }) => { return castChromecast.reload(mediaUrl); }); ipcMain.handle('cast:chromecast:disconnect', async () => { await castChromecast.disconnect(); return true; }); } /** * 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}`; } // ── 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', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); app.whenReady().then(() => { registerUiProtocol(); registerBridge(); 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 };