aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
blob: bebe144f497c4e2df006cc7dcc032f5a7915ed4e (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
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
/**
 * 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, Menu, protocol, safeStorage,
        shell, Tray } =
  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');

// Linux window managers/desktop shells (GNOME's dash included) group and
// icon-match a running window by its WM_CLASS, resolved against an installed
// .desktop file's StartupWMClass — not against BrowserWindow's `icon` option,
// which only sets the window's own icon (titlebar/alt-tab). Unpackaged, the
// class defaults to "electron" and nothing matches it, so the shell falls
// back to the generic Electron icon. A packaged build gets a correct
// StartupWMClass from electron-builder automatically; this switch keeps dev
// runs (`electron .`) consistent with it.
app.commandLine.appendSwitch('class', 'MeshBay');

// Chromium publishes host candidates as random `.local` mDNS names rather
// than as the real local IP. Every peer this client talks to is a node running
// aiortc/aioice, and aioice has no mDNS resolver on any platform: it logs
// `Remote candidate "<uuid>.local" could not be resolved` and drops the
// candidate outright. Concealment therefore does not degrade here, it removes
// the only LAN-routable candidate and leaves reflexive pairs — which fail
// whenever both peers sit behind the same NAT, since that pair needs the
// router to hairpin. Measured: a Linux client and a node in a libvirt guest
// formed exactly one pair, host -> srflx on a shared public address, and it
// answered none of five binding requests.
//
// This was previously scoped to win32, from a session where the guest ran the
// *client*; the platform that matters is the peer's, not ours, and the peer is
// always a node. The trade is that our private address reaches the hub and the
// node in the SDP — both the user's own infrastructure, not an arbitrary page.
app.commandLine.appendSwitch('disable-features', 'WebRtcHideLocalIpsWithMdns');

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

// ── Platform directories ────────────────────────────────────────────────────

function meshbayConfigDir() {
  if (process.platform === 'win32')
    return path.join(process.env.LOCALAPPDATA || os.homedir(), 'meshbay');
  return path.join(os.homedir(), '.config', 'meshbay');
}

function meshbayDataDir() {
  if (process.platform === 'win32')
    return path.join(process.env.LOCALAPPDATA || os.homedir(), 'meshbay', 'data');
  return path.join(os.homedir(), '.local', 'share', 'meshbay');
}

// 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).
//
// The one exception is reCAPTCHA, used to gate sign-up (and password reset) the
// same way it gates them in the browser. Its script comes from www.google.com,
// its challenge is a www.google.com iframe, and its assets sit on
// www.gstatic.com. These two hosts — and only these two — are allowed under
// `script-src`, `frame-src` and `img-src` for that purpose. It is a real, if
// small, dent in "no third-party code runs here": Google's reCAPTCHA script
// executes in the renderer. It is accepted deliberately so a native sign-up is
// gated like a web one without asking the user to do anything extra, and it is
// the *same* dependency the hub-served SPA already carries. If sign-up ever
// moves to a proof-of-work challenge, delete RECAPTCHA_SRC and the three
// directives that spread it, and the widget in auth-page.js with them.
const RECAPTCHA_SRC = 'https://www.google.com https://www.gstatic.com';
const CSP = [
  "default-src 'none'",
  `script-src 'self' 'wasm-unsafe-eval' ${RECAPTCHA_SRC}`,
  "style-src 'self' 'unsafe-inline'",
  `img-src 'self' data: blob: ${RECAPTCHA_SRC}`,
  "media-src 'self' blob:",
  "font-src 'self'",
  "connect-src 'self' https: wss:",
  "worker-src 'self'",
  // `blob:` here and in `frame-src` are one thing, not two: the PDF preview.
  //
  // `files-app.js` decrypts a PDF in the page, wraps it in a Blob and hands it
  // to `<object type="application/pdf">`, so the bytes never leave the
  // renderer. Chromium serves that with its own viewer, and the viewer takes
  // TWO permissions — it loads the resource as plugin data (`object-src`,
  // which was falling back to `default-src 'none'`) and then renders it in an
  // internal frame (`frame-src`). Fixing either alone still shows the "this
  // browser will not display the PDF inline" fallback, which is how this
  // looked like a missing feature rather than a policy.
  //
  // `'self'` does not cover it: a same-origin `blob:` URL is NOT matched by
  // `'self'` in either directive — measured on Chromium 152, this build's and
  // Chrome's alike — so the token has to be `blob:` in both. Only page script
  // can mint a blob URL and the type is set by our code, so what this admits
  // is PDFium parsing bytes that came from a node — exactly what a browser
  // does with the same file.
  "object-src blob:",
  `frame-src blob: ${RECAPTCHA_SRC}`,
  "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',
          // Without this, a response carrying no cache header at all is a
          // response Chromium is free to reuse by heuristic freshness — the
          // same trap CLAUDE.md already records for the hub-served SPA
          // (`Cache-Control: no-cache` only binds a browser that asks).
          // Here every file is read fresh from disk on each request
          // already (sync-ui during development, a fresh install
          // otherwise), so nothing is ever served from the renderer's own
          // HTTP cache instead — a plain reload is enough after `npm run
          // sync-ui`, not just a full app restart.
          'Cache-Control': 'no-store',
        },
      });
    } 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;

// ── System tray ──────────────────────────────────────────────────────────────
// Linux and Windows.
//
// Created at launch, not on the first "minimise to tray". An indicator that
// only appears once you have already hidden the window is one you cannot use
// to find the application, which is most of what a tray is for -- and on
// Windows it made the app look like it had no tray presence at all until you
// went looking for one.
//
// The context menu is not optional. Under libappindicator -- which is how GNOME
// shows a tray at all, via the AppIndicator extension -- `tray.on('click')`
// never fires: the indicator only opens its menu. A tray whose only affordance
// was a click would be inert there -- Windows does send it, so the same handler
// restores the window on a plain left click.
//
// Labels arrive from the renderer rather than being translated here. The locale
// files are the interface's, the main process has no i18n, and a second string
// table is how two of them start disagreeing.
let tray = null;
let trayLabels = null;
let trayTimer = null;

// The same test preload.js publishes as `capabilities.tray`. macOS is excluded
// deliberately: it has a menu bar rather than a tray, and the window controls
// there already do what hiding to an indicator does elsewhere.
const trayOS = () => process.platform === 'linux' || process.platform === 'win32';
let nodeService = null;          // assigned by registerBridge()

const TRAY_FALLBACK = {
  show: 'Show MeshBay', quit: 'Quit',
  start_node: 'Start the node', stop_node: 'Stop the node',
};

// libappindicator offers no "menu is about to open" event, so a menu built once
// would show a stale Start/Stop for as long as the app runs. `systemctl --user
// show` is a few milliseconds and this only ticks while an indicator exists.
const TRAY_POLL_MS = 5000;

function showFromTray() {
  if (!mainWindow) return;
  if (!mainWindow.isVisible()) mainWindow.show();
  if (mainWindow.isMinimized()) mainWindow.restore();
  mainWindow.focus();
}

async function buildTrayMenu() {
  const text = { ...TRAY_FALLBACK, ...(trayLabels || {}) };
  const items = [{ label: text.show, click: showFromTray }];

  // Only when there is a daemon to act on: not installed means no entry at all,
  // rather than a control that reports failure when used.
  let status = null;
  try {
    status = nodeService ? await nodeService.status() : null;
  } catch {
    status = null;                // unreadable state is the same as no control
  }
  if (status && status.supported && status.installed) {
    const running = status.activeState === 'active';
    items.push({ type: 'separator' });
    items.push({
      label: running ? text.stop_node : text.start_node,
      click: async () => {
        try {
          // `restart` starts a stopped unit, which is what systemd's restart
          // means; there is no separate start verb to call.
          if (running) await nodeService.stop();
          else await nodeService.restart();
        } catch (err) {
          console.error('[MeshBay] tray: node action failed', err);
        }
        refreshTrayMenu();        // reflect the new state without waiting a tick
      },
    });
  }

  items.push({ type: 'separator' },
             { label: text.quit, click: () => app.quit() });
  return Menu.buildFromTemplate(items);
}

async function refreshTrayMenu() {
  if (!tray) return;
  tray.setContextMenu(await buildTrayMenu());
}

function ensureTray(labels) {
  if (labels) trayLabels = labels;   // the person may have changed language
  if (tray) {
    refreshTrayMenu();
    return tray;
  }
  // In src/, not build/: electron-builder packages only `src/**` and `ui/**`
  // (package.json `files`), so an icon under build/ exists in a dev run and is
  // missing from every installed one.
  tray = new Tray(path.join(__dirname, 'tray-icon.png'));
  tray.setToolTip('MeshBay');
  refreshTrayMenu();
  // No-op on GNOME (never fires there), the left-click restore on Windows.
  tray.on('click', showFromTray);
  if (!trayTimer) trayTimer = setInterval(refreshTrayMenu, TRAY_POLL_MS);
  return tray;
}

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

  const win = new BrowserWindow({
    ...bounds,
    minWidth: 320,
    show: false,
    icon: path.join(__dirname, '..', 'build', 'icon.png'),
    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());
  // Some Wayland compositors (observed under GNOME/Mutter on a VM with a
  // virtio-gpu device whose command-buffer creation fails) never schedule a
  // first paint for a surface that isn't mapped yet — but Electron won't map
  // it (show()) until `ready-to-show` fires, which waits for that paint. The
  // two conditions deadlock the window invisible forever. This bounded
  // fallback breaks the cycle. Guarded on isVisible(): show() also raises
  // and refocuses an already-visible window, so once the event has fired
  // normally (real GPU/X11 hosts, well under 2s) this must stay a no-op
  // rather than yank focus back from whatever the person switched to.
  setTimeout(() => { if (!win.isDestroyed() && !win.isVisible()) win.show(); }, 2000);

  // 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('ice:resolve-stun', async (_e, urls) => {
    const dns = require('node:dns').promises;
    const list = Array.isArray(urls) ? urls : [];
    const out = [];
    for (const u of list) {
      const m = /^(stuns?):(\[?[^\]]+\]?|[^:]+):(\d+)$/.exec(String(u));
      if (!m) { out.push(String(u)); continue; }
      const [, scheme, host, port] = m;
      if (/^[\d.]+$/.test(host) || host.includes(':')) { out.push(String(u)); continue; }
      try {
        const [ip] = await dns.resolve4(host);
        if (ip) out.push(`${scheme}:${ip}:${port}`);
      } catch { /* unresolvable (e.g. a decommissioned host) — drop it */ }
    }
    return out;
  });

  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; }
  });

  // Hide, never close: `window-all-closed` quits the app, and closing here would
  // make "minimise to tray" mean "exit". A hidden window keeps the session, the
  // transfers and the node connection exactly as they were.
  ipcMain.handle('window:minimize-to-tray', (_e, labels) => {
    if (!trayOS() || !mainWindow) return false;
    ensureTray(labels);
    mainWindow.hide();
    return true;
  });

  // The renderer sends these once it has a locale, and again after a language
  // change (which reloads the page, so the same call covers both). Until then
  // the tray created at launch shows TRAY_FALLBACK, in English, for the
  // fraction of a second the catalogue takes to arrive -- the alternative,
  // waiting for the renderer before creating it at all, is the behaviour this
  // replaces.
  ipcMain.handle('tray:labels', (_e, labels) => {
    if (!trayOS()) return false;
    ensureTray(labels);
    return true;
  });

  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;

  // A clean quit still has to tidy up: the `.part` convention above means a
  // crash leaves an obviously-unfinished file rather than a plausible one, but
  // quitting deliberately should leave nothing at all. Synchronous on purpose —
  // `before-quit` does not wait for promises, and an async cleanup here would
  // race the process exiting and finish nothing.
  app.on('before-quit', () => {
    for (const [id, sink] of sinks) {
      try { sink.stream.destroy(); } catch { /* already closed */ }
      try { fs.unlinkSync(sink.partial); } catch { /* already gone */ }
      sinks.delete(id);
    }
  });

  /** `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;
    }

    // Written to `<target>.part` and renamed on completion, never straight to
    // the final name. `save:abort` already deleted a cancelled download, but
    // nothing covered the app being quit, killed or crashing mid-transfer: the
    // stream was simply abandoned and a truncated file kept the final name,
    // which is the exact thing save:abort's own comment says is worse than no
    // file at all — it looks complete to whoever opens it next. A leftover
    // `.part` is self-evidently unfinished, and it is the same convention the
    // node already uses for uploads (`_do_file_upload`).
    const id = String(++sinkId);
    const partial = target + '.part';
    sinks.set(id, { stream: fs.createWriteStream(partial), path: target, partial });
    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));
    await new Promise((resolve) => sink.stream.end(resolve));
    // The rename is what publishes the download. Only after the stream has
    // flushed, or the file bearing the final name would still be short.
    try {
      fs.renameSync(sink.partial, sink.path);
    } catch (err) {
      console.error('[MeshBay] could not finalise download:', err.message);
      return false;
    }
    completedPaths.set(String(id), sink.path);
    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. Only the `.part`
    // exists at this stage — the final name is only taken by the rename in
    // save:end — so this removes that.
    try { fs.unlinkSync(sink.partial); } 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(meshbayConfigDir(), 'node.toml');
  }

  function readNodeConfig() {
    try {
      const text = fs.readFileSync(nodeConfigPath(), 'utf8');
      let dataDir = meshbayDataDir();
      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 };
    }
  });

  // Does THIS build ship its own node, as opposed to one merely reachable on
  // PATH (findNodeBinary's fallback, meant for a dev venv -- not "this
  // installer provisioned a node"). The "Light" target (electron-builder
  // .light.yml) ships no node-runtime extraResource at all; the renderer
  // uses this to fall back to the browser-only "create a group" form
  // instead of the wizard that assumes a local node it can link right there
  // (create-group-page.js) -- see C:\Users\admin\devel\light-client.md 5.3.
  // Unaffected off win32 and in dev: only a packaged Windows build can even
  // be Light, so everything else keeps today's behaviour unconditionally.
  function hasBundledNode() {
    if (process.platform === 'win32' && app.isPackaged) {
      return fs.existsSync(path.join(process.resourcesPath, 'node-runtime', 'meshbay-node.exe'));
    }
    return true;
  }

  // build/installer.nsh's customInstall adds node-runtime\ to the per-user
  // PATH at install time (HKCU\Environment, no elevation needed for that --
  // it never was the elevation that blocked it here). An AppX/MSIX install
  // has no install-time hook at all, so `meshbay-node` in a terminal simply
  // never got added for that target -- a real regression found by actually
  // running a sideloaded build, not a theoretical gap. Idempotent (the script
  // itself checks first) and harmless to call on every launch, NSIS Full
  // included, where it is normally already a no-op. Fire-and-forget: a
  // terminal convenience is not worth blocking startup or surfacing an error
  // dialog over.
  function winEnsureNodeOnPath() {
    if (process.platform !== 'win32' || !hasBundledNode()) return;
    const script = path.join(process.resourcesPath, 'ensure-node-path.ps1');
    if (!fs.existsSync(script)) return; // dev run, or an older build without it
    const nodeDir = path.join(process.resourcesPath, 'node-runtime');
    execFile(MB_PWSH,
      ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-NodeDir', nodeDir],
      (err, stdout) => {
        if (err) { console.error('[path] ensure-node-path.ps1 failed:', err.message); return; }
        console.log('[path] node-runtime on PATH:', (stdout || '').trim());
      });
  }

  function findNodeBinary() {
    if (process.platform === 'win32') {
      // A packaged Windows build carries the frozen daemon as an
      // extraResource (package.json build.win, packaging/win/). Prefer it —
      // it is the version that shipped with this client.
      if (app.isPackaged) {
        const bundled = path.join(process.resourcesPath, 'node-runtime', 'meshbay-node.exe');
        if (fs.existsSync(bundled)) return bundled;
      }
    } else {
      const local = path.join(os.homedir(), '.local', 'bin', 'meshbay-node');
      if (fs.existsSync(local)) return local;
    }
    const cmd = process.platform === 'win32' ? 'where.exe' : 'which';
    return new Promise((resolve) => {
      execFile(cmd, ['meshbay-node'], (err, stdout) => {
        if (err) { resolve(null); return; }
        // where.exe/which can list more than one match on PATH, and each
        // line keeps its own trailing \r on Windows -- `stdout.trim()` only
        // strips the ends of the *whole* string, so with 2+ matches a stray
        // \r stayed glued to the end of the first line. That \r then landed
        // inside the quoted path this function's caller writes into the
        // Startup .vbs, breaking VBScript's parser with "Unterminated
        // string constant" the next time Windows tried to run it at sign-in.
        const first = stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
        resolve(first || null);
      });
    });
  }

  // ── Windows: the Startup-folder launcher that stands in for the systemd unit ──
  // A logon-triggered Task Scheduler task needs elevation to create, which an
  // ordinary user does not have, so autostart is a `.vbs` in the per-user
  // Startup folder instead: wscript runs it hidden at every sign-in — no admin,
  // no console window. Kept in step with meshbay_node.platform._startup_vbs().
  const WIN_STARTUP_VBS = path.join(
    app.getPath('appData'), 'Microsoft', 'Windows', 'Start Menu', 'Programs',
    'Startup', 'MeshBay Node.vbs');

  function winAutostartInstalled() {
    try { return fs.existsSync(WIN_STARTUP_VBS); } catch { return false; }
  }

  function winAutostartInstall(bin) {
    fs.mkdirSync(path.dirname(WIN_STARTUP_VBS), { recursive: true });
    // Chr(34) is a literal " — wraps the path so a space in it doesn't split
    // the command. 0 = hidden window, False = don't wait. Kept in step with
    // meshbay_node.platform.autostart_install().
    fs.writeFileSync(WIN_STARTUP_VBS,
      `CreateObject("WScript.Shell").Run Chr(34) & "${bin}" & Chr(34), 0, False\r\n`);
  }

  function winAutostartRemove() {
    try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ }
  }

  // Prefers a graceful stop: `autostart stop` now tries CTRL_BREAK_EVENT
  // against the pid autostart_run() recorded first (meshbay_node.platform.
  // autostart_end()), which daemon.py's SIGBREAK handler turns into a real
  // _shutdown() -- closed WebRTC sessions, killed ffmpeg -- before that same
  // function falls back to a hard `taskkill /F` itself. Keeping the
  // graceful-then-forceful logic in that one place, rather than this
  // function *also* going straight to taskkill, is what actually fixed it:
  // two independent hard-kill call sites would still bypass shutdown one of
  // the times. Only genuinely falls back to taskkill here when the binary
  // cannot even be located.
  async function killNodeProcesses() {
    const bin = await findNodeBinary();
    return new Promise((resolve) => {
      if (bin) {
        execFile(bin, ['autostart', 'stop'], () => resolve());
      } else {
        execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve());
      }
    });
  }

  // ── Windows: the opt-in Scheduled Task "service mode" ──────────────────────
  // Set up once, elevated, at install time (build/installer.nsh + packaging/win
  // /service.ps1 + /service-mode.ps1) or via `meshbay-node service install`
  // from an elevated prompt — this process never creates or deletes it, only
  // queries and drives an existing one, which needs no elevation (Task
  // Scheduler grants the owning user that much itself). Kept in step with
  // meshbay_node.platform.TASK_NAME / service_status().
  const WIN_SERVICE_TASK = 'MeshBay Node';

  function winServiceTaskStatus() {
    return new Promise((resolve) => {
      execFile('schtasks', ['/query', '/tn', WIN_SERVICE_TASK, '/fo', 'list'],
        (err, stdout) => {
          if (err) return resolve({ installed: false, state: '' });
          const m = (stdout || '').split(/\r?\n/).find((l) => /^status:/i.test(l.trim()));
          resolve({ installed: true, state: m ? m.split(':')[1].trim() : '' });
        });
    });
  }

  function winServiceTaskRun() {
    return new Promise((resolve) => {
      execFile('schtasks', ['/run', '/tn', WIN_SERVICE_TASK], () => resolve());
    });
  }

  function winServiceTaskEnd() {
    return new Promise((resolve) => {
      execFile('schtasks', ['/end', '/tn', WIN_SERVICE_TASK], () => resolve());
    });
  }

  // ── Windows: switching INTO or OUT OF service mode after install ───────────
  // build/installer.nsh's mode question is effectively one-shot: it skips
  // itself the moment the firewall rules already exist, and per-user mode
  // sets those up on its own, with no Scheduled Task involved. So declining
  // once (or the rules existing for any other reason) is a dead end through
  // the installer alone — this is the other door in, driven from the Node
  // page instead of setup. It runs the exact same packaging/win/service-mode.ps1
  // the installer does (task + firewall, one elevation), so the two paths
  // can never disagree about what "service mode" means.
  const MB_PWSH = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';

  function winElevateServiceMode(action) {
    return new Promise((resolve, reject) => {
      const script = path.join(process.resourcesPath, 'service-mode.ps1');
      if (!fs.existsSync(script)) {
        // service-mode.ps1 is an extraResource -- only present once installed
        // (package.json build.win.extraResources); nothing under `npm start`.
        // The Node page already disables the "background service" option
        // when node:service-status reports canElevate: false, so this should
        // only ever be reached if that guard is bypassed somehow -- keep the
        // message actionable regardless.
        reject(new Error(
          'Switching to a background service needs an installed build. For '
          + 'local testing, run "meshbay-node service install" from an '
          + 'elevated PowerShell instead.'));
        return;
      }
      // Start-Process -Verb RunAs is the one UAC prompt; -Wait -PassThru hands
      // its exit code back to this unelevated process, so a decline ("The
      // operation was canceled by the user") surfaces as a rejection here
      // instead of silently doing nothing. Written to a temp .ps1 and run via
      // -File (not -Command) so the target path and its own arguments bind
      // through real PowerShell parameters instead of nested string quoting.
      const elevator = path.join(os.tmpdir(), 'meshbay-elevate-service-mode.ps1');
      const elevatorSrc = [
        'param([string]$Target, [string]$TargetArgs)',
        '$ErrorActionPreference = "Stop"',
        '$p = Start-Process -FilePath $Target -ArgumentList $TargetArgs -Verb RunAs -Wait -PassThru',
        'exit $p.ExitCode',
        '',
      ].join('\r\n');
      fs.writeFileSync(elevator, elevatorSrc);
      const targetArgs =
        `-NoProfile -ExecutionPolicy Bypass -File "${script}" -Action ${action}`;
      execFile(MB_PWSH,
        ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', elevator,
          '-Target', MB_PWSH, '-TargetArgs', targetArgs],
        (err) => {
          if (err) {
            reject(new Error('Elevation was declined, or the operation failed.'));
            return;
          }
          resolve();
        });
    });
  }

  // A daemon that crashes immediately (a port already in use -- reproduced
  // live: a second node instance found 18000 taken by the first -- a corrupt
  // config, antivirus interference) used to fail silently: stdio was
  // 'ignore', so its stderr was thrown away, and the only failure path left
  // was the caller's waitForNode() timing out after a generic 60s ("did not
  // start within 60s"). The real reason was sitting on stderr the whole time,
  // just never read. This watches for a few seconds -- long enough for any
  // startup crash, reproduced consistently well under one second -- and
  // rejects with the daemon's own tail of stderr if it exits in that window.
  // If it survives the window, stdio is released and it is left fully
  // detached, same as before this existed.
  const NODE_CRASH_WATCH_MS = 2500;

  function spawnNodeDetachedWatched(bin, args = []) {
    return new Promise((resolve, reject) => {
      const child = spawn(bin, args, {
        detached: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
      });
      let stderr = '';
      let settled = false;
      child.stderr.on('data', (d) => { stderr += d.toString(); });
      // spawn() failures (bad path, a stale PATH entry, antivirus
      // interference) land on the ChildProcess as an 'error' event,
      // asynchronously -- with no listener, Node rethrows it as an uncaught
      // exception and takes the whole main process down with it.
      child.on('error', (err) => {
        if (settled) return;
        settled = true;
        reject(err);
      });
      child.on('exit', (code, signal) => {
        if (settled) return;
        settled = true;
        // By lines (last 8) at first cut the actual OSError -- a real crash
        // captured live logged the bind failure, then two separate uvicorn/
        // asyncio tracebacks *after* it, which pushed it out of a short tail.
        // Character-bounded instead: Python's own daemon rarely writes more
        // than a couple of screens on a startup crash, so keeping the last
        // stretch of raw text is far more likely to still include the one
        // line that actually says what went wrong than guessing a line count.
        let tail = stderr.trim();
        if (tail.length > 4000) tail = `…${tail.slice(-4000)}`;
        reject(new Error(
          `meshbay-node exited immediately (code ${code}${signal ? `, signal ${signal}` : ''})`
          + (tail ? `:\n${tail}` : '')));
      });
      setTimeout(() => {
        if (settled) return;
        settled = true;
        child.stdout.destroy();
        child.stderr.destroy();
        child.unref();
        resolve();
      }, NODE_CRASH_WATCH_MS);
    });
  }

  async function spawnNodeDetached() {
    const bin = await findNodeBinary();
    if (!bin) throw new Error('meshbay-node not found on PATH');
    await spawnNodeDetachedWatched(bin);
  }

  async function waitForNode(deadline) {
    while (Date.now() < deadline) {
      const p = await probeNode();
      if (p) return p;
      await new Promise((r) => setTimeout(r, 800));
    }
    return null;
  }

  // The daemon is reachable but sitting at 'waiting_for_node_key' /
  // 'waiting_for_account': its Ed25519 key is not linked to the hub account it
  // runs as (a fresh node, or that account still carries a previous machine's
  // node key). Link it with the signed-in user's token, then wait out the
  // daemon's own 5s hub-auth retry until it reports 'running'. `PUT
  // /me/node_key` overwrites unconditionally, so this also recovers an account
  // whose linked key belongs to a node that is gone.
  //
  // The Linux branch of `node:start` does the same thing inline; Windows went
  // without it, so the daemon never left 'waiting_for_account' and the Create
  // Group wizard spun on "Detecting local node…" for ever.
  async function linkNodeKeyAndAwaitRunning(opts, deadline) {
    let linked = false;
    let last = null;
    while (Date.now() < deadline) {
      last = await probeNode();
      if (last && last.status === 'running') return last;
      if (last && !linked && opts && opts.token && opts.hubUrl
          && last.pk_node_ed25519
          && (last.status === 'waiting_for_node_key'
              || last.status === 'waiting_for_account')) {
        try {
          const r = await fetch(`${opts.hubUrl}/v1/users/me/node_key`, {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json',
                       'Authorization': `Bearer ${opts.token}` },
            body: JSON.stringify({ pk_node_ed25519: last.pk_node_ed25519 }),
            signal: AbortSignal.timeout(5000),
          });
          if (r.ok) linked = true;
        } catch { /* transient — retried on the next tick */ }
      }
      await new Promise((r) => setTimeout(r, 500));
    }
    return last;
  }

  ipcMain.handle('node:installed', async () => {
    if (process.platform === 'win32') {
      const [bin, svc] = await Promise.all([findNodeBinary(), winServiceTaskStatus()]);
      return {
        installed: Boolean(bin),
        autostart: winAutostartInstalled(),
        service: svc.installed,
      };
    }
    if (process.platform !== 'linux') return { installed: false };
    const unit = await new Promise((resolve) => {
      execFile('systemctl', ['--user', 'show', 'meshbay-node.service',
        '--property=LoadState'], (err, stdout) => {
        if (err) return resolve(false);
        resolve(stdout.trim() === 'LoadState=loaded');
      });
    });
    if (unit) return { installed: true };
    const bin = await findNodeBinary();
    return { installed: Boolean(bin) };
  });

  ipcMain.handle('node:bundled', () => hasBundledNode());

  // The systemd unit's own view of the node, for the status panel at the top
  // of the Node page. Deliberately not `probeNode()`: that asks the daemon's
  // own HTTP API, which cannot answer while the daemon is stopped or crash-
  // looping — exactly the states this panel exists to show and act on.
  // The tray menu drives the same daemon controls as the Node page. Declared
  // here because these close over registerBridge's helpers; the tray is created
  // long after, so it reads them through this binding rather than duplicating
  // the systemctl and Task Scheduler branches.
  nodeService = { status: nodeServiceStatus, stop: nodeServiceStop,
                  restart: nodeServiceRestart };

  ipcMain.handle('node:service-status', () => nodeServiceStatus());

  // service-mode.ps1 is an extraResource present in a packaged Full build,
  // absent from a packaged Light one (nothing to run as a service) and from
  // an unpackaged dev run. app.isPackaged alone used to gate this, which is
  // wrong for Light: it would enable the "background service" option and
  // only fail when actually clicked (winElevateServiceMode's own existsSync
  // check below, with an actionable error) -- correct but a dead click the
  // Node page should not offer in the first place.
  function winCanElevateServiceMode() {
    return app.isPackaged
      && fs.existsSync(path.join(process.resourcesPath, 'service-mode.ps1'));
  }

  async function nodeServiceStatus() {
    if (process.platform === 'win32') {
      const svc = await winServiceTaskStatus();
      if (svc.installed) {
        // Service mode: Task Scheduler already tracks running/not, directly —
        // no need to probe the daemon's own API for this panel.
        const running = /running/i.test(svc.state);
        return {
          supported: true,
          mode: 'service',
          installed: true,
          activeState: running ? 'active' : 'inactive',
          subState: svc.state,
          // Whether switching startup mode can actually elevate right now —
          // service-mode.ps1 is an extraResource, present in a packaged Full
          // build but not a Light one (no node to run as a service at all).
          // Already installed here, so removing it always works regardless;
          // this only gates the Node page offering to switch *into* service
          // mode.
          canElevate: winCanElevateServiceMode(),
        };
      }
      // Per-user Startup mode. `installed` used to be winAutostartInstalled(),
      // which is wrong: it answers "does the Startup launcher exist", not "is
      // there a daemon to manage". The Node page's Stop/Restart buttons are
      // gated on `installed`, so with no autostart configured they silently
      // vanished — the daemon was perfectly manageable, just not launchable
      // at sign-in. `autostart` carries that state as its own field instead.
      const [p, bin] = await Promise.all([probeNode(), findNodeBinary()]);
      return {
        supported: true,
        // Only claim "startup mode" once a node was actually found (bundled
        // or on PATH) -- a Light install with none at all would otherwise
        // show a working-looking autostart dropdown for a node that does
        // not exist. `showStartupRow` in node-page.js is gated on this being
        // a string, so `null` here hides that whole row.
        mode: bin ? 'startup' : null,
        installed: Boolean(bin),
        autostart: winAutostartInstalled(),
        activeState: p ? 'active' : 'inactive',
        subState: p ? 'running' : '',
        canElevate: winCanElevateServiceMode(),
      };
    }
    if (process.platform !== 'linux') return { supported: false };
    return new Promise((resolve) => {
      execFile('systemctl', ['--user', 'show', 'meshbay-node.service',
        '--property=LoadState,ActiveState,SubState'], (err, stdout) => {
        if (err) {
          resolve({ supported: true, installed: false, activeState: 'unknown',
                    subState: '' });
          return;
        }
        const props = {};
        for (const line of stdout.split('\n')) {
          const i = line.indexOf('=');
          if (i > 0) props[line.slice(0, i)] = line.slice(i + 1);
        }
        resolve({
          supported: true,
          installed: props.LoadState === 'loaded',
          activeState: props.ActiveState || 'unknown',
          subState: props.SubState || '',
        });
      });
    });
  }

  ipcMain.handle('node:service-stop', () => nodeServiceStop());

  async function nodeServiceStop() {
    if (process.platform === 'win32') {
      const svc = await winServiceTaskStatus();
      if (svc.installed) await winServiceTaskEnd();
      await killNodeProcesses();   // graceful-then-forceful; also the
      // belt-and-suspenders in case /end left the process running
      return { stopped: true };
    }
    if (process.platform !== 'linux') {
      throw new Error('Service control is only supported on Linux');
    }
    await new Promise((resolve, reject) => {
      execFile('systemctl', ['--user', 'stop', 'meshbay-node'],
        (err, _stdout, stderr) => {
          if (err) return reject(new Error(stderr.trim() || err.message));
          resolve();
        });
    });
    return { stopped: true };
  }

  ipcMain.handle('node:service-restart', () => nodeServiceRestart());

  async function nodeServiceRestart() {
    if (process.platform === 'win32') {
      const svc = await winServiceTaskStatus();
      if (svc.installed) await winServiceTaskEnd();
      await killNodeProcesses();
      if (svc.installed) {
        await winServiceTaskRun();
      } else {
        await spawnNodeDetached();
      }
      const p = await waitForNode(Date.now() + 30000);
      if (!p) throw new Error('node did not come back up within 30s');
      return { restarted: true, ...p };
    }
    if (process.platform !== 'linux') {
      throw new Error('Service control is only supported on Linux');
    }
    await new Promise((resolve, reject) => {
      execFile('systemctl', ['--user', 'restart', 'meshbay-node'],
        (err, _stdout, stderr) => {
          if (err) return reject(new Error(stderr.trim() || err.message));
          resolve();
        });
    });
    return { restarted: true };
  }

  // Install / remove the Windows Startup-folder launcher, and query it.
  ipcMain.handle('node:autostart', async (_e, action) => {
    if (process.platform !== 'win32') return { supported: false };
    if (action === 'install') {
      const bin = await findNodeBinary();
      if (!bin) throw new Error('meshbay-node not found on PATH');
      winAutostartInstall(bin);
      return { supported: true, installed: true };
    }
    if (action === 'remove') {
      winAutostartRemove();
      return { supported: true, installed: false };
    }
    return { supported: true, installed: winAutostartInstalled() };
  });

  // Turn service mode on or off after install — one elevation, task + firewall
  // together, via the same service-mode.ps1 the installer runs. See
  // winElevateServiceMode() above for why this is needed at all.
  ipcMain.handle('node:service-mode', async (_e, action) => {
    if (process.platform !== 'win32') return { supported: false };
    if (action !== 'install' && action !== 'remove') {
      throw new Error(`unknown service-mode action: ${action}`);
    }
    await winElevateServiceMode(action);
    const svc = await winServiceTaskStatus();
    return { supported: true, installed: svc.installed };
  });

  async function probeNode() {
    const nc = readNodeConfig();
    const dataDir = nc ? nc.dataDir : meshbayDataDir();
    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();
      const READY = ['running', 'waiting_for_node_key', 'waiting_for_account', 'starting'];
      if (!READY.includes(status.status)) return null;
      _nodeToken = token;
      _nodePort = port;
      return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status };
    } catch { return null; }
  }

  // A path inside a TOML basic string: forward slashes only. A raw Windows
  // path there (`C:\Users\...`) is a parse error — `\U`, `\a`, ... are escape
  // sequences. pathlib on the node reads the `/` form fine.
  const tomlPath = (p) => p.split(path.sep).join('/');

  function provisionNode(hubUrl, username) {
    const configDir = meshbayConfigDir();
    const dataDir = meshbayDataDir();
    fs.mkdirSync(configDir, { recursive: true });
    fs.mkdirSync(dataDir, { recursive: true });

    const configFile = nodeConfigPath();
    if (fs.existsSync(configFile)) {
      const content = fs.readFileSync(configFile, 'utf8');
      const updated = content
        .replace(/^url\s*=\s*"[^"]*"/m, `url      = "${hubUrl}"`)
        .replace(/^username\s*=\s*"[^"]*"/m, `username = "${username}"`);
      fs.writeFileSync(configFile, updated, { mode: 0o600 });
    } else {
      const toml = [
        '[hub]',
        `url      = "${hubUrl}"`,
        `username = "${username}"`,
        '',
        '[node]',
        'quic_enabled = false  # QUIC direct path; no client uses it yet',
        'quic_port = 19010',
        'ui_port   = 18000',
        '',
        '[keystore]',
        `unlock_file = "${tomlPath(path.join(configDir, 'unlock.key'))}"`,
        '',
      ].join('\n');
      fs.writeFileSync(configFile, toml, { mode: 0o600 });
    }

    const unlockFile = path.join(configDir, 'unlock.key');
    if (!fs.existsSync(unlockFile)) {
      const key = crypto.randomBytes(32).toString('base64url');
      fs.writeFileSync(unlockFile, key + '\n', { mode: 0o600 });
    }
  }

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

    if (process.platform === 'win32') {
      if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username);
      await killNodeProcesses();   // clear a crash-looping one
      const svc = await winServiceTaskStatus();
      if (svc.installed) {
        await winServiceTaskRun();
      } else {
        await spawnNodeDetached();
      }
      const p = await waitForNode(Date.now() + 60000);
      if (!p) throw new Error('the node did not start within 60s — run it from a '
        + 'terminal (`meshbay-node`) to see why');
      // Up, but almost never 'running' on a first launch: link the node key to
      // the hub account and wait for the daemon to authenticate. Without this
      // it stays at 'waiting_for_account' and nothing here ever tells the hub
      // about the node.
      const ready = p.status === 'running'
        ? p
        : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 45000);
      if (!ready || ready.status !== 'running') {
        throw new Error(
          'the node started but could not link to your hub account. Open the '
          + 'Node page and use "Link this node", or check you are signed in to '
          + 'the hub this node is configured for.');
      }
      return { started: true, ...ready };
    }

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

    if (opts && opts.hubUrl && opts.username) {
      provisionNode(opts.hubUrl, opts.username);
    }

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

    // Clear any failed state from a prior crash loop.
    await new Promise((r) => {
      execFile('systemctl', ['--user', 'reset-failed', 'meshbay-node'],
        () => r());
    });

    // If a daemon is running in a bad state, restart it with the fresh config.
    if (already) {
      try {
        await new Promise((resolve, reject) => {
          execFile('systemctl', ['--user', 'restart', 'meshbay-node'],
            (err, _stdout, stderr) => {
              if (err) return reject(new Error(stderr.trim() || err.message));
              resolve();
            });
        });
        launched = true;
      } catch { /* not systemd-managed — fall through to start */ }
    }

    // Try systemctl first — the production path.
    if (!launched) 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 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) { launched = true; break; }
      }
      if (!launched) {
        const isActive = await new Promise((resolve) => {
          execFile('systemctl', ['--user', 'is-active', 'meshbay-node'],
            (err) => resolve(!err));
        });
        if (isActive) {
          launched = true;
        } else {
          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 findNodeBinary();
      if (!binPath) {
        throw new Error(
          'meshbay-node is not installed');
      }
      const child = spawn(binPath, ['--config', configFile], {
        detached: true,
        stdio: 'ignore',
      });
      // Same reason as spawnNodeDetached(): an unhandled 'error' event here
      // would crash the whole main process instead of letting the polling
      // loop below report "never came up".
      child.on('error', (err) => console.error('[node] failed to start:', err.message));
      child.unref();
    }

    // Wait for the daemon to reach 'running'. Along the way, auto-link the
    // node key on the hub so the daemon can authenticate.
    let keyLinked = false;
    while (Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, 500));

      const result = await probeNode();
      if (!result) continue;

      if (result.status === 'running') {
        return { started: true, ...result };
      }

      // Daemon is up but stuck on hub auth — link the key so it can proceed.
      if (!keyLinked && opts && opts.token && result.pk_node_ed25519 &&
          (result.status === 'waiting_for_node_key' ||
           result.status === 'waiting_for_account')) {
        try {
          const lr = await fetch(
            `${opts.hubUrl}/v1/users/me/node_key`, {
              method: 'PUT',
              headers: { 'Content-Type': 'application/json',
                         'Authorization': `Bearer ${opts.token}` },
              body: JSON.stringify({
                pk_node_ed25519: result.pk_node_ed25519 }),
              signal: AbortSignal.timeout(5000),
            });
          if (lr.ok) keyLinked = true;
        } catch { /* best effort */ }
      }
    }
    throw new Error(
      'meshbay-node was started but did not become ready within 60 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;
  });

  winEnsureNodeOnPath();
}

/**
 * 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}`;
}

// ── The version gate ────────────────────────────────────────────────────────

/** Compare two dotted versions. -1, 0 or 1; unreadable sorts as equal. */
function compareVersions(a, b) {
  const parse = (v) => String(v || '').split('.').map((n) => parseInt(n, 10));
  const [x, y] = [parse(a), parse(b)];
  if (x.some(Number.isNaN) || y.some(Number.isNaN)) return 0;
  for (let i = 0; i < Math.max(x.length, y.length); i++) {
    const d = (x[i] || 0) - (y[i] || 0);
    if (d) return d < 0 ? -1 : 1;
  }
  return 0;
}

/**
 * Refuse to start when this build is older than the hub will talk to.
 *
 * The reason this exists rather than letting the handshake do it: the SPA is
 * served by the hub and picks up a new client on reload, but **this
 * application ships its own interface**. On the MNP 3.0 flag day an
 * un-updated one can still sign in, still list groups, and then fail every
 * connection with `version_too_old` — a refusal in a protocol vocabulary,
 * surfacing as a node that will not talk, with nothing anyone can act on.
 *
 * So the question is asked once, up front, of `/v1/hub/version`, which has
 * carried `client.minimum` since before there was a client to check it.
 *
 * **Unreachable is not too old.** A hub that is down, a laptop with no network,
 * a captive portal: none of those are a reason to refuse to open the
 * application, and treating them as one would make an offline start impossible
 * for ever. Only a definite answer, saying in so many words that this version
 * is below the minimum, stops anything.
 */
async function refuseIfTooOld() {
  const base = String(config.hubBase || '').replace(/\/+$/, '');
  if (!base) return false;   // First run: there is no hub to ask yet.
  let info;
  try {
    const r = await fetch(`${base}/v1/hub/version`,
                          { signal: AbortSignal.timeout(10000) });
    if (!r.ok) return false;
    info = await r.json();
  } catch {
    return false;
  }
  const minimum = info && info.client && info.client.minimum;
  if (!minimum) return false;
  const mine = app.getVersion();
  if (compareVersions(mine, minimum) >= 0) return false;

  const { response } = await dialog.showMessageBox({
    type: 'warning',
    title: 'Update required',
    message: 'This version of MeshBay can no longer connect',
    detail: `This application is version ${mine}, and ${base} now requires `
      + `${minimum} or later.\n\nDownload the current version and install it `
      + 'over this one — your groups, keys and settings are kept.',
    buttons: ['Download the update', 'Quit'],
    defaultId: 0,
    cancelId: 1,
  });
  if (response === 0) await shell.openExternal(base);
  return true;
}

// ── 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', () => {
    showFromTray();
  });

  app.whenReady().then(async () => {
    // Before anything else is built. A window that opens and then cannot
    // connect is the failure this replaces.
    if (await refuseIfTooOld()) { app.quit(); return; }
    registerUiProtocol();
    // Before ensureTray: buildTrayMenu reads `nodeService`, which registerBridge
    // assigns, so creating the tray after it means the Start/Stop entry is on
    // the very first menu rather than appearing one poll later.
    registerBridge();
    if (trayOS()) ensureTray();
    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 };