diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 12:57:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 12:57:58 +0200 |
| commit | 2c6921aa2c35ffd41b6c453e6700574ef631ba2c (patch) | |
| tree | 01c766e13607e4f957900bfd36b4f722e8c8b3c5 | |
| parent | 8a4651e9d223de856ff085b329801998f95db138 (diff) | |
| download | meshbay-2c6921aa2c35ffd41b6c453e6700574ef631ba2c.tar.gz | |
fix(client): the page names node operations, and the app confirms what widens the node
node:call is replaced by named operations with checked arguments; hosting a
group, sharing an unpicked folder, key rotation, denylist clearing and a change
of node account are confirmed by a native dialog. Every channel checks its
sender, secrets:get/set/clear are gone, node:start writes the app's own hub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 files changed, 574 insertions, 193 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index cd97aa2..d63f722 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2261,7 +2261,24 @@ What running it establishes, and what each fact costs: - **The device's hub key lives in the main process, never in the renderer.** Generated, stored and used there; the interface asks for a signature and is never handed a key. Same rule as the save dialog, and for the same reason: the renderer - parses decrypted content from nodes, which is attacker-controlled input. + parses decrypted content from nodes, which is attacker-controlled input. The + secret store that holds it is not reachable from the page either: a generic + read and write by name was a way to take the key and to replace it, and nothing + in the interface used it. +- **The page administers the local node by operation, never by route.** It names + one of the operations the interface performs (`NODE_OPS` in `main.js`); the main + process checks the arguments — ids are ids, names are encoded — builds the + request and adds the node's token. `node:start` writes the hub this application + is signed in to and a username the hub would register, never a value the page + supplies verbatim. **What widens what the node shares or admits, or replaces its + group key, is confirmed by a dialog the main process draws**: hosting a group, + sharing a folder not chosen in the native folder picker (one chosen there is its + own confirmation, so the ordinary path asks nothing twice), rotating the key, + clearing the denylist, and pointing the node at another account. The words come + from the interface's catalogues, read by the main process; the page sets the + language and nothing else. An in-page confirmation is one a script in the page + can answer for itself. **Every channel answers only the packaged page's top-level + document.** - **OS-backed secret storage is real on a desktop and honest without one.** With a keyring it is keyring-backed; headless, the same code reports unavailable and **refuses to store rather than downgrading silently**. @@ -3435,7 +3452,7 @@ had already been asked. | **O1** | Initial key setup in the pre-proof window — deferred; that window is where C4 and C5b came from | | **O2** | A LAN enrolment door — one endpoint, bounded window, one-time code, closing permanently on success | | **O3** | `device_policy {allow_bundle: false}`, signed by a pinned key — **the mechanism that actually closes C4** (§3.7) | -| **O4** | Isolating the node-admin panel from the process holding user keys | +| **O4** | Isolating the node-admin panel from the process holding user keys. **Narrowed**: the panel reaches the node through named operations, and what widens the node is confirmed natively (§8.2); it still runs in the renderer that parses node content | | **O5** | An unlock key in the environment, for the **node** | | **O6** | The engine version floor, verified rather than assumed | | **O8** | A minimum client version in the hub version endpoint — **done** (§5.6) | diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index a802ae1..e190a92 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -203,6 +203,13 @@ any. So the application is the one to prefer for anything you care about. The browser stays, and is a perfectly reasonable way to use MeshBay — being able to open a group on someone else's laptop with nothing installed is worth having. +The application asks in a small window of its own, not in the page, before it +hosts a group on this computer, shares a folder you did not pick in its folder +dialog, replaces a group's key, clears the denylist, or points the node at +another account. A folder you pick in the folder dialog is not asked about +twice. That window belongs to the application, so nothing displayed in the page +— which shows content from other people's nodes — can answer it for you. + --- ## 4. Joining a group diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index f8bb3f4..80f49e4 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -30,6 +30,7 @@ const fsp = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); +const vm = require('node:vm'); // Linux window managers/desktop shells (GNOME's dash included) group and // icon-match a running window by its WM_CLASS, resolved against an installed @@ -737,13 +738,85 @@ function createWindow() { // renderer parses decrypted content from nodes, which is attacker-controlled // input, so it is treated as hostile even though it is our own code. +// Every channel answers the packaged interface's own top-level document and +// nothing else. The preload is not injected into subframes, so today nothing +// else holds the bridge; this is what keeps that true if a frame, a second +// window or a navigation ever gets one by another route. +function fromOurPage(event) { + const frame = event.senderFrame; + if (!frame || frame.parent) return false; + try { + const url = new URL(frame.url); + return url.protocol === `${SCHEME}:` && url.host === 'meshbay'; + } catch { return false; } +} + +function handle(channel, fn) { + ipcMain.handle(channel, (event, ...args) => { + if (!fromOurPage(event)) throw new Error('Refused: not the MeshBay interface'); + return fn(event, ...args); + }); +} + +// ── Native confirmation ───────────────────────────────────────────────────── +// +// What widens what the local node shares or admits, or replaces its group key, +// is confirmed by a dialog this process draws. A confirmation drawn by the page +// is one a script in the page can answer for itself, and the page parses +// content from nodes. The words come from the interface's own catalogues, read +// here from the packaged files; the facts in them (a folder, a group, an +// account) are filled in by this process. The page chooses the language and +// nothing else. + +let uiLocale = 'en'; + +function readCatalogue(code) { + try { + const source = fs.readFileSync(path.join(UI_DIR, 'locales', `${code}.js`), 'utf8'); + const box = { module: {} }; + vm.runInNewContext(source.replace(/^export default /m, 'module.exports = '), box, + { timeout: 1000 }); + return box.module.exports || {}; + } catch { return {}; } +} + +function nativeText(key, params = {}) { + const pick = (cat) => { + const entry = cat[key]; + return typeof entry === 'string' ? entry : (entry && entry.other) || null; + }; + let text = pick(readCatalogue(uiLocale)) || pick(readCatalogue('en')) || key; + // split/join, as in i18n.js: a folder name may contain `$&`. + for (const [k, v] of Object.entries(params)) text = text.split(`{${k}}`).join(String(v)); + return text; +} + +async function confirmNatively(key, params) { + const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; + const options = { + type: 'question', message: nativeText(key, params), noLink: true, + buttons: [nativeText('dialog.ok'), nativeText('dialog.cancel')], defaultId: 0, cancelId: 1, + }; + const { response } = win ? await dialog.showMessageBox(win, options) + : await dialog.showMessageBox(options); + // The keyboard goes back to the page explicitly: after a dialog, Chromium can + // leave the document unfocused and every keystroke then goes nowhere, which + // is why the interface draws its own confirmations (ask.js). + if (win && !win.isDestroyed()) { win.focus(); win.webContents.focus(); } + return response === 0; +} + +async function confirmOrRefuse(key, params) { + if (!await confirmNatively(key, params)) throw new Error(nativeText('native.declined')); +} + 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) => { + 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 @@ -795,7 +868,7 @@ function registerBridge() { // 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) => { + 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 @@ -833,7 +906,7 @@ function registerBridge() { }; }); - ipcMain.handle('ice:resolve-stun', async (_e, urls) => { + handle('ice:resolve-stun', async (_e, urls) => { const dns = require('node:dns').promises; const list = Array.isArray(urls) ? urls : []; const out = []; @@ -853,7 +926,7 @@ function registerBridge() { // 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) => { + handle('window:minimize-to-tray', (_e, labels) => { if (!trayOS() || !mainWindow) return false; ensureTray(labels); mainWindow.hide(); @@ -866,18 +939,18 @@ function registerBridge() { // 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) => { + handle('tray:labels', (_e, labels) => { if (!trayOS()) return false; ensureTray(labels); return true; }); - ipcMain.handle('device:ensure', () => ensureDeviceKey()); - ipcMain.handle('device:public', () => { + handle('device:ensure', () => ensureDeviceKey()); + handle('device:public', () => { const key = deviceKey(); return key ? publicKeyB64(key) : null; }); - ipcMain.handle('device:sign', (_e, username) => { + handle('device:sign', (_e, username) => { const key = deviceKey(); if (!key) return null; const timestamp = Math.floor(Date.now() / 1000); @@ -890,26 +963,27 @@ function registerBridge() { signature: crypto.sign(null, message, key).toString('base64'), }; }); - ipcMain.handle('device:forget', () => { + 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; + // The store itself is not reachable from the page. It holds the device's hub + // key, which the page is never handed (above), and nothing in the interface + // reads or writes anything else in it: a generic get/set by name was a way + // to both read that key and replace it. What the page may know is whether + // the OS protects the store. + handle('secrets:backend', () => secretsBackend()); + + // Which catalogue native confirmations are worded from. A language code and + // nothing else: an unknown one leaves the current language in place. + handle('ui:locale', (_e, code) => { + const c = String(code || ''); + if (/^[a-z]{2}(-[A-Z]{2})?$/.test(c) + && fs.existsSync(path.join(UI_DIR, 'locales', `${c}.js`))) uiLocale = c; + return uiLocale; }); // Downloads are written to disk as they arrive — never collected in memory @@ -953,7 +1027,7 @@ function registerBridge() { throw new Error(`No free name for ${filename}`); } - ipcMain.handle('folder:choose', async () => { + handle('folder:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); @@ -978,7 +1052,7 @@ function registerBridge() { try { return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; } } - ipcMain.handle('folder:get', () => { + 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 @@ -986,22 +1060,29 @@ function registerBridge() { return { name: chosen || defaultDownloadDir(), isDefault: !chosen }; }); - ipcMain.handle('folder:forget', () => { + handle('folder:forget', () => { config = { ...config, downloadDir: '' }; writeConfig(config); return true; }); - ipcMain.handle('root:choose', async () => { + // A folder chosen here is one the person pointed at in a dialog this process + // drew, which is the consent that sharing it needs: the node is given it + // without asking again. A folder the page names that was not chosen here is + // confirmed natively before the node hears of it (`node:op`). + const pickedFolders = new Set(); + + 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]; + pickedFolders.add(path.resolve(chosen)); return { path: chosen, name: path.basename(chosen) }; }); - ipcMain.handle('save:begin', async (_e, suggestedName, opts) => { + handle('save:begin', async (_e, suggestedName, opts) => { const wanted = path.basename(String(suggestedName || 'download')); const chosen = chosenDownloadDir(); let target = null; @@ -1047,7 +1128,7 @@ function registerBridge() { return { id, name: path.basename(target), path: target }; }); - ipcMain.handle('save:write', async (_e, id, chunk) => { + 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 @@ -1059,7 +1140,7 @@ function registerBridge() { return true; }); - ipcMain.handle('save:end', async (_e, id) => { + handle('save:end', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); @@ -1076,14 +1157,14 @@ function registerBridge() { return true; }); - ipcMain.handle('save:open', async (_e, id) => { + 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) => { + handle('save:abort', async (_e, id) => { const sink = sinks.get(String(id)); if (!sink) return false; sinks.delete(String(id)); @@ -1136,7 +1217,7 @@ function registerBridge() { } } - ipcMain.handle('node:detect', async () => { + handle('node:detect', async () => { const nc = readNodeConfig(); if (!nc) return { detected: false, configured: false }; const token = readNodeToken(nc.dataDir); @@ -1439,7 +1520,7 @@ function registerBridge() { // 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) { + async function linkNodeKeyAndAwaitRunning(link, deadline) { let linked = false; let last = null; while (Date.now() < deadline) { @@ -1447,13 +1528,13 @@ function registerBridge() { if (last && last.status === 'running') return last; // Whatever it is waiting for: a node backing off a 429 still needs its // key linked before its next attempt can succeed. - if (last && !linked && opts && opts.token && opts.hubUrl + if (last && !linked && link.token && link.hubUrl && last.pk_node_ed25519 && last.status !== 'starting') { try { - const r = await fetch(`${opts.hubUrl}/v1/users/me/node_key`, { + const r = await fetch(`${link.hubUrl}/v1/users/me/node_key`, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${opts.token}` }, + 'Authorization': `Bearer ${link.token}` }, body: JSON.stringify({ pk_node_ed25519: last.pk_node_ed25519 }), signal: AbortSignal.timeout(5000), }); @@ -1465,7 +1546,7 @@ function registerBridge() { return last; } - ipcMain.handle('node:installed', async () => { + handle('node:installed', async () => { if (process.platform === 'win32') { const [bin, svc] = await Promise.all([findNodeBinary(), winServiceTaskStatus()]); return { @@ -1487,7 +1568,7 @@ function registerBridge() { return { installed: Boolean(bin) }; }); - ipcMain.handle('node:bundled', () => hasBundledNode()); + 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 @@ -1500,7 +1581,7 @@ function registerBridge() { nodeService = { status: nodeServiceStatus, stop: nodeServiceStop, restart: nodeServiceRestart }; - ipcMain.handle('node:service-status', () => nodeServiceStatus()); + 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 @@ -1582,7 +1663,7 @@ function registerBridge() { }); } - ipcMain.handle('node:service-stop', () => nodeServiceStop()); + handle('node:service-stop', () => nodeServiceStop()); async function nodeServiceStop() { if (process.platform === 'win32') { @@ -1608,7 +1689,7 @@ function registerBridge() { return { stopped: true }; } - ipcMain.handle('node:service-restart', () => nodeServiceRestart()); + handle('node:service-restart', () => nodeServiceRestart()); async function nodeServiceRestart() { if (process.platform === 'win32') { @@ -1634,7 +1715,7 @@ function registerBridge() { } // Install / remove the Windows Startup-folder launcher, and query it. - ipcMain.handle('node:autostart', async (_e, action) => { + handle('node:autostart', async (_e, action) => { if (process.platform !== 'win32') return { supported: false }; if (action === 'install') { // Both would start the node: at boot, then again at sign-in. @@ -1656,7 +1737,7 @@ function registerBridge() { // 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) => { + 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}`); @@ -1732,6 +1813,43 @@ function registerBridge() { // sequences. pathlib on the node reads the `/` form fine. const tomlPath = (p) => p.split(path.sep).join('/'); + // What the page may ask the node to run as: a name the hub would register, + // which is also a string that cannot break out of a TOML string. The hub is + // never the page's to name -- it is the one this application is signed in to. + const USERNAME_RE = /^[\p{L}\p{N}._-]{1,64}$/u; + const tomlString = (s) => JSON.stringify(String(s)); + const sameHub = (a, b) => String(a || '').trim().replace(/\/+$/, '') + === String(b || '').trim().replace(/\/+$/, ''); + + function provisionedAs() { + try { + const text = fs.readFileSync(nodeConfigPath(), 'utf8'); + const url = text.match(/^url\s*=\s*"([^"]*)"/m); + const user = text.match(/^username\s*=\s*"([^"]*)"/m); + return url && user ? { hubUrl: url[1], username: user[1] } : null; + } catch { return null; } + } + + // Pointing a node already set up for one account at another stops it serving + // that account's groups, so it is the person's decision, asked natively. Not + // asked when nothing changes, which is every start but the first on a machine + // with one account. + async function provisionFromRequest(opts) { + const hubUrl = String(config.hubBase || ''); + if (!opts || !opts.username || !hubUrl) return null; + const username = String(opts.username); + if (!USERNAME_RE.test(username)) throw new Error('Refused: not a username'); + const current = provisionedAs(); + if (current && (!sameHub(current.hubUrl, hubUrl) || current.username !== username)) { + await confirmOrRefuse('native.node_account_confirm', { + current: `${current.username} @ ${current.hubUrl}`, + next: `${username} @ ${hubUrl}`, + }); + } + provisionNode(hubUrl, username); + return hubUrl; + } + function provisionNode(hubUrl, username) { const configDir = meshbayConfigDir(); const dataDir = meshbayDataDir(); @@ -1741,15 +1859,17 @@ function registerBridge() { const configFile = nodeConfigPath(); if (fs.existsSync(configFile)) { const content = fs.readFileSync(configFile, 'utf8'); + // Functions, not strings, as replacements: `$&` in a string replacement + // is a pattern, and these values are not the code's own. const updated = content - .replace(/^url\s*=\s*"[^"]*"/m, `url = "${hubUrl}"`) - .replace(/^username\s*=\s*"[^"]*"/m, `username = "${username}"`); + .replace(/^url\s*=\s*"[^"]*"/m, () => `url = ${tomlString(hubUrl)}`) + .replace(/^username\s*=\s*"[^"]*"/m, () => `username = ${tomlString(username)}`); fs.writeFileSync(configFile, updated, { mode: 0o600 }); } else { const toml = [ '[hub]', - `url = "${hubUrl}"`, - `username = "${username}"`, + `url = ${tomlString(hubUrl)}`, + `username = ${tomlString(username)}`, '', '[node]', 'quic_enabled = false # QUIC direct path; no client uses it yet', @@ -1770,14 +1890,19 @@ function registerBridge() { } } - ipcMain.handle('node:start', async (_e, opts) => { + handle('node:start', async (_e, opts) => { const already = await probeNode(); if (already && already.status === 'running') { return { started: true, ...already }; } + // Provisioned only where a node can be started from here (below). + const startable = process.platform === 'win32' || process.platform === 'linux'; + const hubUrl = (startable && await provisionFromRequest(opts)) + || String(config.hubBase || ''); + const link = { token: opts && opts.token, hubUrl }; + if (process.platform === 'win32') { - if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username); // Restarted, not merely started: provisionNode() may just have pointed // the node at another hub or account, which it only reads at start. // The CLI stops whatever runs (in any session, gracefully first), starts @@ -1807,7 +1932,7 @@ function registerBridge() { ? p // 90s: a node caught in the hub's per-minute sign-in limit waits out // the rest of that minute plus its 10s back-off before trying again. - : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 90000); + : await linkNodeKeyAndAwaitRunning(link, Date.now() + 90000); if (!ready) { // It answered once and then stopped answering: it is not running, and // saying "started but could not link" sent the reader after the link. @@ -1830,10 +1955,6 @@ function registerBridge() { 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; @@ -1924,14 +2045,14 @@ function registerBridge() { // Daemon is up but stuck on hub auth — link the key so it can proceed. // Whatever it is waiting for, 'waiting_for_hub' included (see probeNode). - if (!keyLinked && opts && opts.token && result.pk_node_ed25519 && + if (!keyLinked && link.token && link.hubUrl && result.pk_node_ed25519 && result.status !== 'starting') { try { const lr = await fetch( - `${opts.hubUrl}/v1/users/me/node_key`, { + `${link.hubUrl}/v1/users/me/node_key`, { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${opts.token}` }, + 'Authorization': `Bearer ${link.token}` }, body: JSON.stringify({ pk_node_ed25519: result.pk_node_ed25519 }), signal: AbortSignal.timeout(5000), @@ -1944,11 +2065,110 @@ function registerBridge() { 'meshbay-node was started but did not become ready within 60 seconds'); }); - ipcMain.handle('node:call', async (_e, method, apiPath, body) => { + // The local node's control API, by operation name. The page used to name a + // method and a path, which made every route of the loopback API -- present + // and future -- the page's to call with this process's token. Now it names + // one of the operations the interface performs, each with its arguments + // checked here, and the path is built here. Ids are ids and names are + // encoded, so no argument can reach another route. + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const anId = (v, what) => { + const s = String(v ?? ''); + if (!UUID_RE.test(s)) throw new Error(`Refused: ${what} is not an id`); + return s; + }; + const aText = (v, what, max = 255) => { + const s = String(v ?? ''); + if (!s || s.length > max) throw new Error(`Refused: ${what}`); + return s; + }; + const anObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {}); + const aCount = (v, fallback) => { + const n = Number(v); + return Number.isInteger(n) && n >= 0 ? n : fallback; + }; + const group = (a) => `/api/groups/${anId(a.groupId, 'the group')}`; + const root = (a) => `${group(a)}/roots/${encodeURIComponent(aText(a.rootName, 'the folder name'))}`; + + // Sharing a folder the person did not choose in this process's dialog. + async function confirmFolder(folder) { + if (!pickedFolders.has(path.resolve(folder))) { + await confirmOrRefuse('native.folder_confirm', { path: folder }); + } + } + + const NODE_OPS = { + status: () => ['GET', '/api/status'], + groups: () => ['GET', '/api/groups'], + indexStatus: () => ['GET', '/api/index-status'], + groupIndexStatus: (a) => ['GET', `${group(a)}/index-status`], + reload: () => ['POST', '/api/reload'], + attachGroup: async (a) => { + const body = { name: aText(a.name, 'the group name'), + shared_dir: aText(a.path, 'the folder', 4096), + writable: a.writable !== false }; + await confirmOrRefuse('native.attach_confirm', + { name: body.name, path: body.shared_dir }); + return ['POST', '/api/groups/attach', body]; + }, + detachGroup: (a) => ['POST', '/api/groups/detach', { name: aText(a.name, 'the group name') }], + addRoot: async (a) => { + const body = { path: aText(a.path, 'the folder', 4096) }; + if (a.name) body.name = aText(a.name, 'the folder name'); + if (a.writable !== undefined) body.writable = Boolean(a.writable); + if (a.removable !== undefined) body.removable = Boolean(a.removable); + const target = `${group(a)}/roots`; + await confirmFolder(body.path); + return ['POST', target, body]; + }, + updateRoot: (a) => ['PATCH', root(a), anObject(a.updates)], + ejectRoot: (a) => ['PUT', `${root(a)}/eject`], + plugRoot: (a) => ['PUT', `${root(a)}/plug`], + removeRoot: (a) => ['DELETE', root(a)], + // Creating a missing key replaces nothing -- the node keeps an existing one + // -- so only a rotation is asked about. + initGek: async (a) => { + const target = group(a); + if (a.rotate) { + await confirmOrRefuse('node.gek_rotate_confirm'); + return ['POST', `${target}/gek?rotate=true`]; + } + return ['POST', `${target}/gek`]; + }, + pairOperator: () => ['POST', '/api/operator/pair'], + roster: (a) => ['GET', a.groupId + ? `/api/roster?group_id=${anId(a.groupId, 'the group')}` : '/api/roster'], + unpinMember: (a) => ['POST', `/api/members/${anId(a.userId, 'the member')}/unpin`], + revokeMember: (a) => ['POST', `/api/members/${anId(a.userId, 'the member')}/revoke` + + `?group_id=${anId(a.groupId, 'the group')}`], + denylist: () => ['GET', '/api/denylist'], + // Re-admits whoever the entries were keeping out. + clearDenylist: async (a) => { + const subject = String(a.subject ?? '').slice(0, 255); + await confirmOrRefuse('node.denylist_clear_confirm', + { subject: subject || nativeText('node.denylist_clear_all') }); + return ['POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`]; + }, + setNodeSettings: (a) => ['PUT', '/api/node-settings', anObject(a.settings)], + peers: () => ['GET', '/api/peers'], + audit: (a) => { + let q = `limit=${aCount(a.limit, 50)}&offset=${aCount(a.offset, 0)}`; + if (a.event) q += `&event=${encodeURIComponent(aText(a.event, 'the event'))}`; + return ['GET', `/api/audit?${q}`]; + }, + indexCache: () => ['GET', '/api/index-cache'], + pruneIndexCache: () => ['POST', '/api/index-cache/prune'], + unlink: () => ['DELETE', '/api/unlink'], + }; + + handle('node:op', async (_e, name, args) => { + const op = Object.hasOwn(NODE_OPS, String(name)) ? NODE_OPS[String(name)] : null; + if (!op) throw new Error(`Refused: unknown node operation ${String(name)}`); if (!_nodeToken) throw new Error('Node not detected'); + const [method, apiPath, body] = await op(anObject(args)); const sep = apiPath.includes('?') ? '&' : '?'; const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`; - const init = { method: String(method).toUpperCase() }; + const init = { method }; if (body !== undefined && body !== null) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); @@ -1965,13 +2185,13 @@ function registerBridge() { return data; }); - ipcMain.handle('node:pairing-code', async () => { + handle('node:pairing-code', async () => { const code = _nodePairingCode; _nodePairingCode = null; return code; }); - ipcMain.handle('node:set-pairing-code', async (_e, code) => { + handle('node:set-pairing-code', async (_e, code) => { _nodePairingCode = code || null; return true; }); @@ -1983,7 +2203,7 @@ function registerBridge() { // 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) => { + handle('cast:start', async (_e, opts) => { return castRelay.start({ codec: opts.codec, initSegment: opts.initSegment ? Buffer.from(opts.initSegment) : null, @@ -1994,26 +2214,26 @@ function registerBridge() { // Carried separately from `cast:start` for the viewer who turns subtitles on // without seeking: the relay keeps serving the same video while the receiver // is told to load again with the new track. - ipcMain.handle('cast:subtitle', async (_e, sub) => { + handle('cast:subtitle', async (_e, sub) => { return castRelay.setSubtitle(sub || null); }); - ipcMain.handle('cast:push', async (_e, data) => { + handle('cast:push', async (_e, data) => { castRelay.pushSegment(Buffer.from(data)); return true; }); - ipcMain.handle('cast:stop', async () => { + handle('cast:stop', async () => { await castRelay.stop(); return true; }); - ipcMain.handle('cast:finish', async () => { + handle('cast:finish', async () => { castRelay.finish(); return true; }); - ipcMain.handle('cast:status', async () => ({ + handle('cast:status', async () => ({ active: castRelay.active, url: castRelay.url, subtitle: castRelay.subtitle, @@ -2022,21 +2242,21 @@ function registerBridge() { // ── Chromecast discovery + control ────────────────────────────────────── - ipcMain.handle('cast:discover', async () => { + handle('cast:discover', async () => { return castChromecast.discover(); }); - ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { + handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { return castChromecast.connect(deviceId, mediaUrl, subtitle === undefined ? castRelay.subtitle : subtitle); }); - ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => { + handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => { return castChromecast.reload(mediaUrl, subtitle === undefined ? castRelay.subtitle : subtitle); }); - ipcMain.handle('cast:chromecast:disconnect', async () => { + handle('cast:chromecast:disconnect', async () => { await castChromecast.disconnect(); return true; }); diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index c2a2bd4..632718b 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -55,6 +55,11 @@ contextBridge.exposeInMainWorld('meshbay', { // again after a language change. setTrayLabels: (labels) => ipcRenderer.invoke('tray:labels', labels), + // The interface's language, so the confirmations the main process draws for + // itself are worded in it. A code, never text: the words are read from the + // packaged catalogues by the main process. + setLocale: (code) => ipcRenderer.invoke('ui:locale', code), + // Ask the main process to call the hub. The renderer has an `app://` origin, // which CORS refuses and which is not a credential anyway. fetch: (url, init) => ipcRenderer.invoke('hub:fetch', url, init), @@ -75,10 +80,9 @@ contextBridge.exposeInMainWorld('meshbay', { forget: () => ipcRenderer.invoke('device:forget'), }, + // Whether the OS protects what the main process stores. The store itself is + // not reachable from here: it holds the device key above. secrets: { - get: (name) => ipcRenderer.invoke('secrets:get', name), - set: (name, value) => ipcRenderer.invoke('secrets:set', name, value), - clear: (name) => ipcRenderer.invoke('secrets:clear', name), // 'unprotected_fallback' means safeStorage found no keyring and is using a // fixed key. Encrypted on disk, by a key that is not a secret — the // interface says so rather than letting someone believe otherwise. @@ -100,8 +104,9 @@ contextBridge.exposeInMainWorld('meshbay', { }, // The local node, if one is running. The renderer never sees the session - // token — it names an operation and the main process executes it, the same - // pattern as hub:fetch. + // token, and never names a route: it names one of the operations the + // interface performs, the main process checks its arguments, builds the + // request and confirms natively what widens what the node shares. node: { detect: () => ipcRenderer.invoke('node:detect'), installed: () => ipcRenderer.invoke('node:installed'), @@ -110,13 +115,13 @@ contextBridge.exposeInMainWorld('meshbay', { // PATH. Windows only; other platforms always resolve true. bundled: () => ipcRenderer.invoke('node:bundled'), start: (opts) => ipcRenderer.invoke('node:start', opts), - call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body), + op: (name, args) => ipcRenderer.invoke('node:op', name, args), pairingCode: () => ipcRenderer.invoke('node:pairing-code'), setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), // The daemon's lifecycle as seen from outside it: the systemd unit (Linux) // or, on Windows, a probe of the daemon plus whether the Startup launcher // is in place — reachable even while the daemon itself is stopped or - // crash-looping, which `call()` above is not. + // crash-looping, which `op()` above is not. service: { status: () => ipcRenderer.invoke('node:service-status'), stop: () => ipcRenderer.invoke('node:service-stop'), diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 3a85bf7..a12fea4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1397,6 +1397,9 @@ const mount = () => { // browser and on macOS. A language change reloads the page, which comes back // through here, so nothing else has to watch for it. platform.setTrayLabels(trayLabels()).catch(() => {}); + // Same moment, same reason: the app's own confirmation dialogs are worded + // from the catalogue of the language this page settled on. + platform.setUiLocale(getLocale()).catch(() => {}); }; initLocale().then(mount, (err) => { // Nothing in initLocale() is supposed to reject. If something does, an diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index 7556010..d4c2ab8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -253,11 +253,11 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl const mainRoot = roots[0]; const attachBody = { name: name.trim(), - shared_dir: mainRoot.path, + path: mainRoot.path, writable: mainRoot.writable !== false, }; - await platform.node.call('POST', '/api/groups/attach', attachBody); - await platform.node.call('POST', '/api/reload'); + await platform.node.op('attachGroup', attachBody); + await platform.node.op('reload'); update('done'); advance(); @@ -272,8 +272,9 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl update('running'); for (let i = 1; i < roots.length; i++) { const r = roots[i]; - await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, { - path: r.path, name: r.name, writable: !!r.writable, removable: !!r.removable, + await withRetry(() => platform.node.op('addRoot', { + groupId: gid, path: r.path, name: r.name, + writable: !!r.writable, removable: !!r.removable, })); } await platform.waitForRootsIndexed(gid, setIndexProgress); @@ -283,20 +284,20 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl // 5. GEK init update('running'); - await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`)); + await withRetry(() => platform.node.op('initGek', { groupId: gid })); update('done'); advance(); // 6. Generate pairing code update('running'); - const pairResult = await platform.node.call('POST', '/api/operator/pair'); + const pairResult = await platform.node.op('pairOperator'); if (pairResult && pairResult.code) { await platform.node.setPairingCode(pairResult.code); session.pendingJoinCode = pairResult.code; } update('done'); - try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ } + try { await platform.node.op('reload'); } catch { /* best effort */ } setStep(3); if (onCreated) onCreated(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 048f831..f16bdf8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -122,9 +122,6 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, const overLoopback = !isLocal && !overMnp && nodeAvail; const canEdit = isLocal || overMnp || overLoopback; - const rootUrl = (name, suffix = '') => - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix; - // Deliberately no index refresh after a root change. // // Adding a root makes the node reload, which rescans — minutes on a real @@ -162,7 +159,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, })); const ok = await run(async () => { if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn); - else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates); + else if (overLoopback) await platform.node.op('updateRoot', { groupId, rootName, updates }); else throw new Error(t('node.root_no_route')); }); // Only a failure clears the patch here; a success waits for the node's @@ -177,13 +174,13 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, const doEjectRoot = useCallback((rootName) => run(async () => { if (overMnp) await transport.ejectRoot(groupId, rootName, signFn); - else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject')); + else if (overLoopback) await platform.node.op('ejectRoot', { groupId, rootName }); else throw new Error(t('node.root_no_route')); }), [overMnp, overLoopback, transport, groupId, signFn, run]); const doPlugRoot = useCallback((rootName) => run(async () => { if (overMnp) await transport.plugRoot(groupId, rootName, signFn); - else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug')); + else if (overLoopback) await platform.node.op('plugRoot', { groupId, rootName }); else throw new Error(t('node.root_no_route')); }), [overMnp, overLoopback, transport, groupId, signFn, run]); @@ -198,8 +195,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, const ok = await run(async () => { if (overMnp) await transport.removeRoot(groupId, rootName, signFn); else if (overLoopback) { - await platform.node.call('DELETE', rootUrl(rootName)); - await platform.node.call('POST', '/api/reload'); + await platform.node.op('removeRoot', { groupId, rootName }); + await platform.node.op('reload'); } else throw new Error(t('node.root_no_route')); }); if (ok) say(t('node.root_removed')); @@ -228,9 +225,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, if (overMnp) { await transport.addRoot(groupId, path, { name }, signFn); } else if (overLoopback) { - await platform.node.call('POST', '/api/groups/' + groupId + '/roots', - { path, name }); - await platform.node.call('POST', '/api/reload'); + await platform.node.op('addRoot', { groupId, path, name }); + await platform.node.op('reload'); await platform.watchIndexProgress(groupId, setIndexProgress); } else throw new Error(t('node.root_no_route')); }); @@ -468,7 +464,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const detect = await platform.node.detect(); if (!detect.detected) { setNodeDetected(false); return; } setNodeDetected(true); - const data = await platform.node.call('GET', '/api/groups'); + const data = await platform.node.op('groups'); const groups = data.groups || []; const ng = groups.find(g => g.id === groupId); if (ng) { @@ -496,7 +492,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const waitForRootCount = useCallback(async (expectedCount) => { for (let i = 0; i < 10; i++) { try { - const data = await platform.node.call('GET', '/api/groups'); + const data = await platform.node.op('groups'); const ng = (data.groups || []).find(g => g.id === groupId); const roots = (ng && ng.roots) || []; if (roots.length === expectedCount) { @@ -780,8 +776,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, try { if (platform.node.available) { try { - await platform.node.call('POST', - `/api/members/${member.user_id}/revoke?group_id=${groupId}`); + await platform.node.op('revokeMember', { userId: member.user_id, groupId }); } catch { /* best effort — node may not host this group */ } } else if (transport && transport.connected && operatorPaired) { const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; @@ -1311,8 +1306,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // Node detach first (reversible), then hub delete (irreversible) if (nodeDetected && nodeGroupName) { try { - await platform.node.call('POST', '/api/groups/detach', - { name: nodeGroupName }); + await platform.node.op('detachGroup', { name: nodeGroupName }); } catch (detachErr) { if (!await ask(t('settings_node.detach_failed_continue'))) return; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js index b136578..6730e60 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js @@ -54,7 +54,7 @@ function useLoopbackActivity() { const poll = async () => { let delay = IDLE_POLL_MS; try { - const data = await platform.node.call('GET', '/api/index-status'); + const data = await platform.node.op('indexStatus'); const next = {}; for (const g of (data && data.groups) || []) next[g.group_id] = fromLoopback(g); if (Object.values(next).some((j) => j.scanning || j.queued.length)) { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 3c0f1ec..e2363eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1240,4 +1240,10 @@ export default { 'hosts.approve': "Genehmigen", 'hosts.refuse': "Ablehnen", 'hosts.online': "online", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'Die Gruppe „{name}" auf diesem Computer hosten und den Ordner {path} mit ihren Mitgliedern teilen?', + 'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.', + 'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.', + 'native.declined': 'Abgebrochen — nichts wurde geändert.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 947c61d..2fdda50 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1221,4 +1221,10 @@ export default { 'hosts.approve': "Approve", 'hosts.refuse': "Refuse", 'hosts.online': "online", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'Host the group "{name}" on this computer and share the folder {path} with its members?', + 'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.', + 'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.', + 'native.declined': 'Cancelled — nothing was changed.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index a0220d8..498cba3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1234,4 +1234,10 @@ export default { 'hosts.approve': "Aprobar", 'hosts.refuse': "Rechazar", 'hosts.online': "en línea", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': '¿Alojar el grupo «{name}» en este ordenador y compartir la carpeta {path} con sus miembros?', + 'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.', + 'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.', + 'native.declined': 'Cancelado: no se ha cambiado nada.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index c4bc37e..c62ed98 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1249,4 +1249,10 @@ export default { 'hosts.approve': "Approuver", 'hosts.refuse': "Refuser", 'hosts.online': "en ligne", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'Héberger le groupe « {name} » sur cet ordinateur et partager le dossier {path} avec ses membres ?', + 'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.', + 'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.', + 'native.declined': 'Annulé — rien n\'a été modifié.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 59505b8..9f1d9f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1248,4 +1248,10 @@ export default { 'hosts.approve': "Approva", 'hosts.refuse': "Rifiuta", 'hosts.online': "online", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'Ospitare il gruppo «{name}» su questo computer e condividere la cartella {path} con i suoi membri?', + 'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.', + 'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.', + 'native.declined': 'Annullato: non è stato modificato nulla.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index bba9564..a4bb5ca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1232,4 +1232,10 @@ export default { 'hosts.approve': "承認", 'hosts.refuse': "拒否", 'hosts.online': "オンライン", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'このコンピューターでグループ「{name}」をホストし、フォルダー {path} をメンバーと共有しますか?', + 'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。', + 'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。', + 'native.declined': 'キャンセルしました。何も変更されていません。', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 87e5b87..2fdf236 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1250,4 +1250,10 @@ export default { 'hosts.approve': "Goedkeuren", 'hosts.refuse': "Weigeren", 'hosts.online': "online", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'De groep "{name}" op deze computer hosten en de map {path} met de leden delen?', + 'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.', + 'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.', + 'native.declined': 'Geannuleerd — er is niets gewijzigd.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 6d81b25..0530b79 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1276,4 +1276,10 @@ export default { 'hosts.approve': "Akceptuj", 'hosts.refuse': "Odrzuć", 'hosts.online': "online", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'Hostować grupę „{name}" na tym komputerze i udostępnić jej członkom folder {path}?', + 'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.', + 'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.', + 'native.declined': 'Anulowano — nic nie zostało zmienione.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 7b12ea5..d2be432 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1235,4 +1235,10 @@ export default { 'hosts.approve': "Aprovar", 'hosts.refuse': "Recusar", 'hosts.online': "online", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': 'Hospedar o grupo "{name}" neste computador e compartilhar a pasta {path} com os membros?', + 'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.', + 'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.', + 'native.declined': 'Cancelado — nada foi alterado.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 9f3c902..c994780 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1221,4 +1221,10 @@ export default { 'hosts.approve': "批准", 'hosts.refuse': "拒绝", 'hosts.online': "在线", + + // Worded by the desktop main process for its own dialogs (main.js). + 'native.attach_confirm': '在这台电脑上托管群组“{name}”,并与其成员共享文件夹 {path}?', + 'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。', + 'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。', + 'native.declined': '已取消,未做任何更改。', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 7fd7ab1..f940934 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -10,7 +10,7 @@ import { HUB } from './hub-client.js'; // ── Node management (D5) ──────────────────────────────────────────────────── // // Electron-only: talks to the local node daemon via its loopback HTTP API -// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain +// (platform.node.op), not over MNP/WebRTC. The MNP protocol types remain // for potential future browser-side use. // true / false from a node that has read its roster, null from one that has @@ -163,8 +163,8 @@ function NodeServicePanel({ onChanged, token, username }) { </div>`; } -async function nodeCall(method, path, body) { - return platform.node.call(method, path, body); +async function nodeOp(name, args) { + return platform.node.op(name, args); } // Hand a generated file to the user: native Save As on the desktop, a blob @@ -237,11 +237,11 @@ export function NodePage({ groups, token, username }) { setStatus('error'); return; } - const result = await nodeCall('GET', '/api/groups'); + const result = await nodeOp('groups'); setNodeGroups(result.groups || []); setOperatorPaired(pairedFrom(result)); setNodeSettings(result.settings || null); - try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} + try { setNodeInfo(await nodeOp('status')); } catch {} setStatus('connected'); } catch (err) { setError(platform.bridgeMessage(err)); @@ -259,11 +259,11 @@ export function NodePage({ groups, token, username }) { const refresh = useCallback(async () => { try { - const result = await nodeCall('GET', '/api/groups'); + const result = await nodeOp('groups'); setNodeGroups(result.groups || []); setOperatorPaired(pairedFrom(result)); setNodeSettings(result.settings || null); - try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} + try { setNodeInfo(await nodeOp('status')); } catch {} } catch {} }, []); @@ -283,8 +283,8 @@ export function NodePage({ groups, token, username }) { setBusy(true); setActionMsg(''); try { - await nodeCall('POST', `/api/groups/${groupId}/roots`, { path: chosen.path }); - await nodeCall('POST', '/api/reload'); + await nodeOp('addRoot', { groupId, path: chosen.path }); + await nodeOp('reload'); await refresh(); setActionMsg(t('node.root_added')); } catch (err) { @@ -299,8 +299,8 @@ export function NodePage({ groups, token, username }) { setBusy(true); setActionMsg(''); try { - await nodeCall('DELETE', `/api/groups/${groupId}/roots/${encodeURIComponent(rootName)}`); - await nodeCall('POST', '/api/reload'); + await nodeOp('removeRoot', { groupId, rootName }); + await nodeOp('reload'); await refresh(); setActionMsg(t('node.root_removed')); } catch (err) { @@ -313,7 +313,7 @@ export function NodePage({ groups, token, username }) { const loadRoster = useCallback(async (groupId) => { setBusy(true); try { - const result = await nodeCall('GET', `/api/roster?group_id=${groupId}`); + const result = await nodeOp('roster', { groupId }); setRoster(result); setRosterGroup(groupId); } catch (err) { @@ -328,7 +328,7 @@ export function NodePage({ groups, token, username }) { setBusy(true); setActionMsg(''); try { - await nodeCall('POST', `/api/members/${userId}/unpin`); + await nodeOp('unpinMember', { userId }); setActionMsg(t('node.unpin_done')); if (rosterGroup) await loadRoster(rosterGroup); } catch (err) { @@ -338,12 +338,13 @@ export function NodePage({ groups, token, username }) { } }, [rosterGroup, loadRoster]); + // No confirmation drawn here: replacing the key is asked natively by the + // app itself (node:op), which a script in this page cannot answer. const rotateGek = useCallback(async (groupId) => { - if (!await ask(t('node.gek_rotate_confirm'))) return; setBusy(true); setActionMsg(''); try { - await nodeCall('POST', `/api/groups/${groupId}/gek?rotate=true`); + await nodeOp('initGek', { groupId, rotate: true }); setActionMsg(t('node.gek_rotated')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); @@ -355,7 +356,7 @@ export function NodePage({ groups, token, username }) { const loadDenylist = useCallback(async () => { setBusy(true); try { - const result = await nodeCall('GET', '/api/denylist'); + const result = await nodeOp('denylist'); setDenylist(result); setShowDenylist(true); } catch (err) { @@ -366,12 +367,11 @@ export function NodePage({ groups, token, username }) { }, []); const clearDenylist = useCallback(async (subject) => { - const label = subject || t('node.denylist_clear_all'); - if (!await ask(t('node.denylist_clear_confirm', { subject: label }))) return; + // Asked natively by the app (node:op): it re-admits whoever it kept out. setBusy(true); setActionMsg(''); try { - await nodeCall('POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`); + await nodeOp('clearDenylist', { subject }); setActionMsg(t('node.denylist_cleared')); await loadDenylist(); } catch (err) { @@ -386,8 +386,8 @@ export function NodePage({ groups, token, username }) { setBusy(true); setActionMsg(''); try { - await nodeCall('POST', '/api/groups/detach', { name }); - await nodeCall('POST', '/api/reload'); + await nodeOp('detachGroup', { name }); + await nodeOp('reload'); setActionMsg(t('node.detached')); await refresh(); } catch (err) { @@ -401,7 +401,7 @@ export function NodePage({ groups, token, username }) { setBusy(true); setActionMsg(''); try { - await nodeCall('POST', '/api/reload'); + await nodeOp('reload'); setActionMsg(t('node.reloaded')); await refresh(); } catch (err) { @@ -416,7 +416,7 @@ export function NodePage({ groups, token, username }) { setSavingSettings(true); setActionMsg(''); try { - await nodeCall('PUT', '/api/node-settings', editSettings); + await nodeOp('setNodeSettings', { settings: editSettings }); setNodeSettings({ ...editSettings }); setActionMsg(t('node.settings_saved')); } catch (err) { @@ -431,7 +431,7 @@ export function NodePage({ groups, token, username }) { setSavingStun(true); setActionMsg(''); try { - await nodeCall('PUT', '/api/node-settings', { stun_servers: editStun }); + await nodeOp('setNodeSettings', { settings: { stun_servers: editStun } }); setNodeSettings(s => s ? { ...s, stun_servers: [...editStun] } : s); setActionMsg(t('node.stun_saved')); } catch (err) { @@ -486,7 +486,7 @@ export function NodePage({ groups, token, username }) { setSavingIce(true); setActionMsg(''); try { - await nodeCall('PUT', '/api/node-settings', { ice_interfaces: editIce }); + await nodeOp('setNodeSettings', { settings: { ice_interfaces: editIce } }); setNodeSettings(s => s ? { ...s, ice_interfaces: [...editIce] } : s); setActionMsg(t('node.ice_saved')); } catch (err) { @@ -521,7 +521,7 @@ export function NodePage({ groups, token, username }) { setPairBusy(true); setPairStatus(''); try { - const result = await nodeCall('POST', '/api/operator/pair'); + const result = await nodeOp('pairOperator'); if (result && result.code) { await platform.node.setPairingCode(result.code); } @@ -538,7 +538,7 @@ export function NodePage({ groups, token, username }) { const loadPeers = useCallback(async () => { setBusy(true); try { - const r = await nodeCall('GET', '/api/peers'); + const r = await nodeOp('peers'); setPeers(r.peers || []); } catch (err) { setActionMsg(platform.bridgeMessage(err)); @@ -551,9 +551,8 @@ export function NodePage({ groups, token, username }) { setBusy(true); try { const offset = auditPage * auditPageSize; - let path = `/api/audit?limit=${auditPageSize}&offset=${offset}`; - if (auditEvent) path += `&event=${encodeURIComponent(auditEvent)}`; - const r = await nodeCall('GET', path); + const r = await nodeOp('audit', { limit: auditPageSize, offset, + event: auditEvent || undefined }); setAudit(r.entries || []); setAuditHasMore(!!r.has_more); } catch (err) { @@ -572,12 +571,11 @@ export function NodePage({ groups, token, username }) { // mid-export (which shifts rows to a higher offset) cannot duplicate one. const PAGE = 1000; const MAX_PAGES = 1000; // 1M-row stop, so a bug cannot spin forever - const evq = auditEvent ? `&event=${encodeURIComponent(auditEvent)}` : ''; const seen = new Set(); const rows = []; for (let page = 0; page < MAX_PAGES; page++) { - const r = await nodeCall( - 'GET', `/api/audit?limit=${PAGE}&offset=${page * PAGE}${evq}`); + const r = await nodeOp('audit', { limit: PAGE, offset: page * PAGE, + event: auditEvent || undefined }); const batch = r.entries || []; for (const e of batch) { if (!seen.has(e.id)) { seen.add(e.id); rows.push(e); } @@ -613,7 +611,7 @@ export function NodePage({ groups, token, username }) { const loadCache = useCallback(async () => { setBusy(true); try { - const r = await nodeCall('GET', '/api/index-cache'); + const r = await nodeOp('indexCache'); setCacheCount(r.count ?? 0); } catch (err) { setActionMsg(platform.bridgeMessage(err)); @@ -626,7 +624,7 @@ export function NodePage({ groups, token, username }) { setBusy(true); setActionMsg(''); try { - const r = await nodeCall('POST', '/api/index-cache/prune'); + const r = await nodeOp('pruneIndexCache'); setCacheCount(r.kept ?? null); setActionMsg(t('node.maintenance_pruned', { n: r.removed ?? 0 })); } catch (err) { @@ -639,7 +637,7 @@ export function NodePage({ groups, token, username }) { const loadNodeRoster = useCallback(async () => { setBusy(true); try { - const r = await nodeCall('GET', '/api/roster'); + const r = await nodeOp('roster'); setNodeRoster(r); } catch (err) { setActionMsg(platform.bridgeMessage(err)); @@ -653,7 +651,7 @@ export function NodePage({ groups, token, username }) { setBusy(true); setActionMsg(''); try { - await nodeCall('POST', `/api/members/${userId}/unpin`); + await nodeOp('unpinMember', { userId }); setActionMsg(t('node.unpin_done')); await loadNodeRoster(); } catch (err) { @@ -668,7 +666,7 @@ export function NodePage({ groups, token, username }) { setUnlinkBusy(true); setActionMsg(''); try { - await nodeCall('DELETE', '/api/unlink'); + await nodeOp('unlink'); setActionMsg(t('node.unlink_done')); await refresh(); } catch (err) { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index a3fb80b..8bf09b4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -67,30 +67,15 @@ export const capabilities = { }; /** - * Where the identity keys live. + * The app's secret store, as far as the page may know it. * - * In a browser: exactly where they live today — IndexedDB and sessionStorage, - * with the keypair bundle on the node as the way a second browser recovers - * them, which is finding C4 and is the reason the app exists. - * - * In the app: the OS keychain, and no bundle is stored anywhere. That is what - * closes C4 for a native device — unconditionally for that device, and for the - * account only once it stops signing in from a browser too. + * The store is the main process's (the OS keychain through safeStorage) and it + * holds the device's hub key, which the page is never handed. The page used to + * be able to read and write it by name; nothing here did, and that was a way to + * both read the key and replace it. What is left is whether the OS protects it. */ export const secrets = { available: Boolean(bridge && bridge.secrets), - async get(name) { - if (!bridge || !bridge.secrets) return null; - return bridge.secrets.get(name); - }, - async set(name, value) { - if (!bridge || !bridge.secrets) return false; - return bridge.secrets.set(name, value); - }, - async clear(name) { - if (!bridge || !bridge.secrets) return false; - return bridge.secrets.clear(name); - }, /** * Whether the OS is really protecting them. * @@ -257,9 +242,15 @@ export const node = { if (!bridge || !bridge.node) throw new Error('Node bridge not available'); return bridge.node.start(opts); }, - async call(method, path, body) { + /** + * One of the local node's operations, by name (`NODE_OPS` in the desktop + * client's main.js). The page never names a route: the main process checks + * the arguments, builds the request, and asks the person itself before + * anything that widens what the node shares. + */ + async op(name, args) { if (!bridge || !bridge.node) throw new Error('Node bridge not available'); - return bridge.node.call(method, path, body); + return bridge.node.op(name, args); }, async pairingCode() { return bridge && bridge.node ? bridge.node.pairingCode() : null; @@ -344,7 +335,7 @@ export async function watchIndexProgress(groupId, onUpdate, { intervalMs = 500 } for (;;) { let status; try { - status = await node.call('GET', `/api/groups/${groupId}/index-status`); + status = await node.op('groupIndexStatus', { groupId }); } catch { // The node went away mid-poll — stop rather than spin forever; the // caller's own connection-status handling already covers that case. @@ -376,12 +367,12 @@ export async function waitForGroupHosted(groupId, onProgress, const deadline = Date.now() + timeoutMs; for (;;) { try { - const status = await node.call('GET', `/api/groups/${groupId}/index-status`); + const status = await node.op('groupIndexStatus', { groupId }); if (onProgress) onProgress(status); } catch { /* keep waiting — the loopback API can be momentarily busy */ } try { - const list = await node.call('GET', '/api/groups'); + const list = await node.op('groups'); if (Array.isArray(list.groups) && list.groups.some((g) => g.id === groupId)) return; } catch { /* keep waiting */ } @@ -420,7 +411,7 @@ export async function waitForRootsIndexed(groupId, onProgress, for (;;) { let status; try { - status = await node.call('GET', `/api/groups/${groupId}/index-status`); + status = await node.op('groupIndexStatus', { groupId }); } catch { return; // the node went away mid-poll — same stance as watchIndexProgress } @@ -507,9 +498,19 @@ export async function setTrayLabels(labels) { return bridge.setTrayLabels(labels); } +/** + * Tell the app which language the interface is in. The confirmations its main + * process draws for itself are worded from the same catalogues, which it reads + * from the packaged files -- only the code crosses the bridge. + */ +export async function setUiLocale(code) { + if (!bridge || !bridge.setLocale) return false; + return bridge.setLocale(code); +} + export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, bridgeMessage, folder, rootPicker, node, - cast, minimizeToTray, setTrayLabels }; + cast, minimizeToTray, setTrayLabels, setUiLocale }; // Also a global, because `transport.js` is loaded as a classic script — it // predates the module graph and exposes `MeshBayTransport` the same way. The @@ -519,5 +520,5 @@ if (typeof window !== 'undefined') { window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, bridgeMessage, folder, rootPicker, node, - cast, minimizeToTray, setTrayLabels }; + cast, minimizeToTray, setTrayLabels, setUiLocale }; } diff --git a/packages/meshbay-hub/tests/test_connect_never_hangs.py b/packages/meshbay-hub/tests/test_connect_never_hangs.py index 5680877..1fb4d3d 100644 --- a/packages/meshbay-hub/tests/test_connect_never_hangs.py +++ b/packages/meshbay-hub/tests/test_connect_never_hangs.py @@ -69,7 +69,7 @@ def test_a_timed_out_gathering_still_sends_the_offer(): @pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present") def test_every_hub_call_from_the_client_has_a_deadline(): source = MAIN.read_text(encoding="utf-8") - block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0] + block = source.split("handle('hub:fetch'", 1)[1].split("\n handle(", 1)[0] assert "AbortSignal.timeout" in block, ( "a hub that accepts the connection and says nothing holds this for " "as long as the OS allows") @@ -88,5 +88,5 @@ def test_the_deadline_outlasts_the_hubs_own_longest_call(): @pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present") def test_a_timeout_says_so_rather_than_saying_fetch_failed(): source = MAIN.read_text(encoding="utf-8") - block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0] + block = source.split("handle('hub:fetch'", 1)[1].split("\n handle(", 1)[0] assert "TimeoutError" in block and "did not answer" in block diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index b9b3319..732ba07 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -18,7 +18,7 @@ import re from pathlib import Path import pytest -from spa_source import transport_files +from spa_source import STATIC, transport_files CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" MAIN = CLIENT / "src" / "main.js" @@ -378,6 +378,81 @@ def test_plain_http_is_refused_except_to_loopback(): assert "127\\." in source and "localhost" in source +def test_every_channel_answers_only_the_packaged_page(): + """ + A channel registered straight on `ipcMain` would answer any frame that got + hold of a bridge; `handle()` checks the sender first. So no channel may be + registered any other way, and every one the preload names is registered. + """ + source = _main() + wrapper = source.split("function handle(channel, fn) {", 1)[1].split("\n}\n", 1)[0] + assert "fromOurPage(event)" in wrapper + assert source.count("ipcMain.handle(") == 1, "a channel skips the sender check" + registered = set(re.findall(r"\n handle\('([\w:-]+)'", source)) + invoked = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", _preload())) + assert invoked <= registered, f"the preload names unregistered channels: {invoked - registered}" + + +def test_the_page_cannot_read_or_replace_the_secret_store(): + """It holds the device's hub key (§8.2), which the page is never handed.""" + for channel in ("secrets:get", "secrets:set", "secrets:clear"): + assert channel not in _main() and channel not in _preload(), channel + + +def _node_ops() -> dict[str, str]: + """Each operation of `NODE_OPS` in main.js, with its source.""" + block = _main().split("const NODE_OPS = {", 1)[1].split("\n };\n", 1)[0] + parts = re.split(r"\n (\w+):", "\n" + block) + return dict(zip(parts[1::2], parts[2::2])) + + +def test_the_page_names_node_operations_not_routes(): + """ + The page used to hand the main process a method and a path, which made the + whole loopback API the page's. Every operation the interface calls exists, + and none exists that it does not call — an unused one is surface. + """ + assert "node:call" not in _main() and "node:call" not in _preload() + used: set[str] = set() + for path in STATIC.glob("*.js"): + used |= set(re.findall(r"(?:node\.op|nodeOp)\('(\w+)'", path.read_text(encoding="utf-8"))) + defined = set(_node_ops()) + assert used, "no node operation found in the interface" + assert used <= defined, f"called but not defined: {used - defined}" + assert defined <= used, f"defined but never called: {defined - used}" + + +@pytest.mark.parametrize("op", ["attachGroup", "addRoot", "initGek", "clearDenylist"]) +def test_what_widens_the_node_is_confirmed_natively(op): + """Sharing a folder, hosting a group, replacing the key, re-admitting a + revoked subject: asked by a dialog the main process draws, which a script in + the page cannot answer. A folder chosen in the native picker is its own + confirmation.""" + body = _node_ops()[op] + assert "confirmOrRefuse(" in body or "confirmFolder(" in body + + +def test_the_native_dialogs_are_worded_in_every_language(): + """The words come from the interface's catalogues; a key missing from one is + a dialog that shows its key.""" + keys = set(re.findall(r"(?:confirmOrRefuse|nativeText)\('([\w.]+)'", _main())) + assert "native.declined" in keys and "dialog.ok" in keys + for catalogue in (STATIC / "locales").glob("*.js"): + text = catalogue.read_text(encoding="utf-8") + missing = [k for k in keys if f"'{k}':" not in text] + assert not missing, f"{catalogue.name} lacks {missing}" + + +def test_the_node_is_never_pointed_at_a_hub_the_page_names(): + """`node:start` writes the hub this application is signed in to, and a + username that cannot break out of a TOML string.""" + source = _main() + assert "opts.hubUrl" not in source + provision = source.split("async function provisionFromRequest(opts) {", 1)[1] + provision = provision.split("\n }\n", 1)[0] + assert "config.hubBase" in provision and "USERNAME_RE.test(username)" in provision + + # ── One interface, one source ─────────────────────────────────────────────── def test_the_interface_is_copied_not_forked(): @@ -420,7 +495,7 @@ def test_automatic_saving_never_opens_a_dialog_for_want_of_a_folder(): system Downloads folder is the answer when there is no other. """ source = _main() - begin = source.split("ipcMain.handle('save:begin'", 1)[1].split("ipcMain.handle", 1)[0] + begin = source.split("handle('save:begin'", 1)[1].split("\n handle(", 1)[0] assert "defaultDownloadDir()" in begin, ( "the automatic path has no destination when no folder was chosen") assert "app.getPath('downloads')" in source @@ -430,7 +505,7 @@ def test_a_chosen_folder_that_has_gone_is_not_silently_replaced(): """Someone who picked an external drive should be told it is not there, not find the film in their home directory a week later.""" source = _main() - begin = source.split("ipcMain.handle('save:begin'", 1)[1].split("ipcMain.handle", 1)[0] + begin = source.split("handle('save:begin'", 1)[1].split("\n handle(", 1)[0] assert "config.downloadDir && !chosen" in begin, ( "a chosen-but-missing folder falls through to the default instead of asking") assert "showSaveDialog" in begin diff --git a/packages/meshbay-hub/tests/test_indexing_dock.py b/packages/meshbay-hub/tests/test_indexing_dock.py index 93803a0..e960950 100644 --- a/packages/meshbay-hub/tests/test_indexing_dock.py +++ b/packages/meshbay-hub/tests/test_indexing_dock.py @@ -190,5 +190,5 @@ def test_the_transport_passes_the_new_counters_through(): def test_the_dock_polls_the_node_wide_route_not_one_group(): source = DOCK.read_text(encoding="utf-8") - assert "'/api/index-status'" in source - assert "/index-status`" not in source + assert "node.op('indexStatus')" in source + assert "groupIndexStatus" not in source diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py index b011802..9eb8339 100644 --- a/packages/meshbay-node/tests/test_ops.py +++ b/packages/meshbay-node/tests/test_ops.py @@ -314,7 +314,7 @@ async def test_reload_config_without_fn_is_refused(tmp_path): async def test_start_reload_returns_before_reload_fn_finishes(tmp_path): """The loopback route uses this one: a brand-new group's initial scan can take minutes, and the Electron bridge caps every loopback call at - 30s (main.js node:call) — start_reload must not block on it.""" + 30s (main.js node:op) — start_reload must not block on it.""" state = _state(tmp_path) release = asyncio.Event() called = [] diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index cc9f8cc..96351db 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -349,7 +349,7 @@ def test_a_service_restart_waits_for_the_old_instance_before_starting_one(): def test_node_start_refuses_a_node_of_another_version(): main_js = MAIN_JS.read_text(encoding="utf-8") - body = main_js.split("ipcMain.handle('node:start'", 1)[1] + body = main_js.split("handle('node:start'", 1)[1] body = body[:body.index("process.platform !== 'linux'")] assert "p.version !== app.getVersion()" in body assert body.index("waitForNode(") < body.index("p.version !== app.getVersion()") \ @@ -726,8 +726,8 @@ def test_service_mode_toggle_elevates_the_same_script_the_installer_runs(): assert "-Verb RunAs" in fn or "'-Verb', 'RunAs'" in fn or "-Verb', 'RunAs'" in fn assert "Get-Credential" not in fn - handler = main_js.split("ipcMain.handle('node:service-mode'", 1)[1] - handler = handler[:handler.index("ipcMain.handle(")] + handler = main_js.split("handle('node:service-mode'", 1)[1] + handler = handler[:handler.index("\n handle(")] assert "winElevateServiceMode" in handler assert "'install'" in handler or '"install"' in handler assert "'remove'" in handler or '"remove"' in handler @@ -793,7 +793,7 @@ def test_every_start_stop_and_restart_goes_through_the_cli(): assert "killNodeProcesses()" in _fn_body(main_js, "async function nodeServiceStop()") assert "winNodeStartVia(['restart-daemon'])" in _fn_body( main_js, "async function nodeServiceRestart()") - start = main_js.split("ipcMain.handle('node:start'", 1)[1] + start = main_js.split("handle('node:start'", 1)[1] start = start.split("process.platform !== 'linux'", 1)[0] assert "winNodeStartVia(['restart-daemon'])" in start for gone in ("spawnNodeDetached", "winServiceTaskEnd", "winServiceTaskRun"): @@ -834,8 +834,8 @@ def test_node_start_provisions_before_it_starts_anything(): safety if the order were reversed. """ main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") - body = main_js.split("ipcMain.handle('node:start'", 1)[1] - body = body[:body.index("ipcMain.handle(")] + body = main_js.split("handle('node:start'", 1)[1] + body = body[:body.index("\n handle(")] provision_at = body.index("provisionNode(") start_at = body.index("winNodeStartVia(") @@ -857,8 +857,8 @@ def test_node_start_links_the_node_key_on_windows_not_only_linux(): node started. """ main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") - body = main_js.split("ipcMain.handle('node:start'", 1)[1] - body = body[:body.index("ipcMain.handle(")] + body = main_js.split("handle('node:start'", 1)[1] + body = body[:body.index("\n handle(")] win_branch = body.split("process.platform === 'win32'", 1)[1] win_branch = win_branch[:win_branch.index("process.platform !== 'linux'")] assert "linkNodeKeyAndAwaitRunning" in win_branch, ( @@ -1193,7 +1193,7 @@ def test_node_bundled_ipc_channel_exists_end_to_end(): """main.js handles it, preload.js exposes it, platform.js wraps it -- the same three-layer shape every other node.* capability already has.""" main = MAIN_JS.read_text(encoding="utf-8") - assert "ipcMain.handle('node:bundled'" in main + assert "handle('node:bundled'" in main assert "hasBundledNode()" in main preload = PRELOAD_JS.read_text(encoding="utf-8") @@ -1588,12 +1588,12 @@ def test_switching_modes_stops_the_node_first_and_brings_it_back(): """Removing the service left its node running in session 0, unstoppable; installing it started a second node that found the port taken and quit.""" main_js = MAIN_JS.read_text(encoding="utf-8") - body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0] + body = main_js.split("handle('node:service-mode'", 1)[1].split("\n handle(", 1)[0] assert body.index("killNodeProcesses()") < body.index("winElevateServiceMode(action)") after = body.split("winElevateServiceMode(action)", 1)[1] assert "wasRunning" in after and "winNodeStartVia(['autostart', 'start'])" in after - autostart = main_js.split("ipcMain.handle('node:autostart'", 1)[1] - autostart = autostart.split("ipcMain.handle(", 1)[0] + autostart = main_js.split("handle('node:autostart'", 1)[1] + autostart = autostart.split("\n handle(", 1)[0] assert "winServiceTaskStatus()).installed" in autostart, ( "the sign-in launcher must refuse while the boot task exists") @@ -1603,7 +1603,7 @@ def test_a_declined_prompt_leaves_the_node_as_it_was(): answered for two minutes -- used to leave it stopped, groups offline, with the mode unchanged. Found by letting the prompt time out on a real install.""" main_js = MAIN_JS.read_text(encoding="utf-8") - body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0] + body = main_js.split("handle('node:service-mode'", 1)[1].split("\n handle(", 1)[0] guarded = body.split("await winElevateServiceMode(action);", 1)[1] catch = guarded.split("} catch (err) {", 1)[1].split("throw err;", 1)[0] assert "wasRunning" in catch and "winNodeStartVia(['restart-daemon'])" in catch |