summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
blob: 22e0162e7de3d83d578b26f0b46b4964151876e0 (plain) (blame)
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
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
/**
 * MeshBay desktop client — the Electron main process.
 *
 * The reason this exists, stated once so nobody has to rediscover it: **the
 * interface ships inside this package and loads from disk.** A shell that
 * points a WebView at the hub's /app/ is a browser with a different icon and
 * fixes nothing — the hub could still send whatever code it liked, which is
 * finding T3. The hub is used for its API and for nothing else.
 *
 * What that does and does not buy is worth being honest about: it does not make
 * the hub untrusted. A build downloaded from meshbay.org and signed with a key
 * its operator holds relocates the trust rather than removing it. What changes
 * is **detectability** — an attack has to ship as an artifact that can be
 * hashed and compared, instead of being one HTTP response aimed at one person.
 * That value is realised by reproducible builds and published hashes (18.7),
 * not by the packaging format.
 *
 * See docs/desktop-client-v1.md §2 and §3.
 */

'use strict';

const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } =
  require('electron');
const { execFile, spawn } = require('node:child_process');
const crypto = require('node:crypto');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { pathToFileURL } = require('node:url');

const UI_DIR = path.join(__dirname, '..', 'ui');
const SCHEME = 'app';

// The policy, sent as a header on every response.
//
// Not a <meta> tag: `frame-ancestors` is ignored there — Chromium says so in
// the console — and a policy with a directive that silently does nothing is
// worse than one without it. A header is authoritative for every directive,
// and this handler is the only thing that serves the interface, so there is one
// source rather than two that can drift.
//
// `'wasm-unsafe-eval'` is required and must stay: the bundle key is Argon2id in
// WebAssembly, and a policy without it does not degrade anything — it locks
// every user out of their keys.
//
// The hub is reachable under connect-src, for its API and its signaling socket.
// It is deliberately absent from script-src: nothing it returns is executed,
// which is the whole reason this application exists (T3).
const CSP = [
  "default-src 'none'",
  "script-src 'self' 'wasm-unsafe-eval'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: blob:",
  "media-src 'self' blob:",
  "font-src 'self'",
  "connect-src 'self' https: wss:",
  "worker-src 'self'",
  "frame-ancestors 'none'",
  "base-uri 'none'",
  "form-action 'none'",
].join('; ');

// ── The scheme the interface is served from ─────────────────────────────────
//
// Not file://. Service workers, ES modules and IndexedDB all misbehave there,
// and the streamed-download path needs a *controlled* page — a worker that is
// merely active is not enough, which this codebase has already learned once.
//
// `secure` is what makes it a secure context, and without it the whole of
// `crypto.subtle` is undefined — measured on Electron 42 / Chromium 148, where
// a page on a non-secure scheme failed every algorithm including AES-GCM.
// `standard` gives it a real origin, so IndexedDB survives an update instead of
// being keyed to something that moves.
//
// What it does NOT buy: service workers. Chromium refuses to register one on a
// custom scheme whatever its privileges ("The URL protocol of the current
// origin is not supported"). So `sw.js` never runs here, and the streamed
// download it exists for is replaced by the native save dialog below — which is
// the better path anyway. It stays in the package because the same files serve
// the browser, where it is one of only three ways to write a large file.
protocol.registerSchemesAsPrivileged([{
  scheme: SCHEME,
  privileges: {
    standard: true,
    secure: true,
    supportFetchAPI: true,
    stream: true,          // Range requests, for playing a film
    corsEnabled: true,
  },
}]);

/**
 * Serve the packaged interface, and refuse to leave it.
 *
 * Every path is resolved and checked against the UI directory before anything
 * is read: the renderer is the least trusted part of this process, and a
 * traversal here would hand it the user's filesystem.
 */
function registerUiProtocol() {
  protocol.handle(SCHEME, async (request) => {
    const url = new URL(request.url);
    const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html';
    const target = path.resolve(UI_DIR, rel);
    const root = path.resolve(UI_DIR);
    if (target !== root && !target.startsWith(root + path.sep)) {
      return new Response('Not found', { status: 404 });
    }
    try {
      const body = await fsp.readFile(target);
      return new Response(body, {
        headers: {
          'Content-Type': contentType(target),
          'Content-Security-Policy': CSP,
          'X-Content-Type-Options': 'nosniff',
        },
      });
    } catch {
      return new Response('Not found', { status: 404 });
    }
  });
}

function contentType(file) {
  const ext = path.extname(file).toLowerCase();
  return {
    '.html': 'text/html; charset=utf-8',
    '.js': 'text/javascript; charset=utf-8',
    '.mjs': 'text/javascript; charset=utf-8',
    '.css': 'text/css; charset=utf-8',
    '.json': 'application/json; charset=utf-8',
    '.wasm': 'application/wasm',
    '.svg': 'image/svg+xml',
    '.png': 'image/png',
    '.woff2': 'font/woff2',
  }[ext] || 'application/octet-stream';
}

// ── Configuration ───────────────────────────────────────────────────────────

function configPath() {
  return path.join(app.getPath('userData'), 'config.json');
}

function readConfig() {
  try {
    return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
  } catch {
    // No default hub. A client that picks its own is a client that can be
    // pointed at one, so the first run asks and the answer is remembered.
    return { hubBase: '', window: null };
  }
}

function writeConfig(next) {
  const dir = path.dirname(configPath());
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
}

let config = readConfig();

// ── Secrets ─────────────────────────────────────────────────────────────────
//
// The OS keychain, through safeStorage. What it protects and what it does not
// is reported rather than assumed: on Linux, safeStorage falls back to a fixed
// key when no keyring is running — a headless session, a minimal desktop — and
// it does so silently. Someone who believes the OS is holding their keys should
// be told when it is not.

function secretsFile() {
  return path.join(app.getPath('userData'), 'secrets.bin');
}

function readSecrets() {
  try {
    const raw = fs.readFileSync(secretsFile());
    if (!safeStorage.isEncryptionAvailable()) return {};
    return JSON.parse(safeStorage.decryptString(raw));
  } catch {
    return {};
  }
}

function writeSecrets(all) {
  if (!safeStorage.isEncryptionAvailable()) {
    throw new Error('No OS key storage is available on this system');
  }
  fs.mkdirSync(path.dirname(secretsFile()), { recursive: true });
  fs.writeFileSync(secretsFile(), safeStorage.encryptString(JSON.stringify(all)),
                   { mode: 0o600 });
}

function secretsBackend() {
  if (!safeStorage.isEncryptionAvailable()) return 'unavailable';
  if (process.platform !== 'linux') return process.platform === 'darwin'
    ? 'keychain' : 'dpapi';
  const backend = safeStorage.getSelectedStorageBackend
    ? safeStorage.getSelectedStorageBackend() : 'unknown';
  // "basic_text" is Electron's fixed-key fallback: encrypted on disk, but by a
  // key that is not a secret. Named plainly so the interface can say so.
  return backend === 'basic_text' ? 'unprotected_fallback' : backend;
}

// ── The device's hub key ────────────────────────────────────────────────────
//
// Ed25519, generated here on first sign-in, registered with the hub, and used
// from then on instead of deriving a key from the passphrase every time. The
// passphrase remains the account's credential and its only recovery path.
//
// **The renderer never holds it.** It parses decrypted content from nodes —
// video, images, filenames — which is attacker-controlled input, so it asks for
// a signature rather than being handed a key it could leak. This is the same
// rule as the save dialog: the renderer asks, this process acts.
//
// Note what this key is *not*: it is not a per-node identity key. Those are
// generated per node, pinned there, and never leave that relationship
// (docs/per-node-identity-v1.md). Nothing here correlates a person across
// operators, and nothing wraps a group key for it.

const DEVICE_KEY = 'device_auth_ed25519';

function deviceKey() {
  const stored = readSecrets()[DEVICE_KEY];
  if (!stored) return null;
  return crypto.createPrivateKey({
    key: Buffer.from(stored, 'base64'), format: 'der', type: 'pkcs8',
  });
}

function ensureDeviceKey() {
  const existing = deviceKey();
  if (existing) return publicKeyB64(existing);
  const { privateKey } = crypto.generateKeyPairSync('ed25519');
  const all = readSecrets();
  all[DEVICE_KEY] = privateKey.export({ format: 'der', type: 'pkcs8' })
    .toString('base64');
  writeSecrets(all);
  return publicKeyB64(crypto.createPrivateKey({
    key: Buffer.from(all[DEVICE_KEY], 'base64'), format: 'der', type: 'pkcs8' }));
}

function publicKeyB64(privateKey) {
  // Raw 32 bytes, as the hub stores and as `pk_to_b64` produces: the DER
  // SubjectPublicKeyInfo for Ed25519 is a fixed 12-byte prefix and the key.
  const der = crypto.createPublicKey(privateKey)
    .export({ format: 'der', type: 'spki' });
  return der.subarray(der.length - 32).toString('base64');
}

// Everything the interface is allowed to ask Chromium for. Watching a film
// full-screen is the whole list.
const GRANTED_PERMISSIONS = new Set(['fullscreen']);

// No hub call is still going to be answered after this. The hub's longest is
// signaling a WebRTC offer, which gives up at fifteen seconds of its own.
const HUB_FETCH_TIMEOUT_MS = 30000;

// ── Window ──────────────────────────────────────────────────────────────────

let mainWindow = null;

function createWindow() {
  const bounds = (config.window && config.window.width) ? config.window : {
    width: 1200, height: 800,
  };

  const win = new BrowserWindow({
    ...bounds,
    minWidth: 320,
    show: false,
    webPreferences: {
      // The three that matter. `sandbox` keeps the Chromium renderer sandbox —
      // the strongest one available, and the reason Electron is not the
      // trade-off the earlier design recorded against "native". Without
      // `contextIsolation` the preload's objects are reachable and mutable from
      // page script, which would make the bridge below decorative.
      sandbox: true,
      contextIsolation: true,
      nodeIntegration: false,
      preload: path.join(__dirname, 'preload.js'),
      // The hub address, handed over as a process argument rather than fetched.
      // `platform.hubBase()` runs while the module graph is loading — before
      // anything can await — so it has to be synchronous, and synchronous IPC
      // would block the renderer on every call for a value that never changes
      // within a run. Changing it restarts the window.
      additionalArguments: [`--meshbay-hub=${config.hubBase || ''}`],
      // The page is loaded over app:// and talks to the hub over https. Neither
      // needs to reach the local filesystem.
      webSecurity: true,
    },
  });

  win.once('ready-to-show', () => win.show());

  // The hub must never become the document origin. Anything that would navigate
  // away from the packaged interface is refused, and an external link opens in
  // the user's own browser rather than inside a window holding their keys.
  const isOurs = (target) => {
    try { return new URL(target).protocol === `${SCHEME}:`; } catch { return false; }
  };
  win.webContents.on('will-navigate', (event, target) => {
    if (!isOurs(target)) event.preventDefault();
  });
  win.webContents.setWindowOpenHandler(({ url }) => {
    if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url);
    return { action: 'deny' };
  });
  // Deny by default, with one exception, and the exception is the point of the
  // comment. A blanket `callback(false)` here is what stopped a film going
  // fullscreen: Chromium's own video controls ask for `fullscreen`, and a
  // **denial does not reject** — `requestFullscreen()` returns a promise that
  // never settles, so the button simply does nothing and there is no error
  // anywhere to find. Measured, not deduced: the probe reported `NEVER SETTLED`
  // and the main process logged `PERMISSION ASKED: fullscreen`.
  //
  // So: enumerate what is granted rather than what is refused. A camera, a
  // microphone, a location, notifications and MIDI are all still refused, and
  // anything Chromium adds later arrives refused rather than quietly allowed.
  win.webContents.session.setPermissionRequestHandler(
    (_wc, permission, callback) => callback(GRANTED_PERMISSIONS.has(permission)));
  // `Permissions.query` takes the other handler; same answer, or the two can
  // disagree about what the page is allowed to do.
  win.webContents.session.setPermissionCheckHandler(
    (_wc, permission) => GRANTED_PERMISSIONS.has(permission));

  win.on('close', () => {
    if (!win.isMinimized() && !win.isFullScreen()) {
      config = { ...config, window: win.getBounds() };
      writeConfig(config);
    }
  });

  // `win`, not `mainWindow`. Changing the hub closes one window and opens
  // another, and `closed` arrives *after* the replacement has been assigned —
  // so a handler reading the module variable nulls out the new window, and the
  // new window's `ready-to-show` then crashes on it. Every handler here belongs
  // to the window it was created with, and only the current one may clear the
  // reference.
  win.on('closed', () => { if (mainWindow === win) mainWindow = null; });

  mainWindow = win;
  win.loadURL(`${SCHEME}://meshbay/index.html`);
  return win;
}

// ── Bridge ──────────────────────────────────────────────────────────────────
//
// Everything the interface may ask of this process, enumerated. A handler that
// takes a path from the renderer and acts on it is the shape to avoid: the
// renderer parses decrypted content from nodes, which is attacker-controlled
// input, so it is treated as hostile even though it is our own code.

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) => {
    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
      // on their own machine. Anywhere else it would put the session token on
      // the wire in clear.
      throw new Error('The hub address must be https');
    }
    // Ask the hub whether it is one, before writing the address down.
    //
    // Without this the first-run screen accepts anything shaped like a URL and
    // the application is then broken with no way back — there was no way to
    // change the hub once it was set, so a typo meant editing a JSON file by
    // hand. `https` typed at an `http` hub is the obvious case and it fails
    // with a TLS error that says nothing to anyone.
    let version;
    try {
      const probe = await fetch(`${url}/v1/hub/version`,
                                { signal: AbortSignal.timeout(10000) });
      if (!probe.ok) throw new Error(`answered ${probe.status}`);
      version = await probe.json();
      if (!version || !version.hub) throw new Error('did not answer as a hub');
    } catch (e) {
      throw new Error(describeUnreachable(url, e));
    }

    config = { ...config, hubBase: url };
    writeConfig(config);
    // The renderer reads the address from a process argument, so the window has
    // to be rebuilt for a change to take. Reloading in place would leave the
    // interface talking to the old hub with no sign of it.
    // Build the replacement first, then close the old one: the new window is
    // what `mainWindow` points at, so the outgoing window's `closed` handler
    // finds a reference that is no longer its own and leaves it alone.
    const outgoing = mainWindow;
    createWindow();
    if (outgoing && !outgoing.isDestroyed()) outgoing.close();
    return url;
  });

  // Every call to the hub leaves from here, not from the renderer.
  //
  // Not a preference. 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, which is
  // a posture worth keeping: its API is reachable from no web origin whatever.
  // Widening it for `app://meshbay` would be worse than it looks, because that
  // origin is not a credential — any Electron application on any machine can
  // claim the same scheme and host.
  //
  // 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) => {
    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
    // controls must not become a request to somewhere else.
    if (!base || target.origin !== base.origin) {
      throw new Error('Refused: not this hub');
    }
    let response;
    try {
      response = await fetch(target, {
        method: (init && init.method) || 'GET',
        headers: (init && init.headers) || {},
        body: (init && init.body) || undefined,
        // Node's fetch waits as long as the OS lets it, which for a host that
        // accepts a connection and then says nothing is minutes. The hub's own
        // longest call is signaling, which gives up at fifteen seconds, so
        // anything past this is not an answer that is still coming.
        signal: AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS),
      });
    } catch (e) {
      if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) {
        throw new Error(
          `${target.origin} accepted the connection but did not answer within `
          + `${Math.round(HUB_FETCH_TIMEOUT_MS / 1000)}s.`);
      }
      // Node's fetch says "fetch failed" for everything from a refused
      // connection to a TLS mismatch, which tells a person nothing at all.
      throw new Error(describeUnreachable(target.origin, e));
    }
    return {
      status: response.status,
      ok: response.ok,
      headers: Object.fromEntries(response.headers),
      body: await response.text(),
    };
  });

  ipcMain.handle('hub:probe', async (_e, url) => {
    const target = String(url || config.hubBase || '').replace(/\/+$/, '');
    if (!target) return null;
    try {
      const r = await fetch(`${target}/v1/hub/version`,
                            { signal: AbortSignal.timeout(10000) });
      return r.ok ? await r.json() : null;
    } catch { return null; }
  });

  ipcMain.handle('device:ensure', () => ensureDeviceKey());
  ipcMain.handle('device:public', () => {
    const key = deviceKey();
    return key ? publicKeyB64(key) : null;
  });
  ipcMain.handle('device:sign', (_e, username) => {
    const key = deviceKey();
    if (!key) return null;
    const timestamp = Math.floor(Date.now() / 1000);
    // The same bytes `POST /v1/users/auth` verifies. The username is inside the
    // signature, so one collected for a different account is not usable.
    const message = Buffer.from(
      `meshbay:user_auth:${String(username)}:${timestamp}`);
    return {
      timestamp,
      signature: crypto.sign(null, message, key).toString('base64'),
    };
  });
  ipcMain.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;
  });

  // Downloads are written to disk as they arrive — never collected in memory
  // and handed over at the end.
  //
  // That is what the browser does through the File System Access API or a
  // service worker, and **this application has neither**: `showDirectoryPicker`
  // is absent, and Chromium refuses to register a worker on a custom scheme. So
  // the chain fell through to its floor, which accumulates the whole file in
  // the page and hands Chromium a blob — a gigabyte of RAM for a gigabyte of
  // film, and a Save As dialog at the *end*, which is how it was noticed.
  //
  // The renderer still never names a path. It asks; the user chooses once; the
  // main process holds the handle and the renderer refers to it by an opaque id.
  const sinks = new Map();
  const completedPaths = new Map();
  let sinkId = 0;

  /** `name`, or the first "name (n).ext" that is not taken — never an overwrite. */
  function freeName(dir, filename) {
    if (!fs.existsSync(path.join(dir, filename))) return filename;
    const ext = path.extname(filename);
    const stem = path.basename(filename, ext);
    for (let n = 2; n < 1000; n++) {
      const candidate = `${stem} (${n})${ext}`;
      if (!fs.existsSync(path.join(dir, candidate))) return candidate;
    }
    throw new Error(`No free name for ${filename}`);
  }

  ipcMain.handle('folder:choose', async () => {
    const result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openDirectory', 'createDirectory'],
    });
    if (result.canceled || !result.filePaths.length) return null;
    config = { ...config, downloadDir: result.filePaths[0] };
    writeConfig(config);
    return config.downloadDir;
  });

  // Where downloads land when nobody has chosen anywhere. A browser does not
  // make you pick a folder before it will save a file, and neither should this
  // — "save automatically" that opens a dialog is not automatic.
  function defaultDownloadDir() {
    try { return app.getPath('downloads'); } catch { return os.homedir(); }
  }

  function chosenDownloadDir() {
    const dir = config.downloadDir;
    // A folder that has been removed or unmounted is not a folder any more,
    // and saying so beats failing on the first chunk of a download.
    if (!dir) return null;
    try { return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; }
  }

  ipcMain.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
    // offer "forget" for a folder nobody chose.
    return { name: chosen || defaultDownloadDir(), isDefault: !chosen };
  });

  ipcMain.handle('folder:forget', () => {
    config = { ...config, downloadDir: '' };
    writeConfig(config);
    return true;
  });

  ipcMain.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];
    return { path: chosen, name: path.basename(chosen) };
  });

  ipcMain.handle('save:begin', async (_e, suggestedName, opts) => {
    const wanted = path.basename(String(suggestedName || 'download'));
    const chosen = chosenDownloadDir();
    let target = null;

    // "Save automatically" means exactly that: no dialog. Into the chosen
    // folder if there is one, otherwise the system's Downloads folder — the
    // first version required a folder to have been picked first, so the very
    // first automatic download opened a dialog, which is the one thing the
    // setting says it will not do.
    //
    // The one case that still asks: a folder *was* chosen and has since gone.
    // Redirecting those files somewhere else without saying so is worse than a
    // dialog — someone who picked an external drive wants to know it is not
    // there, not to find the film in their home directory a week later.
    if (opts && opts.auto && !(config.downloadDir && !chosen)) {
      const dir = chosen || defaultDownloadDir();
      try {
        fs.mkdirSync(dir, { recursive: true });
        target = path.join(dir, freeName(dir, wanted));
      } catch { target = null; }
    }

    if (!target) {
      const dir = chosen || defaultDownloadDir();
      const result = await dialog.showSaveDialog(mainWindow, {
        defaultPath: path.join(dir, wanted),
      });
      if (result.canceled || !result.filePath) return null;
      target = result.filePath;
    }

    const id = String(++sinkId);
    sinks.set(id, { stream: fs.createWriteStream(target), path: target });
    return { id, name: path.basename(target), path: target };
  });

  ipcMain.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
    // renderer would outrun the disk and queue the file in memory anyway,
    // which is the thing this exists to avoid.
    await new Promise((resolve, reject) =>
      sink.stream.write(Buffer.from(chunk),
                        (err) => (err ? reject(err) : resolve())));
    return true;
  });

  ipcMain.handle('save:end', async (_e, id) => {
    const sink = sinks.get(String(id));
    if (!sink) return false;
    sinks.delete(String(id));
    completedPaths.set(String(id), sink.path);
    await new Promise((resolve) => sink.stream.end(resolve));
    return true;
  });

  ipcMain.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) => {
    const sink = sinks.get(String(id));
    if (!sink) return false;
    sinks.delete(String(id));
    await new Promise((resolve) => sink.stream.close(resolve));
    // A cancelled download leaves a truncated file, which is worse than none:
    // it looks like a complete one to whoever opens it next.
    try { fs.unlinkSync(sink.path); } catch { /* already gone */ }
    return true;
  });

  // ── Node loopback bridge ─────────────────────────────────────────────────
  //
  // The renderer never sees the session token. It names an operation and this
  // process executes it — the same pattern as hub:fetch. The token is read
  // from the daemon's data directory, cached for the lifetime of this process,
  // and never exposed through the preload.

  let _nodeToken = null;
  let _nodePort = 18000;
  let _nodePairingCode = null;

  function nodeConfigPath() {
    return path.join(os.homedir(), '.config', 'meshbay', 'node.toml');
  }

  function readNodeConfig() {
    try {
      const text = fs.readFileSync(nodeConfigPath(), 'utf8');
      let dataDir = path.join(os.homedir(), '.local', 'share', 'meshbay');
      let uiPort = 18000;
      const dataMatch = text.match(/^\s*data_dir\s*=\s*"([^"]+)"/m);
      if (dataMatch) {
        dataDir = dataMatch[1].replace(/^~/, os.homedir());
      }
      const portMatch = text.match(/^\s*ui_port\s*=\s*(\d+)/m);
      if (portMatch) uiPort = parseInt(portMatch[1], 10);
      return { dataDir, uiPort };
    } catch {
      return null;
    }
  }

  function readNodeToken(dataDir) {
    try {
      return fs.readFileSync(path.join(dataDir, 'ui-token'), 'utf8').trim();
    } catch {
      return null;
    }
  }

  ipcMain.handle('node:detect', async () => {
    const nc = readNodeConfig();
    if (!nc) return { detected: false, configured: false };
    const token = readNodeToken(nc.dataDir);
    if (!token) return { detected: false, configured: true };
    _nodeToken = token;
    _nodePort = nc.uiPort;
    try {
      const r = await fetch(
        `http://127.0.0.1:${_nodePort}/api/status?t=${_nodeToken}`,
        { signal: AbortSignal.timeout(3000) });
      if (!r.ok) return { detected: false, configured: true };
      const status = await r.json();
      return {
        detected: true,
        configured: true,
        status: status.status,
        pk_node_ed25519: status.pk_node_ed25519 || '',
      };
    } catch {
      return { detected: false, configured: true };
    }
  });

  ipcMain.handle('node:installed', async () => {
    if (process.platform !== 'linux') return { installed: false };
    return new Promise((resolve) => {
      execFile('systemctl', ['--user', 'show', 'meshbay-node.service',
        '--property=LoadState'], (err, stdout) => {
        if (err) return resolve({ installed: false });
        resolve({ installed: stdout.trim() === 'LoadState=loaded' });
      });
    });
  });

  async function probeNode() {
    const nc = readNodeConfig();
    const dataDir = nc ? nc.dataDir
      : path.join(os.homedir(), '.local', 'share', 'meshbay');
    const port = nc ? nc.uiPort : 18000;
    const token = readNodeToken(dataDir);
    if (!token) return null;
    try {
      const r = await fetch(
        `http://127.0.0.1:${port}/api/status?t=${token}`,
        { signal: AbortSignal.timeout(3000) });
      if (!r.ok) return null;
      const status = await r.json();
      _nodeToken = token;
      _nodePort = port;
      return { pk_node_ed25519: status.pk_node_ed25519 || '' };
    } catch { return null; }
  }

  ipcMain.handle('node:start', async () => {
    const already = await probeNode();
    if (already) return { started: true, ...already };

    if (process.platform !== 'linux') {
      throw new Error('Automatic node start is only supported on Linux');
    }

    const deadline = Date.now() + 15000;
    const configFile = nodeConfigPath();
    let launched = false;

    // Try systemctl first — the production path.
    try {
      await new Promise((resolve, reject) => {
        execFile('systemctl', ['--user', 'enable', '--now', 'meshbay-node'],
          (err, _stdout, stderr) => {
            if (err) return reject(new Error(stderr.trim() || err.message));
            resolve();
          });
      });
      // Give the service a moment, then check if it actually stayed up.
      for (let i = 0; i < 6 && Date.now() < deadline; i++) {
        await new Promise((r) => setTimeout(r, 500));
        const result = await probeNode();
        if (result) return { started: true, ...result };
      }
      // The unit may be stuck in auto-restart (e.g. ExecStart points to
      // /usr/bin which doesn't exist in dev). is-active returns 0 only when
      // the service is genuinely running.
      const isActive = await new Promise((resolve) => {
        execFile('systemctl', ['--user', 'is-active', 'meshbay-node'],
          (err) => resolve(!err));
      });
      if (isActive) {
        launched = true;
      } else {
        // Stop the broken unit so it doesn't compete with the direct start.
        await new Promise((resolve) => {
          execFile('systemctl', ['--user', 'stop', 'meshbay-node'],
            () => resolve());
        });
      }
    } catch {
      // systemctl itself failed (e.g. no unit file).
    }

    // Fallback: start the binary directly (dev mode, or no unit installed).
    if (!launched) {
      const binPath = await new Promise((resolve) => {
        execFile('which', ['meshbay-node'], (err, stdout) => {
          resolve(err ? null : stdout.trim());
        });
      });
      if (!binPath) {
        throw new Error(
          'meshbay-node is not installed');
      }
      const child = spawn(binPath, ['--config', configFile], {
        detached: true,
        stdio: 'ignore',
      });
      child.unref();
    }

    while (Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, 500));
      const result = await probeNode();
      if (result) return { started: true, ...result };
    }
    throw new Error(
      'meshbay-node was started but did not become ready within 15 seconds');
  });

  ipcMain.handle('node:call', async (_e, method, apiPath, body) => {
    if (!_nodeToken) throw new Error('Node not detected');
    const sep = apiPath.includes('?') ? '&' : '?';
    const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`;
    const init = { method: String(method).toUpperCase() };
    if (body !== undefined && body !== null) {
      init.headers = { 'Content-Type': 'application/json' };
      init.body = JSON.stringify(body);
    }
    init.signal = AbortSignal.timeout(30000);
    const r = await fetch(url, init);
    const text = await r.text();
    let data;
    try { data = JSON.parse(text); } catch { data = text; }
    if (!r.ok) {
      const msg = (data && data.error) || (data && data.detail) || text;
      throw new Error(`Node ${r.status}: ${msg}`);
    }
    return data;
  });

  ipcMain.handle('node:pairing-code', async () => {
    const code = _nodePairingCode;
    _nodePairingCode = null;
    return code;
  });

  ipcMain.handle('node:set-pairing-code', async (_e, code) => {
    _nodePairingCode = code || null;
    return true;
  });

  // ── LAN cast relay ──────────────────────────────────────────────────────
  //
  // A local HTTP server that re-serves decrypted fMP4 segments so a
  // Chromecast or Smart TV on the same Wi-Fi can stream the video. The
  // 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) => {
    return castRelay.start({
      codec: opts.codec,
      initSegment: opts.initSegment ? Buffer.from(opts.initSegment) : null,
    });
  });

  ipcMain.handle('cast:push', async (_e, data) => {
    castRelay.pushSegment(Buffer.from(data));
    return true;
  });

  ipcMain.handle('cast:stop', async () => {
    await castRelay.stop();
    return true;
  });

  ipcMain.handle('cast:finish', async () => {
    castRelay.finish();
    return true;
  });

  ipcMain.handle('cast:status', async () => ({
    active: castRelay.active,
    url: castRelay.url,
    chromecast: castChromecast.getStatus(),
  }));

  // ── Chromecast discovery + control ──────────────────────────────────────

  ipcMain.handle('cast:discover', async () => {
    return castChromecast.discover();
  });

  ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl }) => {
    return castChromecast.connect(deviceId, mediaUrl);
  });

  ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl }) => {
    return castChromecast.reload(mediaUrl);
  });

  ipcMain.handle('cast:chromecast:disconnect', async () => {
    await castChromecast.disconnect();
    return true;
  });
}

/**
 * Why the hub could not be reached, in words somebody can act on.
 *
 * `TypeError: fetch failed` is what Node says for a refused connection, a DNS
 * failure and a TLS mismatch alike. The most common mistake by far is `https`
 * typed at a hub speaking plain `http`, so that one is named outright.
 */
function describeUnreachable(url, error) {
  const cause = (error && error.cause) || {};
  const code = cause.code || '';
  const detail = cause.message || error.message || String(error);

  if (/^https:/.test(url) &&
      (code === 'ECONNRESET' || /wrong version|SSL|TLS|EPROTO/i.test(detail))) {
    return `${url} does not speak https. If this hub is on your own machine, ` +
           `it is probably http — try http:// instead.`;
  }
  if (code === 'ECONNREFUSED') {
    return `Nothing is listening at ${url}. Is the hub running?`;
  }
  if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
    return `${url} could not be found. Check the address.`;
  }
  if (error && error.name === 'TimeoutError') {
    return `${url} did not answer in time.`;
  }
  return `Could not reach ${url}: ${detail}`;
}

// ── Lifecycle ───────────────────────────────────────────────────────────────

// One instance. Two would fight over the config file and the secrets blob, and
// the second would look like the first had lost its state.
if (!app.requestSingleInstanceLock()) {
  app.quit();
} else {
  app.on('second-instance', () => {
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.focus();
    }
  });

  app.whenReady().then(() => {
    registerUiProtocol();
    registerBridge();
    createWindow();
    app.on('activate', () => {
      if (BrowserWindow.getAllWindows().length === 0) createWindow();
    });
  });

  app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') app.quit();
  });
}

module.exports = { contentType, secretsBackend, CSP };