diff options
Diffstat (limited to 'packages/meshbay-client/src/main.js')
| -rw-r--r-- | packages/meshbay-client/src/main.js | 362 |
1 files changed, 291 insertions, 71 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index f8bb3f4..80f49e4 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -30,6 +30,7 @@ const fsp = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); +const vm = require('node:vm'); // Linux window managers/desktop shells (GNOME's dash included) group and // icon-match a running window by its WM_CLASS, resolved against an installed @@ -737,13 +738,85 @@ function createWindow() { // renderer parses decrypted content from nodes, which is attacker-controlled // input, so it is treated as hostile even though it is our own code. +// Every channel answers the packaged interface's own top-level document and +// nothing else. The preload is not injected into subframes, so today nothing +// else holds the bridge; this is what keeps that true if a frame, a second +// window or a navigation ever gets one by another route. +function fromOurPage(event) { + const frame = event.senderFrame; + if (!frame || frame.parent) return false; + try { + const url = new URL(frame.url); + return url.protocol === `${SCHEME}:` && url.host === 'meshbay'; + } catch { return false; } +} + +function handle(channel, fn) { + ipcMain.handle(channel, (event, ...args) => { + if (!fromOurPage(event)) throw new Error('Refused: not the MeshBay interface'); + return fn(event, ...args); + }); +} + +// ── Native confirmation ───────────────────────────────────────────────────── +// +// What widens what the local node shares or admits, or replaces its group key, +// is confirmed by a dialog this process draws. A confirmation drawn by the page +// is one a script in the page can answer for itself, and the page parses +// content from nodes. The words come from the interface's own catalogues, read +// here from the packaged files; the facts in them (a folder, a group, an +// account) are filled in by this process. The page chooses the language and +// nothing else. + +let uiLocale = 'en'; + +function readCatalogue(code) { + try { + const source = fs.readFileSync(path.join(UI_DIR, 'locales', `${code}.js`), 'utf8'); + const box = { module: {} }; + vm.runInNewContext(source.replace(/^export default /m, 'module.exports = '), box, + { timeout: 1000 }); + return box.module.exports || {}; + } catch { return {}; } +} + +function nativeText(key, params = {}) { + const pick = (cat) => { + const entry = cat[key]; + return typeof entry === 'string' ? entry : (entry && entry.other) || null; + }; + let text = pick(readCatalogue(uiLocale)) || pick(readCatalogue('en')) || key; + // split/join, as in i18n.js: a folder name may contain `$&`. + for (const [k, v] of Object.entries(params)) text = text.split(`{${k}}`).join(String(v)); + return text; +} + +async function confirmNatively(key, params) { + const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; + const options = { + type: 'question', message: nativeText(key, params), noLink: true, + buttons: [nativeText('dialog.ok'), nativeText('dialog.cancel')], defaultId: 0, cancelId: 1, + }; + const { response } = win ? await dialog.showMessageBox(win, options) + : await dialog.showMessageBox(options); + // The keyboard goes back to the page explicitly: after a dialog, Chromium can + // leave the document unfocused and every keystroke then goes nowhere, which + // is why the interface draws its own confirmations (ask.js). + if (win && !win.isDestroyed()) { win.focus(); win.webContents.focus(); } + return response === 0; +} + +async function confirmOrRefuse(key, params) { + if (!await confirmNatively(key, params)) throw new Error(nativeText('native.declined')); +} + 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) => { + 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 @@ -795,7 +868,7 @@ function registerBridge() { // 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) => { + 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 @@ -833,7 +906,7 @@ function registerBridge() { }; }); - ipcMain.handle('ice:resolve-stun', async (_e, urls) => { + handle('ice:resolve-stun', async (_e, urls) => { const dns = require('node:dns').promises; const list = Array.isArray(urls) ? urls : []; const out = []; @@ -853,7 +926,7 @@ function registerBridge() { // 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) => { + handle('window:minimize-to-tray', (_e, labels) => { if (!trayOS() || !mainWindow) return false; ensureTray(labels); mainWindow.hide(); @@ -866,18 +939,18 @@ function registerBridge() { // 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) => { + handle('tray:labels', (_e, labels) => { if (!trayOS()) return false; ensureTray(labels); return true; }); - ipcMain.handle('device:ensure', () => ensureDeviceKey()); - ipcMain.handle('device:public', () => { + handle('device:ensure', () => ensureDeviceKey()); + handle('device:public', () => { const key = deviceKey(); return key ? publicKeyB64(key) : null; }); - ipcMain.handle('device:sign', (_e, username) => { + handle('device:sign', (_e, username) => { const key = deviceKey(); if (!key) return null; const timestamp = Math.floor(Date.now() / 1000); @@ -890,26 +963,27 @@ function registerBridge() { signature: crypto.sign(null, message, key).toString('base64'), }; }); - ipcMain.handle('device:forget', () => { + 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; + // The store itself is not reachable from the page. It holds the device's hub + // key, which the page is never handed (above), and nothing in the interface + // reads or writes anything else in it: a generic get/set by name was a way + // to both read that key and replace it. What the page may know is whether + // the OS protects the store. + handle('secrets:backend', () => secretsBackend()); + + // Which catalogue native confirmations are worded from. A language code and + // nothing else: an unknown one leaves the current language in place. + handle('ui:locale', (_e, code) => { + const c = String(code || ''); + if (/^[a-z]{2}(-[A-Z]{2})?$/.test(c) + && fs.existsSync(path.join(UI_DIR, 'locales', `${c}.js`))) uiLocale = c; + return uiLocale; }); // Downloads are written to disk as they arrive — never collected in memory @@ -953,7 +1027,7 @@ function registerBridge() { throw new Error(`No free name for ${filename}`); } - ipcMain.handle('folder:choose', async () => { + handle('folder:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); @@ -978,7 +1052,7 @@ function registerBridge() { try { return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; } } - ipcMain.handle('folder:get', () => { + 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 @@ -986,22 +1060,29 @@ function registerBridge() { return { name: chosen || defaultDownloadDir(), isDefault: !chosen }; }); - ipcMain.handle('folder:forget', () => { + handle('folder:forget', () => { config = { ...config, downloadDir: '' }; writeConfig(config); return true; }); - ipcMain.handle('root:choose', async () => { + // A folder chosen here is one the person pointed at in a dialog this process + // drew, which is the consent that sharing it needs: the node is given it + // without asking again. A folder the page names that was not chosen here is + // confirmed natively before the node hears of it (`node:op`). + const pickedFolders = new Set(); + + 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]; + pickedFolders.add(path.resolve(chosen)); return { path: chosen, name: path.basename(chosen) }; }); - ipcMain.handle('save:begin', async (_e, suggestedName, opts) => { + handle('save:begin', async (_e, suggestedName, opts) => { const wanted = path.basename(String(suggestedName || 'download')); const chosen = chosenDownloadDir(); let target = null; @@ -1047,7 +1128,7 @@ function registerBridge() { return { id, name: path.basename(target), path: target }; }); - ipcMain.handle('save:write', async (_e, id, chunk) => { + 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 @@ -1059,7 +1140,7 @@ function registerBridge() { return true; }); - ipcMain.handle('save:end', async (_e, id) => { + handle('save:end', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); @@ -1076,14 +1157,14 @@ function registerBridge() { return true; }); - ipcMain.handle('save:open', async (_e, id) => { + 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) => { + handle('save:abort', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); @@ -1136,7 +1217,7 @@ function registerBridge() { } } - ipcMain.handle('node:detect', async () => { + handle('node:detect', async () => { const nc = readNodeConfig(); if (!nc) return { detected: false, configured: false }; const token = readNodeToken(nc.dataDir); @@ -1439,7 +1520,7 @@ function registerBridge() { // 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) { + async function linkNodeKeyAndAwaitRunning(link, deadline) { let linked = false; let last = null; while (Date.now() < deadline) { @@ -1447,13 +1528,13 @@ function registerBridge() { if (last && last.status === 'running') return last; // Whatever it is waiting for: a node backing off a 429 still needs its // key linked before its next attempt can succeed. - if (last && !linked && opts && opts.token && opts.hubUrl + if (last && !linked && link.token && link.hubUrl && last.pk_node_ed25519 && last.status !== 'starting') { try { - const r = await fetch(`${opts.hubUrl}/v1/users/me/node_key`, { + const r = await fetch(`${link.hubUrl}/v1/users/me/node_key`, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${opts.token}` }, + 'Authorization': `Bearer ${link.token}` }, body: JSON.stringify({ pk_node_ed25519: last.pk_node_ed25519 }), signal: AbortSignal.timeout(5000), }); @@ -1465,7 +1546,7 @@ function registerBridge() { return last; } - ipcMain.handle('node:installed', async () => { + handle('node:installed', async () => { if (process.platform === 'win32') { const [bin, svc] = await Promise.all([findNodeBinary(), winServiceTaskStatus()]); return { @@ -1487,7 +1568,7 @@ function registerBridge() { return { installed: Boolean(bin) }; }); - ipcMain.handle('node:bundled', () => hasBundledNode()); + 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 @@ -1500,7 +1581,7 @@ function registerBridge() { nodeService = { status: nodeServiceStatus, stop: nodeServiceStop, restart: nodeServiceRestart }; - ipcMain.handle('node:service-status', () => nodeServiceStatus()); + 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 @@ -1582,7 +1663,7 @@ function registerBridge() { }); } - ipcMain.handle('node:service-stop', () => nodeServiceStop()); + handle('node:service-stop', () => nodeServiceStop()); async function nodeServiceStop() { if (process.platform === 'win32') { @@ -1608,7 +1689,7 @@ function registerBridge() { return { stopped: true }; } - ipcMain.handle('node:service-restart', () => nodeServiceRestart()); + handle('node:service-restart', () => nodeServiceRestart()); async function nodeServiceRestart() { if (process.platform === 'win32') { @@ -1634,7 +1715,7 @@ function registerBridge() { } // Install / remove the Windows Startup-folder launcher, and query it. - ipcMain.handle('node:autostart', async (_e, action) => { + handle('node:autostart', async (_e, action) => { if (process.platform !== 'win32') return { supported: false }; if (action === 'install') { // Both would start the node: at boot, then again at sign-in. @@ -1656,7 +1737,7 @@ function registerBridge() { // 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) => { + 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}`); @@ -1732,6 +1813,43 @@ function registerBridge() { // sequences. pathlib on the node reads the `/` form fine. const tomlPath = (p) => p.split(path.sep).join('/'); + // What the page may ask the node to run as: a name the hub would register, + // which is also a string that cannot break out of a TOML string. The hub is + // never the page's to name -- it is the one this application is signed in to. + const USERNAME_RE = /^[\p{L}\p{N}._-]{1,64}$/u; + const tomlString = (s) => JSON.stringify(String(s)); + const sameHub = (a, b) => String(a || '').trim().replace(/\/+$/, '') + === String(b || '').trim().replace(/\/+$/, ''); + + function provisionedAs() { + try { + const text = fs.readFileSync(nodeConfigPath(), 'utf8'); + const url = text.match(/^url\s*=\s*"([^"]*)"/m); + const user = text.match(/^username\s*=\s*"([^"]*)"/m); + return url && user ? { hubUrl: url[1], username: user[1] } : null; + } catch { return null; } + } + + // Pointing a node already set up for one account at another stops it serving + // that account's groups, so it is the person's decision, asked natively. Not + // asked when nothing changes, which is every start but the first on a machine + // with one account. + async function provisionFromRequest(opts) { + const hubUrl = String(config.hubBase || ''); + if (!opts || !opts.username || !hubUrl) return null; + const username = String(opts.username); + if (!USERNAME_RE.test(username)) throw new Error('Refused: not a username'); + const current = provisionedAs(); + if (current && (!sameHub(current.hubUrl, hubUrl) || current.username !== username)) { + await confirmOrRefuse('native.node_account_confirm', { + current: `${current.username} @ ${current.hubUrl}`, + next: `${username} @ ${hubUrl}`, + }); + } + provisionNode(hubUrl, username); + return hubUrl; + } + function provisionNode(hubUrl, username) { const configDir = meshbayConfigDir(); const dataDir = meshbayDataDir(); @@ -1741,15 +1859,17 @@ function registerBridge() { const configFile = nodeConfigPath(); if (fs.existsSync(configFile)) { const content = fs.readFileSync(configFile, 'utf8'); + // Functions, not strings, as replacements: `$&` in a string replacement + // is a pattern, and these values are not the code's own. const updated = content - .replace(/^url\s*=\s*"[^"]*"/m, `url = "${hubUrl}"`) - .replace(/^username\s*=\s*"[^"]*"/m, `username = "${username}"`); + .replace(/^url\s*=\s*"[^"]*"/m, () => `url = ${tomlString(hubUrl)}`) + .replace(/^username\s*=\s*"[^"]*"/m, () => `username = ${tomlString(username)}`); fs.writeFileSync(configFile, updated, { mode: 0o600 }); } else { const toml = [ '[hub]', - `url = "${hubUrl}"`, - `username = "${username}"`, + `url = ${tomlString(hubUrl)}`, + `username = ${tomlString(username)}`, '', '[node]', 'quic_enabled = false # QUIC direct path; no client uses it yet', @@ -1770,14 +1890,19 @@ function registerBridge() { } } - ipcMain.handle('node:start', async (_e, opts) => { + handle('node:start', async (_e, opts) => { const already = await probeNode(); if (already && already.status === 'running') { return { started: true, ...already }; } + // Provisioned only where a node can be started from here (below). + const startable = process.platform === 'win32' || process.platform === 'linux'; + const hubUrl = (startable && await provisionFromRequest(opts)) + || String(config.hubBase || ''); + const link = { token: opts && opts.token, hubUrl }; + if (process.platform === 'win32') { - if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username); // Restarted, not merely started: provisionNode() may just have pointed // the node at another hub or account, which it only reads at start. // The CLI stops whatever runs (in any session, gracefully first), starts @@ -1807,7 +1932,7 @@ function registerBridge() { ? p // 90s: a node caught in the hub's per-minute sign-in limit waits out // the rest of that minute plus its 10s back-off before trying again. - : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 90000); + : await linkNodeKeyAndAwaitRunning(link, Date.now() + 90000); if (!ready) { // It answered once and then stopped answering: it is not running, and // saying "started but could not link" sent the reader after the link. @@ -1830,10 +1955,6 @@ function registerBridge() { 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; @@ -1924,14 +2045,14 @@ function registerBridge() { // Daemon is up but stuck on hub auth — link the key so it can proceed. // Whatever it is waiting for, 'waiting_for_hub' included (see probeNode). - if (!keyLinked && opts && opts.token && result.pk_node_ed25519 && + if (!keyLinked && link.token && link.hubUrl && result.pk_node_ed25519 && result.status !== 'starting') { try { const lr = await fetch( - `${opts.hubUrl}/v1/users/me/node_key`, { + `${link.hubUrl}/v1/users/me/node_key`, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${opts.token}` }, + 'Authorization': `Bearer ${link.token}` }, body: JSON.stringify({ pk_node_ed25519: result.pk_node_ed25519 }), signal: AbortSignal.timeout(5000), @@ -1944,11 +2065,110 @@ function registerBridge() { 'meshbay-node was started but did not become ready within 60 seconds'); }); - ipcMain.handle('node:call', async (_e, method, apiPath, body) => { + // The local node's control API, by operation name. The page used to name a + // method and a path, which made every route of the loopback API -- present + // and future -- the page's to call with this process's token. Now it names + // one of the operations the interface performs, each with its arguments + // checked here, and the path is built here. Ids are ids and names are + // encoded, so no argument can reach another route. + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const anId = (v, what) => { + const s = String(v ?? ''); + if (!UUID_RE.test(s)) throw new Error(`Refused: ${what} is not an id`); + return s; + }; + const aText = (v, what, max = 255) => { + const s = String(v ?? ''); + if (!s || s.length > max) throw new Error(`Refused: ${what}`); + return s; + }; + const anObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {}); + const aCount = (v, fallback) => { + const n = Number(v); + return Number.isInteger(n) && n >= 0 ? n : fallback; + }; + const group = (a) => `/api/groups/${anId(a.groupId, 'the group')}`; + const root = (a) => `${group(a)}/roots/${encodeURIComponent(aText(a.rootName, 'the folder name'))}`; + + // Sharing a folder the person did not choose in this process's dialog. + async function confirmFolder(folder) { + if (!pickedFolders.has(path.resolve(folder))) { + await confirmOrRefuse('native.folder_confirm', { path: folder }); + } + } + + const NODE_OPS = { + status: () => ['GET', '/api/status'], + groups: () => ['GET', '/api/groups'], + indexStatus: () => ['GET', '/api/index-status'], + groupIndexStatus: (a) => ['GET', `${group(a)}/index-status`], + reload: () => ['POST', '/api/reload'], + attachGroup: async (a) => { + const body = { name: aText(a.name, 'the group name'), + shared_dir: aText(a.path, 'the folder', 4096), + writable: a.writable !== false }; + await confirmOrRefuse('native.attach_confirm', + { name: body.name, path: body.shared_dir }); + return ['POST', '/api/groups/attach', body]; + }, + detachGroup: (a) => ['POST', '/api/groups/detach', { name: aText(a.name, 'the group name') }], + addRoot: async (a) => { + const body = { path: aText(a.path, 'the folder', 4096) }; + if (a.name) body.name = aText(a.name, 'the folder name'); + if (a.writable !== undefined) body.writable = Boolean(a.writable); + if (a.removable !== undefined) body.removable = Boolean(a.removable); + const target = `${group(a)}/roots`; + await confirmFolder(body.path); + return ['POST', target, body]; + }, + updateRoot: (a) => ['PATCH', root(a), anObject(a.updates)], + ejectRoot: (a) => ['PUT', `${root(a)}/eject`], + plugRoot: (a) => ['PUT', `${root(a)}/plug`], + removeRoot: (a) => ['DELETE', root(a)], + // Creating a missing key replaces nothing -- the node keeps an existing one + // -- so only a rotation is asked about. + initGek: async (a) => { + const target = group(a); + if (a.rotate) { + await confirmOrRefuse('node.gek_rotate_confirm'); + return ['POST', `${target}/gek?rotate=true`]; + } + return ['POST', `${target}/gek`]; + }, + pairOperator: () => ['POST', '/api/operator/pair'], + roster: (a) => ['GET', a.groupId + ? `/api/roster?group_id=${anId(a.groupId, 'the group')}` : '/api/roster'], + unpinMember: (a) => ['POST', `/api/members/${anId(a.userId, 'the member')}/unpin`], + revokeMember: (a) => ['POST', `/api/members/${anId(a.userId, 'the member')}/revoke` + + `?group_id=${anId(a.groupId, 'the group')}`], + denylist: () => ['GET', '/api/denylist'], + // Re-admits whoever the entries were keeping out. + clearDenylist: async (a) => { + const subject = String(a.subject ?? '').slice(0, 255); + await confirmOrRefuse('node.denylist_clear_confirm', + { subject: subject || nativeText('node.denylist_clear_all') }); + return ['POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`]; + }, + setNodeSettings: (a) => ['PUT', '/api/node-settings', anObject(a.settings)], + peers: () => ['GET', '/api/peers'], + audit: (a) => { + let q = `limit=${aCount(a.limit, 50)}&offset=${aCount(a.offset, 0)}`; + if (a.event) q += `&event=${encodeURIComponent(aText(a.event, 'the event'))}`; + return ['GET', `/api/audit?${q}`]; + }, + indexCache: () => ['GET', '/api/index-cache'], + pruneIndexCache: () => ['POST', '/api/index-cache/prune'], + unlink: () => ['DELETE', '/api/unlink'], + }; + + handle('node:op', async (_e, name, args) => { + const op = Object.hasOwn(NODE_OPS, String(name)) ? NODE_OPS[String(name)] : null; + if (!op) throw new Error(`Refused: unknown node operation ${String(name)}`); if (!_nodeToken) throw new Error('Node not detected'); + const [method, apiPath, body] = await op(anObject(args)); const sep = apiPath.includes('?') ? '&' : '?'; const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`; - const init = { method: String(method).toUpperCase() }; + const init = { method }; if (body !== undefined && body !== null) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); @@ -1965,13 +2185,13 @@ function registerBridge() { return data; }); - ipcMain.handle('node:pairing-code', async () => { + handle('node:pairing-code', async () => { const code = _nodePairingCode; _nodePairingCode = null; return code; }); - ipcMain.handle('node:set-pairing-code', async (_e, code) => { + handle('node:set-pairing-code', async (_e, code) => { _nodePairingCode = code || null; return true; }); @@ -1983,7 +2203,7 @@ function registerBridge() { // 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) => { + handle('cast:start', async (_e, opts) => { return castRelay.start({ codec: opts.codec, initSegment: opts.initSegment ? Buffer.from(opts.initSegment) : null, @@ -1994,26 +2214,26 @@ function registerBridge() { // 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) => { + handle('cast:subtitle', async (_e, sub) => { return castRelay.setSubtitle(sub || null); }); - ipcMain.handle('cast:push', async (_e, data) => { + handle('cast:push', async (_e, data) => { castRelay.pushSegment(Buffer.from(data)); return true; }); - ipcMain.handle('cast:stop', async () => { + handle('cast:stop', async () => { await castRelay.stop(); return true; }); - ipcMain.handle('cast:finish', async () => { + handle('cast:finish', async () => { castRelay.finish(); return true; }); - ipcMain.handle('cast:status', async () => ({ + handle('cast:status', async () => ({ active: castRelay.active, url: castRelay.url, subtitle: castRelay.subtitle, @@ -2022,21 +2242,21 @@ function registerBridge() { // ── Chromecast discovery + control ────────────────────────────────────── - ipcMain.handle('cast:discover', async () => { + handle('cast:discover', async () => { return castChromecast.discover(); }); - ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { + 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 }) => { + handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => { return castChromecast.reload(mediaUrl, subtitle === undefined ? castRelay.subtitle : subtitle); }); - ipcMain.handle('cast:chromecast:disconnect', async () => { + handle('cast:chromecast:disconnect', async () => { await castChromecast.disconnect(); return true; }); |