aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 12:57:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 12:57:58 +0200
commit2c6921aa2c35ffd41b6c453e6700574ef631ba2c (patch)
tree01c766e13607e4f957900bfd36b4f722e8c8b3c5 /packages/meshbay-client/src
parent8a4651e9d223de856ff085b329801998f95db138 (diff)
downloadmeshbay-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>
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/main.js362
-rw-r--r--packages/meshbay-client/src/preload.js19
2 files changed, 303 insertions, 78 deletions
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'),