/** * What differs between running in a browser and running as an installed app. * * The interface is the same code either way — that is the whole reason Electron * was chosen over a shell that replaces the engine * (docs/MESHBAY_DESIGN.md §8.2). What genuinely differs is small and lives * here: * * · **where the hub is.** Served from the hub, it is the current origin. Ship * the interface in a package and it becomes a configured URL, because the * page is loaded from disk and has no hub origin of its own. * · **where a downloaded file goes**, and whether a native dialog picks it. * · **what the app can do at all** — managing a local node, choosing folders * on this machine. Features gated on these render nowhere in a browser * rather than failing when clicked. * * The browser implementation below is exactly today's behaviour, so nothing * changes for anyone until an app is installed. That is the acceptance * criterion for this split: **the browser SPA behaves identically.** * * The native side arrives through `window.meshbay`, which the Electron preload * exposes over a context bridge. Absent, everything falls back to the browser * path — so this file is safe to load anywhere and there is no build flag. */ const bridge = (typeof window !== 'undefined' && window.meshbay) || null; export const isNative = Boolean(bridge); /** * The hub's base URL, prefixed to every API path. * * Empty string in a browser: the hub served this page, so a relative path goes * to the right place and no configuration can be wrong. In the app it is * whatever the user signed in against, and it is deliberately *not* guessed — * a client that picks its own hub is a client that can be pointed at one. */ export function hubBase() { return bridge ? (bridge.hubBase() || '') : ''; } /** * The hub's own address, as somebody else would type it — for a link that is * sent to another person, where "same origin" means nothing. In a browser the * hub served this page, so it is the page's origin; in the app it is the * configured hub. Decided here and nowhere else, like `hubBase()`. */ export function hubOrigin() { const base = hubBase(); return (base ? new URL(base).origin : window.location.origin); } /** Native-only capabilities. A browser renders none of what these gate. */ export const capabilities = { // Install, configure and drive a node running on this machine. nodeAdmin: Boolean(bridge && bridge.capabilities && bridge.capabilities.nodeAdmin), // Choose directories on this machine to share. localFolders: Boolean(bridge && bridge.capabilities && bridge.capabilities.localFolders), // A real save dialog and a write that does not pass through the page. nativeSave: Boolean(bridge && bridge.capabilities && bridge.capabilities.nativeSave), // Cast decrypted video to a device on the same LAN via a local HTTP relay. lanCast: Boolean(bridge && bridge.capabilities && bridge.capabilities.lanCast), // Hide the window to a system tray indicator. Declared per-OS by the app, not // by this file: a tray is only worth offering where the desktop actually shows // one, so the browser and any platform without support simply never see it. tray: Boolean(bridge && bridge.capabilities && bridge.capabilities.tray), }; /** * Where the identity keys live. * * In a browser: exactly where they live today — IndexedDB and sessionStorage, * with the keypair bundle on the node as the way a second browser recovers * them, which is finding C4 and is the reason the app exists. * * In the app: the OS keychain, and no bundle is stored anywhere. That is what * closes C4 for a native device — unconditionally for that device, and for the * account only once it stops signing in from a browser too. */ export const secrets = { available: Boolean(bridge && bridge.secrets), async get(name) { if (!bridge || !bridge.secrets) return null; return bridge.secrets.get(name); }, async set(name, value) { if (!bridge || !bridge.secrets) return false; return bridge.secrets.set(name, value); }, async clear(name) { if (!bridge || !bridge.secrets) return false; return bridge.secrets.clear(name); }, /** * Whether the OS is really protecting them. * * Electron's safeStorage falls back to a fixed key when no keyring is * running — a headless session, a minimal desktop — and silently. A user who * believes their keys are protected by the OS deserves to be told when they * are not, so this is surfaced rather than swallowed. */ async backend() { if (!bridge || !bridge.secrets) return 'browser'; return bridge.secrets.backend(); }, }; /** * This device's key for signing in to the hub. * * Ed25519, generated and held by the main process — the interface asks for a * signature and never sees a key. The passphrase is still the account's * credential and its only recovery path; this is what saves deriving a key from * it on every launch. * * Not a per-node identity key. Those are generated per node, pinned there, and * never leave that relationship: nothing here correlates a person across * operators, and nothing wraps a group key for it. * * Absent in a browser, where a passphrase is entered every time and the * keypair bundle on each node is what a second browser recovers — which is * finding C4, and the reason the application exists. */ /** * The sentence out of a bridge error. * * Electron wraps anything thrown in the main process as * "Error invoking remote method 'hub:set': Error: …", which puts plumbing in * front of a message written for a person. The message is the part that was * written for them. */ export function bridgeMessage(err) { const raw = (err && err.message) || String(err); const m = raw.match(/^Error invoking remote method '[^']*':\s*(?:\w*Error:\s*)?(.*)$/s); return m ? m[1] : raw; } export const device = { available: Boolean(bridge && bridge.device), async ensure() { return bridge && bridge.device ? bridge.device.ensure() : null; }, async publicKey() { return bridge && bridge.device ? bridge.device.publicKey() : null; }, /** `{timestamp, signature}` over `meshbay:user_auth::`. */ async sign(username) { return bridge && bridge.device ? bridge.device.sign(username) : null; }, async forget() { return bridge && bridge.device ? bridge.device.forget() : false; }, }; /** * Call the hub. * * In a browser this is `fetch`, unchanged — the page came from the hub, so the * request is same-origin and nothing is in the way. * * In the application 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**, and * that is worth keeping: its API is reachable from no web origin whatever. * Widening it for `app://meshbay` would be worse than it appears, because that * origin is not a credential — any Electron application can claim the same * scheme and host name. * * So the main process makes the call. It returns a small object rather than a * Response, and this shapes it back into something with `.ok`, `.status` and * `.json()`, so callers do not have to know which one they got. */ export async function apiFetch(url, init) { if (!bridge || !bridge.fetch) return fetch(url, init); const raw = await bridge.fetch(String(url), init && { method: init.method, headers: init.headers, body: init.body, }); return { ok: raw.ok, status: raw.status, statusText: String(raw.status), headers: new Headers(raw.headers || {}), text: async () => raw.body, json: async () => JSON.parse(raw.body), }; } /** * Save a decrypted file to disk. * * Returns null when there is no native path, so the caller keeps today's * behaviour — File System Access, a service worker stream, or a blob, decided * in `downloads.js`. Adding a native writer must not remove the three that * already work. */ export async function nativeSave(suggestedName, { auto = true } = {}) { if (!bridge || !bridge.saveFile) return null; const sink = await bridge.saveFile(suggestedName, { auto }); if (!sink) return null; // The shape every caller already expects from a download target: a `writable` // with write/close/abort, and the name it was actually given on disk. return { name: sink.name, writable: { write: (bytes) => sink.write(bytes), close: () => sink.close(), abort: () => sink.abort(), }, open: sink.open ? () => sink.open() : null, }; } /** Where downloads go on a desktop build. Null in a browser. */ export const folder = { available: Boolean(bridge && bridge.folder), async choose() { return bridge && bridge.folder ? bridge.folder.choose() : null; }, async get() { return bridge && bridge.folder ? bridge.folder.get() : null; }, async forget() { return bridge && bridge.folder ? bridge.folder.forget() : false; }, }; /** Pick a directory to add as a group root. Returns { path, name } or null. */ export const rootPicker = { available: Boolean(bridge && bridge.rootPicker), async choose() { return bridge && bridge.rootPicker ? bridge.rootPicker.choose() : null; }, }; /** * The local node, if one is running on this machine. * * Detection probes `127.0.0.1:{ui_port}` with the session token read from the * daemon's data directory. The renderer never sees the token — it names an * operation and the main process executes it, the same trust model as hub:fetch. * * In a browser all calls resolve to a "not available" result, so the interface * can gate features on `node.available` without a build flag. */ export const node = { available: Boolean(bridge && bridge.node), async detect() { return bridge && bridge.node ? bridge.node.detect() : { detected: false }; }, async installed() { return bridge && bridge.node ? bridge.node.installed() : { installed: false }; }, /** * Does THIS build ship its own node (Full) or not (Light)? Distinct from * `installed`, which also counts one merely found on PATH. A browser has * no bridge at all, so it resolves false the same as `available` does -- * create-group-page.js already falls back to the node-free form for that * case, and Light should take the same fallback. */ async bundled() { return bridge && bridge.node ? bridge.node.bundled() : false; }, async start(opts) { if (!bridge || !bridge.node) throw new Error('Node bridge not available'); return bridge.node.start(opts); }, async call(method, path, body) { if (!bridge || !bridge.node) throw new Error('Node bridge not available'); return bridge.node.call(method, path, body); }, async pairingCode() { return bridge && bridge.node ? bridge.node.pairingCode() : null; }, async setPairingCode(code) { return bridge && bridge.node ? bridge.node.setPairingCode(code) : false; }, /** * The systemd unit's own state — start, stop and restart the node as a * service, independent of whether the daemon itself is answering. Absent * in a browser, same as the rest of `node`. */ service: { available: Boolean(bridge && bridge.node && bridge.node.service), async status() { if (!bridge || !bridge.node || !bridge.node.service) return { supported: false }; return bridge.node.service.status(); }, async stop() { if (!bridge || !bridge.node || !bridge.node.service) throw new Error('Node bridge not available'); return bridge.node.service.stop(); }, async restart() { if (!bridge || !bridge.node || !bridge.node.service) throw new Error('Node bridge not available'); return bridge.node.service.restart(); }, }, /** * Windows only: run the daemon at sign-in. `service.status()` already * reports the current state as `autostart`; these two just flip it. * `available` gates nothing platform-specific by itself — main.js answers * `{ supported: false }` off Windows, same shape as `service`. */ autostart: { available: Boolean(bridge && bridge.node && bridge.node.autostart), async install() { if (!bridge || !bridge.node || !bridge.node.autostart) throw new Error('Node bridge not available'); return bridge.node.autostart('install'); }, async remove() { if (!bridge || !bridge.node || !bridge.node.autostart) throw new Error('Node bridge not available'); return bridge.node.autostart('remove'); }, }, /** * Windows only: switch INTO or OUT OF service mode after install — the * installer's own radio page (build/installer.nsh) only runs once, at * install time, so this is the only way back in if a different mode was * chosen there and is no longer wanted. One elevation, task + firewall * together — same script the installer runs. */ serviceMode: { available: Boolean(bridge && bridge.node && bridge.node.serviceMode), async install() { if (!bridge || !bridge.node || !bridge.node.serviceMode) throw new Error('Node bridge not available'); return bridge.node.serviceMode('install'); }, async remove() { if (!bridge || !bridge.node || !bridge.node.serviceMode) throw new Error('Node bridge not available'); return bridge.node.serviceMode('remove'); }, }, }; /** * Poll a group's initial-scan progress on the local node (loopback), until * it reports it is no longer scanning. For "add a directory" in Settings, * where the group is already hosted — index-status is meaningful the whole * time. NOT for a brand-new group the wizard just attached: see * waitForGroupHosted below for why that needs a different exit condition. * * `onUpdate` is called with each {scanning, scanned_bytes, total_bytes, * current_dir} snapshot, including the final one where scanning is false. */ export async function watchIndexProgress(groupId, onUpdate, { intervalMs = 500 } = {}) { for (;;) { let status; try { status = await node.call('GET', `/api/groups/${groupId}/index-status`); } catch { // The node went away mid-poll — stop rather than spin forever; the // caller's own connection-status handling already covers that case. return; } onUpdate(status); if (!status.scanning) return; await new Promise(resolve => setTimeout(resolve, intervalMs)); } } /** * Create Group wizard only: wait for a brand-new group to actually become * usable on the node, showing index-status along the way. * * Not the same wait as watchIndexProgress above. `/api/reload` returns as * soon as the reload is scheduled (ops.start_reload) — before the node has * even created an indexer for the group, let alone started scanning. A * naive "poll index-status until scanning is false" would see the default * idle answer on that very first poll and return immediately, and every * group-scoped call after it (add a root, init the GEK) would still 404 * with "not configured"/"not hosted" for as long as the real scan actually * takes. The only answer that means "safe to proceed" is the group * genuinely appearing in /api/groups (groups_ctx, daemon.py) — index-status * is read purely for the progress bar. */ export async function waitForGroupHosted(groupId, onProgress, { intervalMs = 500, timeoutMs = 30 * 60 * 1000 } = {}) { const deadline = Date.now() + timeoutMs; for (;;) { try { const status = await node.call('GET', `/api/groups/${groupId}/index-status`); if (onProgress) onProgress(status); } catch { /* keep waiting — the loopback API can be momentarily busy */ } try { const list = await node.call('GET', '/api/groups'); if (Array.isArray(list.groups) && list.groups.some((g) => g.id === groupId)) return; } catch { /* keep waiting */ } if (Date.now() > deadline) { throw new Error('The node did not finish attaching this group in time'); } await new Promise((r) => setTimeout(r, intervalMs)); } } /** * Create Group wizard only: after adding extra roots (step 5), wait for * whatever scanning that triggers to actually finish, showing progress along * the way. `POST /api/groups/{id}/roots` (ui/app.py) schedules its rescan as * a detached background task and returns as soon as the config write is * done — "every add-root call resolved" is not "the node is done indexing". * Found live (2026-08-25): a multi-root group's later, larger roots kept * scanning for minutes after the wizard had already moved on to GEK init and * pairing, with no progress shown anywhere — the disk was working, the UI * just never asked again. * * Same race as waitForGroupHosted above, one level down: the very first * poll can land in the gap between "the last add-root call returned" and * "its background reload actually started scanning", which reads as "not * scanning" for the wrong reason (nothing left to do) rather than the right * one (hasn't started yet). Waits up to `graceMs` for scanning to be * observed at least once before trusting a "not scanning" answer — after * that, the first "not scanning" really does mean finished, because the * node scans one root at a time (indexer.py's single-worker executor) and * nothing here adds more roots once this call starts. */ export async function waitForRootsIndexed(groupId, onProgress, { intervalMs = 500, graceMs = 5000 } = {}) { const graceDeadline = Date.now() + graceMs; let sawScanning = false; for (;;) { let status; try { status = await node.call('GET', `/api/groups/${groupId}/index-status`); } catch { return; // the node went away mid-poll — same stance as watchIndexProgress } if (onProgress) onProgress(status); if (status.scanning) sawScanning = true; if (sawScanning && !status.scanning) return; if (!sawScanning && Date.now() > graceDeadline) return; await new Promise((r) => setTimeout(r, intervalMs)); } } /** * LAN cast relay — re-serve decrypted video segments over HTTP so a * Chromecast or Smart TV on the same Wi-Fi can play the stream. * * Absent in a browser, where the relay cannot run: there is no main process * to bind a server socket in, and a page cannot open one. */ export const cast = { available: Boolean(bridge && bridge.cast), async start(opts) { if (!bridge || !bridge.cast) return null; return bridge.cast.start(opts); }, async push(data) { if (!bridge || !bridge.cast) return false; return bridge.cast.push(data); }, async stop() { if (!bridge || !bridge.cast) return false; return bridge.cast.stop(); }, async subtitle(sub) { if (!bridge || !bridge.cast || !bridge.cast.subtitle) return null; return bridge.cast.subtitle(sub); }, async finish() { if (!bridge || !bridge.cast) return false; return bridge.cast.finish(); }, async status() { if (!bridge || !bridge.cast) return null; return bridge.cast.status(); }, async discover() { if (!bridge || !bridge.cast) return []; return bridge.cast.discover(); }, async chromecastConnect(opts) { if (!bridge || !bridge.cast) return null; return bridge.cast.chromecastConnect(opts); }, async chromecastReload(opts) { if (!bridge || !bridge.cast) return null; return bridge.cast.chromecastReload(opts); }, async chromecastDisconnect() { if (!bridge || !bridge.cast) return false; return bridge.cast.chromecastDisconnect(); }, }; /** * Hide the window to the system tray indicator. * * A no-op without the bridge, so the same nav renders in a browser without a * guard at the call site -- though `capabilities.tray` keeps the button itself * out of a browser, where there is no tray to minimise into. */ export async function minimizeToTray(labels) { if (!bridge || !bridge.minimizeToTray) return false; return bridge.minimizeToTray(labels); } /** * Translate the tray menu the app created at launch. * * The main process has no i18n -- a second string table is how two of them * start disagreeing -- so the labels come from here, once the catalogue has * loaded. A no-op in a browser and on macOS, where there is no indicator. */ export async function setTrayLabels(labels) { if (!bridge || !bridge.setTrayLabels) return false; return bridge.setTrayLabels(labels); } export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, bridgeMessage, folder, rootPicker, node, cast, minimizeToTray, setTrayLabels }; // Also a global, because `transport.js` is loaded as a classic script — it // predates the module graph and exposes `MeshBayTransport` the same way. The // alternative was a second fetch path there, which is how two callers of one // hub end up disagreeing about how to reach it. if (typeof window !== 'undefined') { window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, bridgeMessage, folder, rootPicker, node, cast, minimizeToTray, setTrayLabels }; }