/** * 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/desktop-client-v1.md * §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() || '') : ''; } /** 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), }; /** * 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, size) { if (!bridge || !bridge.saveFile) return null; return bridge.saveFile(suggestedName, size); } export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, bridgeMessage }; // 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 }; }