aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md21
-rw-r--r--docs/USERGUIDE.md7
-rw-r--r--packages/meshbay-client/src/main.js362
-rw-r--r--packages/meshbay-client/src/preload.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/index-dock.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js74
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js57
-rw-r--r--packages/meshbay-hub/tests/test_connect_never_hangs.py4
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py81
-rw-r--r--packages/meshbay-hub/tests/test_indexing_dock.py4
-rw-r--r--packages/meshbay-node/tests/test_ops.py2
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py26
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