From 2e9490ca27047ae03e495d397abbe1aec1b2273a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 20 Aug 2026 22:21:19 +0200 Subject: feat: unified group management, public groups, and activity-based sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-client/src/main.js | 95 ++++++++++++++++++++++++++++++++++ packages/meshbay-client/src/preload.js | 10 ++++ 2 files changed, 105 insertions(+) (limited to 'packages/meshbay-client') 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; + }); } /** diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index e3af68f..04dfe44 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -80,6 +80,16 @@ contextBridge.exposeInMainWorld('meshbay', { choose: () => ipcRenderer.invoke('root:choose'), }, + // 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. + node: { + detect: () => ipcRenderer.invoke('node:detect'), + call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body), + pairingCode: () => ipcRenderer.invoke('node:pairing-code'), + setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), + }, + // A sink that writes to disk as chunks arrive, never a buffer handed over at // the end. `auto` uses the remembered folder without a dialog, which is what // "save automatically" means; without one, or when the person asked to be -- cgit v1.2.3