aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js111
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css59
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js119
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];