summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-20 22:21:19 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-20 22:21:19 +0200
commit2e9490ca27047ae03e495d397abbe1aec1b2273a (patch)
tree26f850a88565846a139868a4b85c715734751a41 /packages/meshbay-client/src/main.js
parentc8af746c846b5dbc792f7e4f0d806647d513cc5c (diff)
downloadmeshbay-2e9490ca27047ae03e495d397abbe1aec1b2273a.tar.gz
feat: unified group management, public groups, and activity-based sidebar
Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces into a single multi-step page: group creation on hub, node attachment, root selection via folder picker, GEK initialization, and auto-pairing — all in one flow. Browser SPA keeps its current behavior unchanged. Public group support (Option A — GEK for all groups): - All groups have GEK regardless of visibility; open-join groups auto-admit via TOFU when join_policy is "open" - Key rotation blocked for public groups (API guard + UI hidden) - Hub signaling allows WebRTC offers for nodes hosting open-join groups even when the caller isn't a member yet - attach_group writes join_policy to node.toml - Daemon loads GEK for all groups, not just private ones - Known-device path in join_request now auto-admits to open-join groups Node loopback API bridge (Electron IPC): - node:detect, node:call, node:pairing-code IPC handlers in main process - Renderer never sees tokens, paths, or keys (session token = physical access) - platform.js node namespace for UI consumption - Loopback endpoints: roots CRUD, member-upload toggle, reload Bug fixes: - Root change detection: removed premature ctx["roots"] updates from add_root and remove_root that prevented indexer retarget on reload - Duplicate offline message: global fallback now gated on !group - Signaling membership check: fallback to open-join groups for non-members Sidebar groups sorted by last_activity_at (most recent first): - New Group.last_activity_at column with Alembic migration - POST /v1/groups/{id}/activity endpoint, called on connect and chat send - Client-side sort + throttled hub updates (1/min) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/main.js')
-rw-r--r--packages/meshbay-client/src/main.js95
1 files changed, 95 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index e7e7b15..f2ec645 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -643,6 +643,101 @@ function registerBridge() {
try { fs.unlinkSync(sink.path); } catch { /* already gone */ }
return true;
});
+
+ // ── Node loopback bridge ─────────────────────────────────────────────────
+ //
+ // The renderer never sees the session token. It names an operation and this
+ // process executes it — the same pattern as hub:fetch. The token is read
+ // from the daemon's data directory, cached for the lifetime of this process,
+ // and never exposed through the preload.
+
+ let _nodeToken = null;
+ let _nodePort = 18000;
+ let _nodePairingCode = null;
+
+ function nodeConfigPath() {
+ return path.join(os.homedir(), '.config', 'meshbay', 'node.toml');
+ }
+
+ function readNodeConfig() {
+ try {
+ const text = fs.readFileSync(nodeConfigPath(), 'utf8');
+ let dataDir = path.join(os.homedir(), '.local', 'share', 'meshbay');
+ let uiPort = 18000;
+ const dataMatch = text.match(/^\s*data_dir\s*=\s*"([^"]+)"/m);
+ if (dataMatch) {
+ dataDir = dataMatch[1].replace(/^~/, os.homedir());
+ }
+ const portMatch = text.match(/^\s*ui_port\s*=\s*(\d+)/m);
+ if (portMatch) uiPort = parseInt(portMatch[1], 10);
+ return { dataDir, uiPort };
+ } catch {
+ return null;
+ }
+ }
+
+ function readNodeToken(dataDir) {
+ try {
+ return fs.readFileSync(path.join(dataDir, 'ui-token'), 'utf8').trim();
+ } catch {
+ return null;
+ }
+ }
+
+ ipcMain.handle('node:detect', async () => {
+ const nc = readNodeConfig();
+ if (!nc) return { detected: false };
+ const token = readNodeToken(nc.dataDir);
+ if (!token) return { detected: false };
+ _nodeToken = token;
+ _nodePort = nc.uiPort;
+ try {
+ const r = await fetch(
+ `http://127.0.0.1:${_nodePort}/api/status?t=${_nodeToken}`,
+ { signal: AbortSignal.timeout(3000) });
+ if (!r.ok) return { detected: false };
+ const status = await r.json();
+ return {
+ detected: true,
+ status: status.status,
+ pk_node_ed25519: status.pk_node_ed25519 || '',
+ };
+ } catch {
+ return { detected: false };
+ }
+ });
+
+ ipcMain.handle('node:call', async (_e, method, apiPath, body) => {
+ if (!_nodeToken) throw new Error('Node not detected');
+ const sep = apiPath.includes('?') ? '&' : '?';
+ const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`;
+ const init = { method: String(method).toUpperCase() };
+ if (body !== undefined && body !== null) {
+ init.headers = { 'Content-Type': 'application/json' };
+ init.body = JSON.stringify(body);
+ }
+ init.signal = AbortSignal.timeout(30000);
+ const r = await fetch(url, init);
+ const text = await r.text();
+ let data;
+ try { data = JSON.parse(text); } catch { data = text; }
+ if (!r.ok) {
+ const msg = (data && data.error) || (data && data.detail) || text;
+ throw new Error(`Node ${r.status}: ${msg}`);
+ }
+ return data;
+ });
+
+ ipcMain.handle('node:pairing-code', async () => {
+ const code = _nodePairingCode;
+ _nodePairingCode = null;
+ return code;
+ });
+
+ ipcMain.handle('node:set-pairing-code', async (_e, code) => {
+ _nodePairingCode = code || null;
+ return true;
+ });
}
/**