diff options
Diffstat (limited to 'packages/meshbay-hub/src')
16 files changed, 594 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index d18d9a4..044457b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -298,7 +298,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, // ── Sidebar ────────────────────────────────────────────────────────────────── -function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) { +function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey }) { const isStaff = role === 'moderator' || role === 'admin'; return html` <aside class="sidebar ${menuOpen ? 'open' : ''}"> @@ -339,13 +339,16 @@ function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) { // actually cares about. Anything else is "not known yet". const state = presence[g.id] ?? (g.node_online === true ? 'online' : g.node_online === false ? 'offline' : 'unknown'); + const label = state === 'indexing' + ? t('presence.indexing', { pct: indexProgressPct[g.id] ?? 0 }) + : t('presence.' + state); return html` <a key=${g.id} class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}" href="#/group/${g.id}"> <span class="presence presence-${state}" - title="${t('presence.' + state)}" - aria-label="${t('presence.' + state)}"></span> + title="${label}" + aria-label="${label}"></span> <span class="sidebar-item-name">${g.name}</span> </a> `; @@ -840,6 +843,9 @@ function CreateGroupWizard({ token, username, onCreated }) { const [setupSteps, setSetupSteps] = useState([]); const [setupError, setSetupError] = useState(''); const [groupId, setGroupId] = useState(''); + // Bytes-based, not file-count-based: one 20 GB file finishing last must + // not read as "8 of 9 done" while it is still the only thing running. + const [indexProgress, setIndexProgress] = useState(null); const linkNodeKey = useCallback(async (pk) => { if (!pk) return; @@ -908,12 +914,14 @@ function CreateGroupWizard({ token, username, onCreated }) { const steps = [ { label: t('wizard.step_create_hub'), status: 'pending' }, { label: t('wizard.step_attach'), status: 'pending' }, + { label: t('wizard.step_index'), status: 'pending' }, ]; if (roots.length > 1) steps.push({ label: t('wizard.step_add_roots'), status: 'pending' }); steps.push({ label: t('wizard.step_gek'), status: 'pending' }); steps.push({ label: t('wizard.step_pair'), status: 'pending' }); setSetupSteps([...steps]); + setIndexProgress(null); let si = 0; const update = (status) => { @@ -922,6 +930,27 @@ function CreateGroupWizard({ token, username, onCreated }) { }; const advance = () => { si++; }; + // Every step from here on is scoped to the group the node just attached. + // The node hot-loads a brand-new group synchronously — scan included — + // before it is added to groups_ctx or its own in-memory config + // (daemon.py _reload_config_inner: the config swap is the *last* thing + // that function does, a beat after the scan, not atomic with it) — so a + // call that lands in that beat gets refused even though the wait above + // already reported the scan as done. A handful of short retries absorbs + // that gap without a real cross-process synchronization primitive. + const withRetry = async (fn, attempts = 5, delayMs = 400) => { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + const msg = String((err && err.message) || ''); + const notHostedYet = /not configured on this node|not hosted on this node/i.test(msg); + if (!notHostedYet || i === attempts - 1) throw err; + await new Promise((r) => setTimeout(r, delayMs)); + } + } + }; + try { // 1. Create group on hub update('running'); @@ -942,32 +971,48 @@ function CreateGroupWizard({ token, username, onCreated }) { attachBody.upload_dir = mainRoot.path; } await platform.node.call('POST', '/api/groups/attach', attachBody); + // Fire-and-forget on the node's side (ui/app.py) — this call itself + // returns immediately, well before the scan below finishes. It used + // to be the thing the wizard waited on, which is exactly what made + // "Attaching to node" time out on a real library (see ops.start_reload). await platform.node.call('POST', '/api/reload'); update('done'); advance(); - // 3. Add extra roots (if >1) + // 3. Wait for the node's own initial scan of this group to finish — + // the group is not usable for anything below (extra roots, GEK) until + // this finishes, so nobody lands on a page that looks broken, or hits + // a "not configured" error from racing ahead of it. Can take tens of + // minutes on a slow disk (see the StarWars benchmark) — the node + // keeps scanning on its own either way (test_hot_reload_survives_ + // client_close.py); this step is only about not lying about it. + update('running'); + await platform.waitForGroupHosted(gid, setIndexProgress); + update('done'); + advance(); + + // 4. Add extra roots (if >1) if (roots.length > 1) { update('running'); for (let i = 0; i < roots.length; i++) { if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue; const r = roots[i]; - await platform.node.call('POST', `/api/groups/${gid}/roots`, { + await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, { path: r.path, name: r.name, upload: i === uploadIdx, - }); + })); } update('done'); advance(); } - // 4. GEK init + // 5. GEK init update('running'); - await platform.node.call('POST', `/api/groups/${gid}/gek`); + await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`)); update('done'); advance(); - // 5. Generate pairing code + // 6. Generate pairing code update('running'); const pairResult = await platform.node.call('POST', '/api/operator/pair'); if (pairResult && pairResult.code) { @@ -976,7 +1021,7 @@ function CreateGroupWizard({ token, username, onCreated }) { } update('done'); - // Reload node config so it picks up the new group + // Reload once more so any roots added at step 4 are picked up. try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ } setStep(3); @@ -1105,6 +1150,9 @@ function CreateGroupWizard({ token, username, onCreated }) { // Step 2: Automatic setup progress if (step === 2) { + const pct = indexProgress && indexProgress.total_bytes + ? Math.min(100, Math.round(100 * indexProgress.scanned_bytes / indexProgress.total_bytes)) + : 0; return html`<div class="page-content"> <h2>${t('wizard.title')}</h2> <p class="page-message">${t('wizard.setting_up')}</p> @@ -1120,6 +1168,19 @@ function CreateGroupWizard({ token, username, onCreated }) { </div> `)} </div> + ${indexProgress && indexProgress.scanning && html` + <div class="index-progress" style="margin-top:12px"> + <div class="index-progress-bar"> + <div class="index-progress-fill" style="width:${pct}%"></div> + </div> + <div class="index-progress-label">${t('wizard.indexing_progress', { pct })}</div> + ${indexProgress.current_dir && html` + <div class="index-progress-dir"> + ${t('wizard.indexing_current_dir', { dir: indexProgress.current_dir })} + </div> + `} + </div> + `} ${setupError && html` <div class="error-msg" style="margin-top:16px">${setupError}</div> <div style="display:flex;gap:8px;margin-top:8px"> @@ -2835,8 +2896,15 @@ function App() { // for the session only: it is a cache of observations, not a source of truth, // and a reload should go back to asking. const [presence, setPresence] = useState({}); - const notePresence = useCallback((gid, state) => { + // Percentage alongside 'indexing' presence — kept separate from `presence` + // itself so a changing % does not require treating every tick as a new + // presence state (see the Sidebar dot's title/aria-label). + const [indexProgressPct, setIndexProgressPct] = useState({}); + const notePresence = useCallback((gid, state, pct) => { setPresence(prev => (prev[gid] === state ? prev : { ...prev, [gid]: state })); + if (pct !== undefined) { + setIndexProgressPct(prev => (prev[gid] === pct ? prev : { ...prev, [gid]: pct })); + } }, []); const handleLeftGroup = useCallback((gid) => { @@ -3084,6 +3152,7 @@ function App() { ${user && html`<${Sidebar} groups=${groups} presence=${presence} + indexProgressPct=${indexProgressPct} route=${route} menuOpen=${menuOpen} role=${user.role} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 1528213..29f3602 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -76,6 +76,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // every registered app when a node predates the setting (or hasn't answered // yet), so nothing disappears for an existing group. const [enabledApps, setEnabledApps] = useState(null); + // Reconcile interval / debounce currently in effect on the node — shown + // to the operator in Settings, not enforced from here (indexer.py owns + // that). Null until the handshake ack arrives. + const [scanSettings, setScanSettings] = useState(null); // Paired ≠ operator account. `is_node_admin` says the hub account owning this // node is the one connecting; this says the node pinned *this browser's* key // as an operator key. Only the second one lets you sign an invite, and only @@ -123,6 +127,25 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, cacheGroupIndex(groupId, group ? group.name : groupId, fresh); }, [groupId, group]); + // additions/deletions only (daemon.py _broadcast_index_change, once there + // is a previous snapshot to diff against) — applied on top of whatever + // applyIndex last put in `entries`, instead of replacing the whole table + // for one changed file. + const applyIndexDelta = useCallback((deltaMsg) => { + setEntries((prev) => { + const deletions = new Set(deltaMsg.deletions || []); + const kept = prev.filter((e) => !deletions.has(e.id)); + // The index is keyed by content hash: an addition whose id is already + // present is the same duplicate-content case indexer.py's own + // reconcile sweep leaves alone, not a second row for one file. + const keptIds = new Set(kept.map((e) => e.id)); + const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id)); + const fresh = kept.concat(additions); + cacheGroupIndex(groupId, group ? group.name : groupId, fresh); + return fresh; + }); + }, [groupId, group]); + useEffect(() => { let cancelled = false; @@ -172,11 +195,24 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setIsNodeAdmin(!!ack.is_node_admin); setMemberUpload(ack.member_upload !== false); setEnabledApps(ack.enabled_apps || null); + setScanSettings(ack.scan_settings || null); // Changed while we are connected, by an operator who may be someone // else entirely. Without this the button stays until a reconnection, // and a button that is still there is a button people press. transport.onUploadPolicy = (allowed) => setMemberUpload(allowed); transport.onAppsEnabled = (apps) => setEnabledApps(apps); + // The node's own scan (a root added while we were already connected, + // or reconcile catching one back up) — never the entries, just + // enough to animate the sidebar dot. Guaranteed a final push at the + // False transition (daemon.py _progress_pusher), so this always + // settles back to 'online' rather than getting stuck. + transport.onIndexProgress = (status) => { + if (cancelled || !onPresence) return; + const pct = status.total_bytes + ? Math.min(100, Math.round(100 * status.scanned_bytes / status.total_bytes)) + : 0; + onPresence(groupId, status.scanning ? 'indexing' : 'online', pct); + }; setOperatorPaired(transport.memberRole === 'operator'); // A first join to this node generated an identity for it; leave it with @@ -203,6 +239,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (cancelled) return; applyIndex(msg); }; + transport.onIndexDelta = (msg) => { + if (cancelled) return; + applyIndexDelta(msg); + }; // We are in: an invitation to this group has served its purpose. if (onJoined) onJoined(groupId); @@ -214,7 +254,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, touchActivity(); // First-hand evidence, and the strongest available: this browser spoke // to the node. It outranks whatever the hub said in the group list. - if (onPresence) onPresence(groupId, 'online'); + // A scan already under way at the moment of connecting (ack.indexing, + // webrtc_server.py _complete_handshake) shows as indexing right away + // rather than waiting for the next periodic push. + if (onPresence) { + const idx = ack.indexing; + if (idx && idx.scanning) { + const pct = idx.total_bytes + ? Math.min(100, Math.round(100 * idx.scanned_bytes / idx.total_bytes)) + : 0; + onPresence(groupId, 'indexing', pct); + } else { + onPresence(groupId, 'online'); + } + } } catch (err) { if (cancelled) return; @@ -448,6 +501,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onMemberUpload=${(allowed) => setMemberUpload(allowed)} enabledApps=${enabledApps} onEnabledApps=${(keys) => setEnabledApps(keys)} + scanSettings=${scanSettings} + onScanSettings=${(s) => setScanSettings(s)} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> `} 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 a14fd21..0b64a03 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -22,6 +22,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, memberUpload, onMemberUpload, enabledApps, onEnabledApps, + scanSettings, onScanSettings, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); @@ -36,6 +37,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const [nodeGroupName, setNodeGroupName] = useState(''); const [nodeBusy, setNodeBusy] = useState(false); const [nodeMsg, setNodeMsg] = useState(''); + // Bytes-based indexing progress while a newly added directory is being + // scanned — same source as the Create Group wizard's step, see + // platform.watchIndexProgress. + const [nodeIndexProgress, setNodeIndexProgress] = useState(null); const loadNodeInfo = useCallback(async () => { if (!platform.node.available) return; @@ -194,6 +199,51 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onEnabledApps, activeApps]); + const [scanBusy, setScanBusy] = useState(false); + const [scanMsg, setScanMsg] = useState(''); + const [reconcileMinutes, setReconcileMinutes] = useState( + scanSettings ? Math.round(scanSettings.reconcile_interval_secs / 60) : 10); + const [debounceSeconds, setDebounceSeconds] = useState( + scanSettings ? Math.round(scanSettings.debounce_secs) : 2); + // The node is the source of truth; once it has answered, the fields track + // it rather than whatever this browser guessed before connecting. + useEffect(() => { + if (!scanSettings) return; + setReconcileMinutes(Math.round(scanSettings.reconcile_interval_secs / 60)); + setDebounceSeconds(Math.round(scanSettings.debounce_secs)); + }, [scanSettings]); + + /** + * How often the reconciliation backstop runs, and how long a changed file + * is left alone before being hashed. Same shape as toggleApp: signed, and + * the fields do not claim success until the node has confirmed it. + */ + const saveScanSettings = useCallback(async () => { + const transport = transportRef && transportRef.current; + setScanMsg(''); + setScanBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setScanSettings(reconcileMinutes * 60, debounceSeconds, signFn); + const applied = { + reconcile_interval_secs: reconcileMinutes * 60, + debounce_secs: debounceSeconds, + }; + if (onScanSettings) onScanSettings(applied); + setScanMsg(t('settings_node.scan_saved')); + } catch (err) { + setScanMsg(err.message); + } finally { + setScanBusy(false); + } + }, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]); + const [removing, setRemoving] = useState(''); /** @@ -382,6 +432,37 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} + ${/* How hard the node works watching its own disk — indexer.py + DirectoryIndexer. A performance knob, not a permission: it + changes nothing about who can see or do what. */ + isNodeAdmin && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings_node.scan_title')}</h3> + <p class="settings-hint">${t('settings_node.scan_hint')}</p> + <div class="settings-row"> + <label class="settings-label"> + ${t('settings_node.scan_reconcile_label')} + <input type="number" min="1" max="1440" step="1" + value=${reconcileMinutes} disabled=${scanBusy} + onInput=${e => setReconcileMinutes(Number(e.target.value))} /> + </label> + </div> + <div class="settings-row"> + <label class="settings-label"> + ${t('settings_node.scan_debounce_label')} + <input type="number" min="0" max="300" step="1" + value=${debounceSeconds} disabled=${scanBusy} + onInput=${e => setDebounceSeconds(Number(e.target.value))} /> + </label> + </div> + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${scanBusy} onClick=${saveScanSettings}> + ${scanBusy ? t('settings_node.scan_saving') : t('settings_node.scan_save')} + </button> + ${scanMsg && html`<p class="settings-hint">${scanMsg}</p>`} + </div> + `} + ${/* Roots management (Electron-only, when node is local) */ nodeDetected && nodeRoots.length > 0 && html` <div class="settings-section"> @@ -425,12 +506,18 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, onClick=${async () => { const chosen = await platform.rootPicker.choose(); if (!chosen) return; - setNodeBusy(true); setNodeMsg(''); + setNodeBusy(true); setNodeMsg(''); setNodeIndexProgress(null); try { await platform.node.call('POST', '/api/groups/' + groupId + '/roots', { path: chosen.path, name: chosen.name }); await platform.node.call('POST', '/api/reload'); + // The root is already scanning in the background on the + // node regardless of whether anyone watches this — see + // the "closing the client" test in test_hot_reload_*.py. + // This is only about not leaving the operator staring at + // an unchanged screen while it happens. + await platform.watchIndexProgress(groupId, setNodeIndexProgress); setNodeMsg(t('node.root_added')); await loadNodeInfo(); } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } @@ -438,6 +525,28 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, }}> <${Icon} name="folder-plus" /> ${t('node.add_root')} </button> + ${nodeIndexProgress && nodeIndexProgress.scanning && html` + <div class="index-progress" style="margin-top:8px"> + <div class="index-progress-bar"> + <div class="index-progress-fill" style="width:${ + nodeIndexProgress.total_bytes + ? Math.min(100, Math.round( + 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes)) + : 0}%"></div> + </div> + <div class="index-progress-label">${t('wizard.indexing_progress', { + pct: nodeIndexProgress.total_bytes + ? Math.min(100, Math.round( + 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes)) + : 0, + })}</div> + ${nodeIndexProgress.current_dir && html` + <div class="index-progress-dir"> + ${t('wizard.indexing_current_dir', { dir: nodeIndexProgress.current_dir })} + </div> + `} + </div> + `} </div> </div> `} 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 59127af..866212f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -457,6 +457,7 @@ export default { 'presence.online': 'Ein Node, der diese Gruppe hostet, ist online', 'presence.offline': 'Kein Node für diese Gruppe erreichbar', 'presence.unknown': 'Noch nicht geprüft', + 'presence.indexing': 'Indizierung läuft — {pct}%', 'chat.load_older': '{n} ältere Nachrichten laden', 'chat.start_of_history': 'Anfang des Gesprächs', 'chat.today': 'Heute', @@ -564,6 +565,13 @@ export default { // Settings (node) 'settings_node.detach_failed_continue': 'Die Gruppe konnte nicht vom Node getrennt werden. Trotzdem auf dem Hub löschen?', 'settings_node.roots': 'Freigegebene Verzeichnisse', + 'settings_node.scan_title': 'Scan', + 'settings_node.scan_hint': 'Wie oft der Node seine Verzeichnisse erneut auf verpasste Änderungen prüft und wie lange er nach einer Änderung wartet, bevor eine Datei indiziert wird.', + 'settings_node.scan_reconcile_label': 'Prüfintervall (Minuten)', + 'settings_node.scan_debounce_label': 'Wartezeit nach einer Änderung (Sekunden)', + 'settings_node.scan_save': 'Speichern', + 'settings_node.scan_saving': 'Wird gespeichert…', + 'settings_node.scan_saved': 'Gespeichert.', // Create-group wizard 'wizard.title': 'Gruppe erstellen', @@ -586,6 +594,9 @@ export default { 'wizard.step_add_roots': 'Verzeichnisse hinzufügen', 'wizard.step_gek': 'Verschlüsselungsschlüssel initialisieren', 'wizard.step_pair': 'Kopplung einrichten', + 'wizard.step_index': 'Dateien werden indiziert', + 'wizard.indexing_progress': 'Indizierung… {pct}%', + 'wizard.indexing_current_dir': 'Wird gescannt: {dir}', 'wizard.done_title': 'Gruppe erstellt', 'wizard.done_message': 'Ihre Gruppe ist bereit. Ihr Node hostet sie und die Verschlüsselung ist eingerichtet.', 'wizard.go_to_group': 'Zur Gruppe', 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 58590b1..a83b067 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -364,6 +364,9 @@ export default { 'wizard.step_add_roots': 'Adding directories', 'wizard.step_gek': 'Initializing encryption key', 'wizard.step_pair': 'Setting up pairing', + 'wizard.step_index': 'Indexing files', + 'wizard.indexing_progress': 'Indexing… {pct}%', + 'wizard.indexing_current_dir': 'Scanning: {dir}', 'wizard.finish_later': 'Finish setup later', 'wizard.done_title': 'Group created', 'wizard.done_message': 'Your group is ready. Your node is hosting it and encryption is set up.', @@ -386,6 +389,13 @@ export default { // Unified Group Settings (node sections) 'settings_node.roots': 'Shared directories', 'settings_node.detach_failed_continue': 'Could not detach the group from the node. Delete on hub anyway?', + 'settings_node.scan_title': 'Scanning', + 'settings_node.scan_hint': 'How often the node re-checks its directories for changes it may have missed, and how long it waits after a file changes before indexing it.', + 'settings_node.scan_reconcile_label': 'Re-check interval (minutes)', + 'settings_node.scan_debounce_label': 'Wait after a change (seconds)', + 'settings_node.scan_save': 'Save', + 'settings_node.scan_saving': 'Saving…', + 'settings_node.scan_saved': 'Saved.', // Members 'members.col_role': 'Role', @@ -486,6 +496,7 @@ export default { 'presence.online': 'A node serving this group is online', 'presence.offline': 'No node is reachable for this group', 'presence.unknown': 'Not checked yet', + 'presence.indexing': 'Indexing — {pct}% done', 'chat.load_older': 'Load {n} older messages', 'chat.start_of_history': 'Beginning of the conversation', 'chat.today': 'Today', 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 75a67b6..1621182 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -452,6 +452,7 @@ export default { 'presence.online': 'Un node que sirve este grupo está en línea', 'presence.offline': 'Ningún node accesible para este grupo', 'presence.unknown': 'Aún sin comprobar', + 'presence.indexing': 'Indexando — {pct}%', 'chat.load_older': 'Cargar {n} mensajes anteriores', 'chat.start_of_history': 'Inicio de la conversación', 'chat.today': 'Hoy', @@ -559,6 +560,13 @@ export default { // Node settings 'settings_node.detach_failed_continue': 'No se pudo desconectar el grupo del node. ¿Eliminar del hub de todos modos?', 'settings_node.roots': 'Directorios compartidos', + 'settings_node.scan_title': 'Escaneo', + 'settings_node.scan_hint': 'Con qué frecuencia el node vuelve a comprobar sus directorios en busca de cambios que pudo haber pasado por alto, y cuánto tiempo espera tras un cambio antes de indexar un archivo.', + 'settings_node.scan_reconcile_label': 'Intervalo de comprobación (minutos)', + 'settings_node.scan_debounce_label': 'Espera tras un cambio (segundos)', + 'settings_node.scan_save': 'Guardar', + 'settings_node.scan_saving': 'Guardando…', + 'settings_node.scan_saved': 'Guardado.', // Create group wizard 'wizard.title': 'Crear grupo', @@ -581,6 +589,9 @@ export default { 'wizard.step_add_roots': 'Añadiendo directorios', 'wizard.step_gek': 'Inicializando clave de cifrado', 'wizard.step_pair': 'Configurando emparejamiento', + 'wizard.step_index': 'Indexando archivos', + 'wizard.indexing_progress': 'Indexando… {pct}%', + 'wizard.indexing_current_dir': 'Analizando: {dir}', 'wizard.done_title': 'Grupo creado', 'wizard.done_message': 'Su grupo está listo. Su node lo aloja y el cifrado está configurado.', 'wizard.go_to_group': 'Ir al grupo', 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 d60c5d0..6e21a06 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -456,6 +456,7 @@ export default { 'presence.online': 'Un node servant ce groupe est en ligne', 'presence.offline': 'Aucun node joignable pour ce groupe', 'presence.unknown': 'Pas encore vérifié', + 'presence.indexing': 'Indexation en cours — {pct} %', 'chat.load_older': 'Charger {n} messages plus anciens', 'chat.start_of_history': 'Début de la conversation', 'chat.today': 'Aujourd’hui', @@ -575,6 +576,13 @@ export default { // Node settings 'settings_node.detach_failed_continue': 'Impossible de détacher le groupe du node. Supprimer quand même sur le hub ?', 'settings_node.roots': 'Répertoires partagés', + 'settings_node.scan_title': 'Analyse', + 'settings_node.scan_hint': 'À quelle fréquence le node revérifie ses répertoires à la recherche de changements manqués, et combien de temps il attend après une modification avant d\'indexer un fichier.', + 'settings_node.scan_reconcile_label': 'Intervalle de revérification (minutes)', + 'settings_node.scan_debounce_label': 'Attente après un changement (secondes)', + 'settings_node.scan_save': 'Enregistrer', + 'settings_node.scan_saving': 'Enregistrement…', + 'settings_node.scan_saved': 'Enregistré.', // Create group wizard 'wizard.title': 'Créer un groupe', @@ -597,6 +605,9 @@ export default { 'wizard.step_add_roots': 'Ajout des répertoires', 'wizard.step_gek': 'Initialisation de la clé de chiffrement', 'wizard.step_pair': 'Mise en place de l\'appariement', + 'wizard.step_index': 'Indexation des fichiers', + 'wizard.indexing_progress': 'Indexation… {pct} %', + 'wizard.indexing_current_dir': 'Analyse : {dir}', 'wizard.done_title': 'Groupe créé', 'wizard.done_message': 'Votre groupe est prêt. Votre node l\'héberge et le chiffrement est configuré.', 'wizard.go_to_group': 'Aller au groupe', 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 b65e2ce..edd15ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -454,6 +454,7 @@ export default { 'presence.online': 'Un node che ospita questo gruppo è online', 'presence.offline': 'Nessun node raggiungibile per questo gruppo', 'presence.unknown': 'Non ancora verificato', + 'presence.indexing': 'Indicizzazione in corso — {pct}%', 'chat.load_older': 'Carica {n} messaggi precedenti', 'chat.start_of_history': 'Inizio della conversazione', 'chat.today': 'Oggi', @@ -573,6 +574,13 @@ export default { // Settings — node 'settings_node.detach_failed_continue': 'Impossibile scollegare il gruppo dal node. Eliminare comunque dal hub?', 'settings_node.roots': 'Directory condivise', + 'settings_node.scan_title': 'Scansione', + 'settings_node.scan_hint': 'Con quale frequenza il node ricontrolla le sue directory per cambiamenti che potrebbe aver perso, e quanto tempo attende dopo una modifica prima di indicizzare un file.', + 'settings_node.scan_reconcile_label': 'Intervallo di ricontrollo (minuti)', + 'settings_node.scan_debounce_label': 'Attesa dopo una modifica (secondi)', + 'settings_node.scan_save': 'Salva', + 'settings_node.scan_saving': 'Salvataggio…', + 'settings_node.scan_saved': 'Salvato.', // Create-group wizard 'wizard.title': 'Crea gruppo', @@ -595,6 +603,9 @@ export default { 'wizard.step_add_roots': 'Aggiunta delle directory', 'wizard.step_gek': 'Inizializzazione della chiave di cifratura', 'wizard.step_pair': 'Configurazione del pairing', + 'wizard.step_index': 'Indicizzazione dei file', + 'wizard.indexing_progress': 'Indicizzazione… {pct}%', + 'wizard.indexing_current_dir': 'Scansione: {dir}', 'wizard.done_title': 'Gruppo creato', 'wizard.done_message': 'Il suo gruppo è pronto. Il suo node lo ospita e la cifratura è configurata.', 'wizard.go_to_group': 'Vai al gruppo', 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 e965590..00fffed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -442,6 +442,7 @@ export default { 'presence.online': 'このグループをホストする node が稼働中です', 'presence.offline': 'このグループに到達できる node がありません', 'presence.unknown': '未確認', + 'presence.indexing': 'インデックス中 — {pct}%', 'chat.load_older': '以前のメッセージを {n} 件読み込む', 'chat.start_of_history': '会話のはじまり', 'chat.today': '今日', @@ -557,6 +558,13 @@ export default { // Settings — node 'settings_node.detach_failed_continue': 'node からグループを切り離せませんでした。それでも hub 上で削除しますか?', 'settings_node.roots': '共有ディレクトリ', + 'settings_node.scan_title': 'スキャン', + 'settings_node.scan_hint': 'node が見逃した変更がないかディレクトリを再確認する頻度と、ファイルの変更後にインデックスするまで待つ時間。', + 'settings_node.scan_reconcile_label': '再確認の間隔(分)', + 'settings_node.scan_debounce_label': '変更後の待機時間(秒)', + 'settings_node.scan_save': '保存', + 'settings_node.scan_saving': '保存中…', + 'settings_node.scan_saved': '保存しました。', // Wizard 'wizard.title': 'グループを作成', @@ -579,6 +587,9 @@ export default { 'wizard.step_add_roots': 'ディレクトリを追加中', 'wizard.step_gek': '暗号化鍵を初期化中', 'wizard.step_pair': 'ペアリングをセットアップ中', + 'wizard.step_index': 'ファイルをインデックス中', + 'wizard.indexing_progress': 'インデックス中… {pct}%', + 'wizard.indexing_current_dir': 'スキャン中: {dir}', 'wizard.done_title': 'グループを作成しました', 'wizard.done_message': 'グループの準備ができました。node がホストし、暗号化が設定されています。', 'wizard.go_to_group': 'グループを開く', 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 b37be08..2837ebb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -456,6 +456,7 @@ export default { 'presence.online': 'Een node die deze groep host is online', 'presence.offline': 'Geen node bereikbaar voor deze groep', 'presence.unknown': 'Nog niet gecontroleerd', + 'presence.indexing': 'Bezig met indexeren — {pct}%', 'chat.load_older': '{n} oudere berichten laden', 'chat.start_of_history': 'Begin van het gesprek', 'chat.today': 'Vandaag', @@ -575,6 +576,13 @@ export default { // Node settings 'settings_node.detach_failed_continue': 'Kon de groep niet van de node loskoppelen. Toch op de hub verwijderen?', 'settings_node.roots': 'Gedeelde mappen', + 'settings_node.scan_title': 'Scannen', + 'settings_node.scan_hint': 'Hoe vaak de node zijn mappen opnieuw controleert op wijzigingen die zijn gemist, en hoe lang hij na een wijziging wacht voordat een bestand wordt geïndexeerd.', + 'settings_node.scan_reconcile_label': 'Controle-interval (minuten)', + 'settings_node.scan_debounce_label': 'Wachttijd na een wijziging (seconden)', + 'settings_node.scan_save': 'Opslaan', + 'settings_node.scan_saving': 'Bezig met opslaan…', + 'settings_node.scan_saved': 'Opgeslagen.', // Create group wizard 'wizard.title': 'Groep aanmaken', @@ -597,6 +605,9 @@ export default { 'wizard.step_add_roots': 'Mappen toevoegen', 'wizard.step_gek': 'Versleutelingssleutel initialiseren', 'wizard.step_pair': 'Koppeling instellen', + 'wizard.step_index': 'Bestanden indexeren', + 'wizard.indexing_progress': 'Bezig met indexeren… {pct}%', + 'wizard.indexing_current_dir': 'Scannen: {dir}', 'wizard.done_title': 'Groep aangemaakt', 'wizard.done_message': 'Uw groep is klaar. Uw node host de groep en de versleuteling is ingesteld.', 'wizard.go_to_group': 'Naar groep', 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 b445f79..760f29c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -469,6 +469,7 @@ export default { 'presence.online': 'Node hostujący tę grupę jest dostępny', 'presence.offline': 'Brak osiągalnego node dla tej grupy', 'presence.unknown': 'Jeszcze nie sprawdzono', + 'presence.indexing': 'Indeksowanie — {pct}%', 'chat.load_older': 'Wczytaj {n} starszych wiadomości', 'chat.start_of_history': 'Początek rozmowy', 'chat.today': 'Dziś', @@ -596,6 +597,13 @@ export default { // Settings — node 'settings_node.detach_failed_continue': 'Nie udało się odłączyć grupy od node. Usunąć mimo to na hub?', 'settings_node.roots': 'Katalogi współdzielone', + 'settings_node.scan_title': 'Skanowanie', + 'settings_node.scan_hint': 'Jak często node ponownie sprawdza swoje katalogi w poszukiwaniu pominiętych zmian oraz jak długo czeka po zmianie pliku przed jego zindeksowaniem.', + 'settings_node.scan_reconcile_label': 'Interwał sprawdzania (minuty)', + 'settings_node.scan_debounce_label': 'Oczekiwanie po zmianie (sekundy)', + 'settings_node.scan_save': 'Zapisz', + 'settings_node.scan_saving': 'Zapisywanie…', + 'settings_node.scan_saved': 'Zapisano.', // Create-group wizard 'wizard.title': 'Utwórz grupę', @@ -618,6 +626,9 @@ export default { 'wizard.step_add_roots': 'Dodawanie katalogów', 'wizard.step_gek': 'Inicjalizacja klucza szyfrowania', 'wizard.step_pair': 'Konfigurowanie parowania', + 'wizard.step_index': 'Indeksowanie plików', + 'wizard.indexing_progress': 'Indeksowanie… {pct}%', + 'wizard.indexing_current_dir': 'Skanowanie: {dir}', 'wizard.done_title': 'Grupa utworzona', 'wizard.done_message': 'Grupa jest gotowa. Node ją hostuje, a szyfrowanie jest skonfigurowane.', 'wizard.go_to_group': 'Przejdź do grupy', 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 c3220ae..0001051 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 @@ -453,6 +453,7 @@ export default { 'presence.online': 'Um node que hospeda este grupo está on-line', 'presence.offline': 'Nenhum node acessível para este grupo', 'presence.unknown': 'Ainda não verificado', + 'presence.indexing': 'Indexando — {pct}%', 'chat.load_older': 'Carregar {n} mensagens anteriores', 'chat.start_of_history': 'Início da conversa', 'chat.today': 'Hoje', @@ -560,6 +561,13 @@ export default { // Settings — node 'settings_node.detach_failed_continue': 'Não foi possível desanexar o grupo do node. Excluir do hub mesmo assim?', 'settings_node.roots': 'Diretórios compartilhados', + 'settings_node.scan_title': 'Varredura', + 'settings_node.scan_hint': 'Com que frequência o node reverifica seus diretórios em busca de mudanças que possa ter perdido, e quanto tempo espera após uma mudança antes de indexar um arquivo.', + 'settings_node.scan_reconcile_label': 'Intervalo de reverificação (minutos)', + 'settings_node.scan_debounce_label': 'Espera após uma mudança (segundos)', + 'settings_node.scan_save': 'Salvar', + 'settings_node.scan_saving': 'Salvando…', + 'settings_node.scan_saved': 'Salvo.', // Create group wizard 'wizard.title': 'Criar grupo', @@ -582,6 +590,9 @@ export default { 'wizard.step_add_roots': 'Adicionando diretórios', 'wizard.step_gek': 'Inicializando chave de criptografia', 'wizard.step_pair': 'Configurando pareamento', + 'wizard.step_index': 'Indexando arquivos', + 'wizard.indexing_progress': 'Indexando… {pct}%', + 'wizard.indexing_current_dir': 'Analisando: {dir}', 'wizard.done_title': 'Grupo criado', 'wizard.done_message': 'Seu grupo está pronto. Seu node o está hospedando e a criptografia está configurada.', 'wizard.go_to_group': 'Ir para o grupo', 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 0151c4b..ad8bf53 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 @@ -428,6 +428,7 @@ export default { 'presence.online': '有一个服务本群组的 node 在线', 'presence.offline': '本群组没有可达的 node', 'presence.unknown': '尚未检测', + 'presence.indexing': '正在索引 — {pct}%', 'chat.load_older': '加载更早的 {n} 条消息', 'chat.start_of_history': '对话开头', 'chat.today': '今天', @@ -543,6 +544,13 @@ export default { // Settings — node 'settings_node.detach_failed_continue': '无法从 node 上分离此群组。是否仍然在 hub 上删除?', 'settings_node.roots': '共享目录', + 'settings_node.scan_title': '扫描', + 'settings_node.scan_hint': 'node 多久重新检查一次目录以发现可能错过的变化,以及文件变化后等待多久才建立索引。', + 'settings_node.scan_reconcile_label': '重新检查间隔(分钟)', + 'settings_node.scan_debounce_label': '变化后的等待时间(秒)', + 'settings_node.scan_save': '保存', + 'settings_node.scan_saving': '保存中…', + 'settings_node.scan_saved': '已保存。', // Create group wizard 'wizard.title': '创建群组', @@ -566,6 +574,9 @@ export default { 'wizard.step_add_roots': '添加目录', 'wizard.step_gek': '初始化加密密钥', 'wizard.step_pair': '设置配对', + 'wizard.step_index': '正在索引文件', + 'wizard.indexing_progress': '正在索引… {pct}%', + 'wizard.indexing_current_dir': '正在扫描:{dir}', 'wizard.done_title': '群组已创建', 'wizard.done_message': '您的群组已就绪。您的 node 正在托管它,加密已设置完成。', 'wizard.go_to_group': '前往群组', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 30df80e..a5312e1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -266,6 +266,68 @@ export const node = { }; /** + * Poll a group's initial-scan progress on the local node (loopback), until + * it reports it is no longer scanning. For "add a directory" in Settings, + * where the group is already hosted — index-status is meaningful the whole + * time. NOT for a brand-new group the wizard just attached: see + * waitForGroupHosted below for why that needs a different exit condition. + * + * `onUpdate` is called with each {scanning, scanned_bytes, total_bytes, + * current_dir} snapshot, including the final one where scanning is false. + */ +export async function watchIndexProgress(groupId, onUpdate, { intervalMs = 500 } = {}) { + for (;;) { + let status; + try { + status = await node.call('GET', `/api/groups/${groupId}/index-status`); + } catch { + // The node went away mid-poll — stop rather than spin forever; the + // caller's own connection-status handling already covers that case. + return; + } + onUpdate(status); + if (!status.scanning) return; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } +} + +/** + * Create Group wizard only: wait for a brand-new group to actually become + * usable on the node, showing index-status along the way. + * + * Not the same wait as watchIndexProgress above. `/api/reload` returns as + * soon as the reload is scheduled (ops.start_reload) — before the node has + * even created an indexer for the group, let alone started scanning. A + * naive "poll index-status until scanning is false" would see the default + * idle answer on that very first poll and return immediately, and every + * group-scoped call after it (add a root, init the GEK) would still 404 + * with "not configured"/"not hosted" for as long as the real scan actually + * takes. The only answer that means "safe to proceed" is the group + * genuinely appearing in /api/groups (groups_ctx, daemon.py) — index-status + * is read purely for the progress bar. + */ +export async function waitForGroupHosted(groupId, onProgress, + { intervalMs = 500, timeoutMs = 30 * 60 * 1000 } = {}) { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + const status = await node.call('GET', `/api/groups/${groupId}/index-status`); + if (onProgress) onProgress(status); + } catch { /* keep waiting — the loopback API can be momentarily busy */ } + + try { + const list = await node.call('GET', '/api/groups'); + if (Array.isArray(list.groups) && list.groups.some((g) => g.id === groupId)) return; + } catch { /* keep waiting */ } + + if (Date.now() > deadline) { + throw new Error('The node did not finish attaching this group in time'); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +/** * LAN cast relay — re-serve decrypted video segments over HTTP so a * Chromecast or Smart TV on the same Wi-Fi can play the stream. * diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 20b9f8c..2cb27d0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -989,6 +989,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font-size: 0.9em; color: var(--text); } +.settings-label input[type="number"] { + display: block; + width: 90px; + margin-top: 4px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-base); + color: var(--text); + font-size: 0.95em; +} .settings-value { font-size: 0.9em; @@ -1887,6 +1898,19 @@ a.transfer-name { .presence-online { background: var(--success); } .presence-offline { background: var(--error); } .presence-unknown { background: transparent; border-color: var(--text-dim); } +/* The node is scanning this group's files right now (initial import, or a + directory just added) — a state, not a verdict, so it pulses rather than + sitting on a fixed color; green at rest, green again once scanning stops + (driven by IndexProgress.scanning, guaranteed to turn back off — see + daemon.py _progress_pusher and webrtc_server.py's handshake ack). */ +.presence-indexing { + background: var(--success); + animation: presence-indexing-pulse 1.6s ease-in-out infinite; +} +@keyframes presence-indexing-pulse { + 0%, 100% { background: var(--success); } + 50% { background: #f59e0b; } +} .sidebar-item-name { overflow: hidden; @@ -2284,7 +2308,7 @@ a.transfer-name { align-items: center; justify-content: space-between; padding: 8px 12px; - background: var(--surface); + background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; gap: 8px; @@ -2322,7 +2346,7 @@ a.transfer-name { align-items: center; gap: 10px; padding: 10px 14px; - background: var(--surface); + background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; font-size: 0.9em; @@ -2337,6 +2361,37 @@ a.transfer-name { .wizard-step-error .wizard-step-icon { color: var(--error); } .wizard-step-pending { opacity: 0.5; } +/* ── Indexing progress (Create Group wizard, and "add a directory" in + Settings) — bytes-based, not file-count-based; see the two call sites of + platform.watchIndexProgress. ── */ +.index-progress { + padding: 10px 14px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.9em; +} +.index-progress-bar { + height: 6px; + border-radius: 3px; + background: var(--border); + overflow: hidden; + margin-bottom: 8px; +} +.index-progress-fill { + height: 100%; + background: var(--accent); + transition: width 0.3s ease; +} +.index-progress-label { font-weight: 600; } +.index-progress-dir { + color: var(--text-dim); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .warning-msg { padding: 10px 14px; background: color-mix(in srgb, #f59e0b 15%, transparent); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index da98253..4f6b656 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -103,8 +103,10 @@ class MeshBayTransport { set onStreamEnd(fn) { this._onStreamEnd = fn; } set onStreamError(fn) { this._onStreamError = fn; } set onIndexSync(fn) { this._onIndexSync = fn; } + set onIndexDelta(fn) { this._onIndexDelta = fn; } set onUploadPolicy(fn) { this._onUploadPolicy = fn; } set onAppsEnabled(fn) { this._onAppsEnabled = fn; } + set onIndexProgress(fn) { this._onIndexProgress = fn; } get sessionKeys() { return this._sessionKeys; } @@ -634,6 +636,29 @@ class MeshBayTransport { return msg; } + /** + * How often the node's reconciliation backstop runs, and how long it + * waits after a file's last write before hashing it (indexer.py + * DirectoryIndexer). Whole seconds only: the node builds the signing + * subject with Python's `%g` (drops a trailing ".0"), and the simplest + * way to always match it byte-for-byte from JS is to never send a + * fractional value in the first place. + */ + async setScanSettings(reconcileIntervalSecs, debounceSecs, signFn) { + const reconcile = Math.round(reconcileIntervalSecs); + const debounce = Math.round(debounceSecs); + const msg = await this._sendAndWait({ + type: 'set_scan_settings', v: '0.1', + reconcile_interval_secs: reconcile, debounce_secs: debounce, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'set_scan_settings', `${reconcile},${debounce}`, signFn); + } + return msg; + } + async revokeMember(userId, signFn) { const msg = await this._sendAndWait({ type: 'member_revoke', v: '0.1', user_id: userId, @@ -1251,6 +1276,37 @@ class MeshBayTransport { this._onAppsEnabled(msg.apps || []); } + // The operator's node is scanning — never the entries themselves, just + // enough to animate a presence dot. Pushed periodically while it runs, + // plus once more on the transition back to idle (daemon.py + // _progress_pusher). UNLIKE member_upload_ack/apps_enabled_ack above, + // this is never a reply to anything this browser asked for — nobody + // calls _sendAndWait for it — so it MUST return here. Falling through + // to the "oldest pending" guess below hands it to whatever unrelated + // request happens to be waiting (a handshake, a chat history fetch), + // which then waits forever for its real answer while this one already + // "arrived" — and every message after that is one slot off too. Found + // live: a group mid-scan corrupted its own handshake and chat history + // this way, arriving roughly every 2s for as long as scanning ran. + if (msg.type === 'index_progress') { + if (this._onIndexProgress) { + this._onIndexProgress({ + scanning: Boolean(msg.scanning), + scanned_bytes: msg.scanned_bytes || 0, + total_bytes: msg.total_bytes || 0, + }); + } + return; + } + + // Same reasoning as index_progress: nobody awaits this one either, it + // is purely informational (group-settings.js does not currently act on + // it), so it must not be left to fall through to the oldest pending + // request. + if (msg.type === 'set_scan_settings_ack') { + return; + } + if (msg.type === 'index_sync' && msg.entries) { if (this._onIndexSync) this._onIndexSync(msg); const oldest = this._pending.entries().next(); @@ -1260,6 +1316,16 @@ class MeshBayTransport { return; } + // Incremental update — additions/deletions only, never the whole index. + // Only ever arrives after the full index this browser already has (the + // node's first push to a newly connected peer is always index_sync, see + // daemon.py _broadcast_index_change), so there is always a base to + // apply it to. + if (msg.type === 'index_delta') { + if (this._onIndexDelta) this._onIndexDelta(msg); + return; + } + if (msg.type === 'file_chunk') { const key = `chunk:${msg.file_id}:${msg.chunk_index}`; for (const [, handler] of this._pending) { @@ -1298,6 +1364,29 @@ class MeshBayTransport { return; } + // chat_hist_resp answers a `chat_hist` request, but under a different + // type string — unlike index_sync, which is asked for and answered under + // the same name, so the generic fallback below happens to work for it by + // accident. Without this check, whenever a chat_hist_resp arrives while + // something else this browser asked for (fetchIndex, even the handshake + // itself) is still the oldest pending entry, it gets handed to that + // instead: the request chat_hist_resp actually belongs to then hangs + // until _sendAndWait's own 30s timeout, and whatever it stole from + // resolves with the wrong shape entirely — reproduced live as a + // consistent ~30s hang immediately after a successful handshake, for one + // specific group and not others connected the same way, which is exactly + // what depending on response arrival order rather than on request type + // predicts: it fires only when the two responses happen to reorder. + if (msg.type === 'chat_hist_resp') { + const oldest = this._pending.entries().next(); + if (!oldest.done && oldest.value[1]._reqType === 'chat_hist') { + oldest.value[1].resolve(msg); + } else { + console.warn('[MeshBay] chat_hist_resp with no matching chat_hist pending'); + } + return; + } + // Everything above is routed by something in the message. What is left is // matched by arrival order, which is only ever a guess — and a wrong guess // here hands one request's answer to another, which then waits for a reply @@ -1349,6 +1438,16 @@ function _encodeValue(val, parts) { const b = new Uint8Array(5); b[0] = 0xce; new DataView(b.buffer).setUint32(1, val, false); parts.push(b); + } else if (val >= 0 && val <= Number.MAX_SAFE_INTEGER) { + // Same split as the 0xcf decoder case above, in reverse — without + // this, a value over 0xffffffff fell to the plain int32 branch + // below and silently wrapped to a wrong, unrelated number instead + // of failing loudly. + const b = new Uint8Array(9); b[0] = 0xcf; + const dv = new DataView(b.buffer); + dv.setUint32(1, Math.floor(val / 4294967296), false); + dv.setUint32(5, val % 4294967296, false); + parts.push(b); } else if (val >= -32 && val < 0) { parts.push(new Uint8Array([val & 0xff])); } else if (val >= -128 && val < 0) { @@ -1460,6 +1559,26 @@ function _decodeValue(buf, view, offset) { case 0xcc: return [buf[offset + 1], offset + 2]; case 0xcd: return [view.getUint16(offset + 1, false), offset + 3]; case 0xce: return [view.getUint32(offset + 1, false), offset + 5]; + // uint64/int64 — never emitted by this file's own encoder (a JS number + // above 0xffffffff falls to float64 there), but the node's real msgpack + // library sends a plain uint64 for any Python int over ~4.3 billion, and + // a raw byte count crosses that easily (found live: IndexProgress. + // scanned_bytes/total_bytes in the handshake ack, indexer.py, for a + // group whose total library size exceeds ~4 GB). Split into two 32-bit + // halves rather than DataView's getBigUint64/getBigInt64 — a BigInt + // would silently poison every arithmetic use of these fields elsewhere + // (percentage math, comparisons) — and every real byte count fits in a + // plain JS number well under Number.MAX_SAFE_INTEGER (2^53). + case 0xcf: { + const hi = view.getUint32(offset + 1, false); + const lo = view.getUint32(offset + 5, false); + return [hi * 4294967296 + lo, offset + 9]; + } + case 0xd3: { + const hi = view.getInt32(offset + 1, false); + const lo = view.getUint32(offset + 5, false); + return [hi * 4294967296 + lo, offset + 9]; + } case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9]; case 0xd0: return [view.getInt8(offset + 1), offset + 2]; case 0xd1: return [view.getInt16(offset + 1, false), offset + 3]; |