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
|
/**
* 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 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.
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();
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('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));
await new Promise((resolve) => sink.stream.end(resolve));
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;
});
}
/**
* 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 };
|