1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
|
/**
* 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),
// 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:<username>:<ts>`. */
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 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 };
}
|