/** * The bridge, and the whole of it. * * `contextIsolation` puts this in its own world, so what is exposed here is all * the page can reach — page script cannot read the closure, cannot replace * these functions for other code, and cannot call an IPC channel that is not * named below. That is what makes the enumeration meaningful rather than * decorative. * * The rule for anything added here: **the renderer never names a path, a file * handle or a process.** It asks for a dialog and receives an opaque id. The * renderer parses decrypted content from nodes — video, images, filenames — * which is attacker-controlled input, so it is treated as hostile even though * it is our own code. */ 'use strict'; const { contextBridge, ipcRenderer } = require('electron'); const HUB_BASE = (process.argv.find(a => a.startsWith('--meshbay-hub=')) || '') .slice('--meshbay-hub='.length); contextBridge.exposeInMainWorld('meshbay', { // Where the hub is. The interface prefixes every API path with this; in a // browser the same function returns '' and relative paths go to the origin // that served the page. // // Read from a process argument, not over IPC: the interface asks for this // while its modules are still loading, before anything can await, and // synchronous IPC would block the renderer for a value that cannot change // within a run. hubBase: () => HUB_BASE, setHubBase: (base) => ipcRenderer.invoke('hub:set', base), // What this build can do that a browser cannot. The interface renders // features gated on these nowhere at all in a browser, rather than offering // something that fails when clicked. capabilities: { nodeAdmin: true, localFolders: true, nativeSave: true, lanCast: true, // Linux and Windows have a tray to hide into; declared per platform // rather than always, so the button never appears somewhere the desktop // shows no indicator, which would hide the window for good. tray: process.platform === 'linux' || process.platform === 'win32', }, // Labels are passed in because the main process has no i18n; see main.js. minimizeToTray: (labels) => ipcRenderer.invoke('window:minimize-to-tray', labels), // The tray now exists from launch, so its menu needs translating before // anyone minimises into it. Sent once the interface has its catalogue, and // again after a language change. setTrayLabels: (labels) => ipcRenderer.invoke('tray:labels', labels), // The interface's language, so the confirmations the main process draws for // itself are worded in it. A code, never text: the words are read from the // packaged catalogues by the main process. setLocale: (code) => ipcRenderer.invoke('ui:locale', code), // Ask the main process to call the hub. The renderer has an `app://` origin, // which CORS refuses and which is not a credential anyway. fetch: (url, init) => ipcRenderer.invoke('hub:fetch', url, init), // Resolve STUN `stun:host:port` URLs to `stun:ip:port` using Node's resolver. // Chromium's P2P socket manager fails STUN hostnames outright in some // restricted-resolver environments; the renderer cannot do DNS, so it asks // here. Unresolvable entries are dropped. resolveStun: (urls) => ipcRenderer.invoke('ice:resolve-stun', urls), // The device's hub key. Generated, held and used entirely in the main // process: the interface asks for a signature and never sees a key, because // it is the part of this application that parses hostile input. device: { ensure: () => ipcRenderer.invoke('device:ensure'), publicKey: () => ipcRenderer.invoke('device:public'), sign: (username) => ipcRenderer.invoke('device:sign', username), forget: () => ipcRenderer.invoke('device:forget'), }, // The account's bundle key and its identity on every node (keyring.js). // Held in the main process: the interface is told public keys and handed // signatures and agreements, never a private key or the key that opens // bundles. Bytes cross as base64. keys: { available: () => ipcRenderer.invoke('keys:available'), deriveSession: (o) => ipcRenderer.invoke('keys:derive-session', o), commitPending: (u) => ipcRenderer.invoke('keys:commit-pending', u), dropPending: (u) => ipcRenderer.invoke('keys:drop-pending', u), hasSession: (u) => ipcRenderer.invoke('keys:has-session', u), forgetSession: (u) => ipcRenderer.invoke('keys:forget-session', u), identity: (u, n) => ipcRenderer.invoke('keys:identity', u, n), openBundle: (u, n, o) => ipcRenderer.invoke('keys:open-bundle', u, n, o), mint: (u, n) => ipcRenderer.invoke('keys:mint', u, n), sealBundle: (u, n, o) => ipcRenderer.invoke('keys:seal-bundle', u, n, o), sealRecovery: (u, n, m, name) => ipcRenderer.invoke('keys:seal-recovery', u, n, m, name), markSealed: (u, n, fp) => ipcRenderer.invoke('keys:mark-sealed', u, n, fp), fingerprint: (u) => ipcRenderer.invoke('keys:fingerprint', u), sign: (u, n, bytes) => ipcRenderer.invoke('keys:sign', u, n, bytes), shared: (u, n, peer) => ipcRenderer.invoke('keys:shared', u, n, peer), playlistKey: (u) => ipcRenderer.invoke('keys:playlist-key', u), browserAccess: (u) => ipcRenderer.invoke('keys:browser-access', u), setBrowserAccess: (u, on) => ipcRenderer.invoke('keys:set-browser-access', u, on), createdHere: (u) => ipcRenderer.invoke('keys:created-here', u), }, // Whether the OS protects what the main process stores. The store itself is // not reachable from here: it holds the device key above. secrets: { // 'unprotected_fallback' means safeStorage found no keyring and is using a // fixed key. Encrypted on disk, by a key that is not a secret — the // interface says so rather than letting someone believe otherwise. backend: () => ipcRenderer.invoke('secrets:backend'), }, // Where downloads go, chosen once. The renderer never sees or sends a path — // it asks for a dialog and is told the folder's name for display only. folder: { choose: () => ipcRenderer.invoke('folder:choose'), get: () => ipcRenderer.invoke('folder:get'), forget: () => ipcRenderer.invoke('folder:forget'), }, // Pick a directory to share as a group root. Returns { path, name } — the // path is forwarded over MNP to the node, which is the local machine for D5. rootPicker: { choose: () => ipcRenderer.invoke('root:choose'), }, // The local node, if one is running. The renderer never sees the session // token, and never names a route: it names one of the operations the // interface performs, the main process checks its arguments, builds the // request and confirms natively what widens what the node shares. node: { detect: () => ipcRenderer.invoke('node:detect'), installed: () => ipcRenderer.invoke('node:installed'), // Whether THIS build ships its own node (Full) or not (Light) -- // distinct from `installed`, which also counts one merely found on // PATH. Windows only; other platforms always resolve true. bundled: () => ipcRenderer.invoke('node:bundled'), start: (opts) => ipcRenderer.invoke('node:start', opts), op: (name, args) => ipcRenderer.invoke('node:op', name, args), pairingCode: () => ipcRenderer.invoke('node:pairing-code'), setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), // The daemon's lifecycle as seen from outside it: the systemd unit (Linux) // or, on Windows, a probe of the daemon plus whether the Startup launcher // is in place — reachable even while the daemon itself is stopped or // crash-looping, which `op()` above is not. service: { status: () => ipcRenderer.invoke('node:service-status'), stop: () => ipcRenderer.invoke('node:service-stop'), restart: () => ipcRenderer.invoke('node:service-restart'), }, // Windows only: the "run at every sign-in" Startup-folder launcher. // action: 'install' | 'remove' | 'status' (default). Elsewhere returns // { supported: false }. autostart: (action) => ipcRenderer.invoke('node:autostart', action), // Windows only: switch into/out of the boot-time service (Scheduled Task // + firewall, one elevation). action: 'install' | 'remove'. Elsewhere // returns { supported: false }. serviceMode: (action) => ipcRenderer.invoke('node:service-mode', action), }, // LAN cast relay. The main process runs a local HTTP server and the // renderer feeds it decrypted segments. A Chromecast or Smart TV on the // same Wi-Fi plays from the URL. cast: { start: (opts) => ipcRenderer.invoke('cast:start', opts), push: (data) => ipcRenderer.invoke('cast:push', data), stop: () => ipcRenderer.invoke('cast:stop'), subtitle: (sub) => ipcRenderer.invoke('cast:subtitle', sub), finish: () => ipcRenderer.invoke('cast:finish'), status: () => ipcRenderer.invoke('cast:status'), discover: () => ipcRenderer.invoke('cast:discover'), chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), }, // A sink that writes to disk as chunks arrive, never a buffer handed over at // the end. `auto` uses the remembered folder without a dialog, which is what // "save automatically" means; without one, or when the person asked to be // prompted, a dialog opens. The renderer holds an id, not a path. saveFile: async (suggestedName, opts) => { const handle = await ipcRenderer.invoke('save:begin', suggestedName, opts); if (!handle) return null; return { name: handle.name, write: (chunk) => ipcRenderer.invoke('save:write', handle.id, chunk), close: () => ipcRenderer.invoke('save:end', handle.id), abort: () => ipcRenderer.invoke('save:abort', handle.id), open: () => ipcRenderer.invoke('save:open', handle.id), }; }, });