aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 09:42:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 09:42:34 +0200
commit30e855f55f1d920b25da0bdd8e538c249d3c0c26 (patch)
tree587dcad0beb8a120352613be8aa751a97040015c /packages/meshbay-client/src/main.js
parent768e07046368819b8a8f15c8b21e5a8bbfcdf282 (diff)
downloadmeshbay-30e855f55f1d920b25da0bdd8e538c249d3c0c26.tar.gz
feat(client): the platform seam, and an Electron shell that has never been run
Stage D, and the honest half of it. D1 — the seam (done, and verified) ---------------------------------- `static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is built with the same base, so one address has one source. In a browser it returns '' and every path stays relative to the origin that served the page — the acceptance criterion for this split was "the browser SPA behaves identically", and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the content hash and a cached browser would never ask for it. D2 — the shell (written, never launched) ----------------------------------------- **There is no npm on this machine. Electron was never installed and `packages/meshbay-client/` has not been run once.** That is stated here rather than discovered later. What is there: a main process serving the packaged interface over a privileged `app://` scheme (`secure` and `standard` are not cosmetic — without them the service worker refuses to register and streamed downloads break silently), a preload exposing an enumerated bridge that never passes a filesystem path, a window with `sandbox`, `contextIsolation` and no node integration, navigation away from the package refused, and a CSP where the hub is reachable over connect-src and is not a script source. The hub address arrives as a process argument because `platform.hubBase()` runs before anything can await. `test_desktop_shell.py` pins each of those by reading the source — the treatment `test_downloads.py` already gives the three browser save paths. It catches a property being removed and proves nothing about the application running. Two were checked by breaking them. The interface is *copied* into the package by `build/sync-ui.js` from the hub's static directory, and `ui/` is gitignored: a silent fork is the only real way to end up maintaining the interface twice. D3 — partial ------------ The bridge, and the part worth having now: safeStorage's backend is reported rather than assumed. On Linux it falls back to a fixed key when no keyring is running, silently — someone who believes the OS is holding their keys is told when it is not. The native key lifecycle belongs with D4 and needs a running application to mean anything. D8 — partial, and a real defect found -------------------------------------- `meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` — into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry `User=`; systemd refuses the file, so the packaged unit could never have started. Nothing noticed because nobody had built and installed the RPM. Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service` that a person enables themselves without a password — which is what lets the desktop client install a node without asking for one. It carries ExecReload, so `meshbay-node reload` does not have to stop a service somebody is streaming from, and documents the drop-in for a drive outside the home, RequiresMountsFor included. 798 tests pass; e2e.py still passes end to end. Nothing here was built or launched: no npm, no rpmbuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/main.js')
-rw-r--r--packages/meshbay-client/src/main.js331
1 files changed, 331 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
new file mode 100644
index 0000000..3fc52ff
--- /dev/null
+++ b/packages/meshbay-client/src/main.js
@@ -0,0 +1,331 @@
+/**
+ * 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 fs = require('node:fs');
+const fsp = require('node:fs/promises');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+
+const UI_DIR = path.join(__dirname, '..', 'ui');
+const SCHEME = 'app';
+
+// ── 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, without which the worker refuses
+// to register and downloads break with no error at all. `standard` gives it a
+// real origin, so IndexedDB survives an update instead of being keyed to
+// something that moves.
+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) },
+ });
+ } 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;
+}
+
+// ── Window ──────────────────────────────────────────────────────────────────
+
+let mainWindow = null;
+
+function createWindow() {
+ const bounds = (config.window && config.window.width) ? config.window : {
+ width: 1200, height: 800,
+ };
+
+ mainWindow = 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,
+ },
+ });
+
+ mainWindow.once('ready-to-show', () => mainWindow.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; }
+ };
+ mainWindow.webContents.on('will-navigate', (event, target) => {
+ if (!isOurs(target)) event.preventDefault();
+ });
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url);
+ return { action: 'deny' };
+ });
+ // Nothing in this application needs a camera, a microphone or a location.
+ mainWindow.webContents.session.setPermissionRequestHandler(
+ (_wc, _permission, callback) => callback(false));
+
+ mainWindow.on('close', () => {
+ if (!mainWindow.isMinimized() && !mainWindow.isFullScreen()) {
+ config = { ...config, window: mainWindow.getBounds() };
+ writeConfig(config);
+ }
+ });
+ mainWindow.on('closed', () => { mainWindow = null; });
+
+ mainWindow.loadURL(`${SCHEME}://meshbay/index.html`);
+}
+
+// ── 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', (_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');
+ }
+ 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.
+ if (mainWindow) {
+ mainWindow.close();
+ createWindow();
+ }
+ return url;
+ });
+
+ 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;
+ });
+
+ // The renderer never names a path. It asks for a dialog; the user chooses;
+ // the main process holds the handle and the renderer only ever refers to it
+ // by an opaque id.
+ const sinks = new Map();
+ let sinkId = 0;
+
+ ipcMain.handle('save:begin', async (_e, suggestedName) => {
+ const result = await dialog.showSaveDialog(mainWindow, {
+ defaultPath: path.basename(String(suggestedName || 'download')),
+ });
+ if (result.canceled || !result.filePath) return null;
+ const id = String(++sinkId);
+ sinks.set(id, fs.createWriteStream(result.filePath));
+ return { id, name: path.basename(result.filePath) };
+ });
+
+ ipcMain.handle('save:write', async (_e, id, chunk) => {
+ const sink = sinks.get(String(id));
+ if (!sink) throw new Error('No such download');
+ await new Promise((resolve, reject) =>
+ sink.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.end(resolve));
+ 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', () => {
+ 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 };