import { html, useState, useEffect, useCallback, useRef, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; // ── Node management (D5) ──────────────────────────────────────────────────── // // Electron-only: talks to the local node daemon via its loopback HTTP API // (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain // for potential future browser-side use. function NodeServicePanel({ onChanged }) { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(''); const [err, setErr] = useState(''); const refresh = useCallback(async () => { try { const r = await platform.node.service.status(); setInfo(r); setErr(''); } catch (e) { setErr(platform.bridgeMessage(e)); } }, []); useEffect(() => { if (!platform.node.service.available) return; refresh(); const timer = setInterval(refresh, 5000); return () => clearInterval(timer); }, [refresh]); const act = useCallback(async (name, fn) => { setBusy(name); setErr(''); try { await fn(); await refresh(); if (onChanged) onChanged(); } catch (e) { setErr(platform.bridgeMessage(e)); } finally { setBusy(''); } }, [refresh, onChanged]); if (!platform.node.service.available) return null; if (!info || info.supported === false) { return html`
${' '}${t('node.service_checking')}
`; } const KNOWN_STATES = ['active', 'inactive', 'failed', 'activating', 'deactivating']; const stateKey = KNOWN_STATES.includes(info.activeState) ? info.activeState : 'unknown'; const running = info.activeState === 'active' || info.activeState === 'activating'; const dot = info.activeState === 'active' ? 'online' : info.activeState === 'failed' ? 'offline' : 'unknown'; const label = info.installed ? t('node.service_state_' + stateKey) : t('node.service_not_installed'); return html`
${label}
${err && html`
${err}
`}
${info.installed && html` `}
`; } async function nodeCall(method, path, body) { return platform.node.call(method, path, body); } export function NodePage({ groups }) { const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); const [nodeGroups, setNodeGroups] = useState([]); const [busy, setBusy] = useState(false); const [actionMsg, setActionMsg] = useState(''); const [roster, setRoster] = useState(null); const [rosterGroup, setRosterGroup] = useState(''); const [denylist, setDenylist] = useState(null); const [showDenylist, setShowDenylist] = useState(false); const [nodeSettings, setNodeSettings] = useState(null); const [editSettings, setEditSettings] = useState(null); const [savingSettings, setSavingSettings] = useState(false); const [editStun, setEditStun] = useState(null); const [savingStun, setSavingStun] = useState(false); const [stunInput, setStunInput] = useState(''); const [editIce, setEditIce] = useState(null); const [savingIce, setSavingIce] = useState(false); const [iceInput, setIceInput] = useState(''); const [operatorPaired, setOperatorPaired] = useState(false); const [pairBusy, setPairBusy] = useState(false); const [pairStatus, setPairStatus] = useState(''); const fetchStatus = useCallback(async () => { setStatus('connecting'); setError(''); try { const detection = await platform.node.detect(); if (!detection.detected) { setError(t('node.offline')); setStatus('error'); return; } const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); setStatus('connected'); } catch (err) { setError(platform.bridgeMessage(err)); setStatus('error'); } }, []); useEffect(() => { fetchStatus(); }, [fetchStatus]); useEffect(() => { if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings }); if (nodeSettings && !editStun) setEditStun([...(nodeSettings.stun_servers || [])]); if (nodeSettings && !editIce) setEditIce([...(nodeSettings.ice_interfaces || [])]); }, [nodeSettings]); const refresh = useCallback(async () => { try { const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); } catch {} }, []); const addRoot = useCallback(async (groupId) => { const chosen = await platform.rootPicker.choose(); if (!chosen) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/groups/${groupId}/roots`, { path: chosen.path }); await nodeCall('POST', '/api/reload'); await refresh(); setActionMsg(t('node.root_added')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const removeRoot = useCallback(async (groupId, rootName) => { if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('DELETE', `/api/groups/${groupId}/roots/${encodeURIComponent(rootName)}`); await nodeCall('POST', '/api/reload'); await refresh(); setActionMsg(t('node.root_removed')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const loadRoster = useCallback(async (groupId) => { setBusy(true); try { const result = await nodeCall('GET', `/api/roster?group_id=${groupId}`); setRoster(result); setRosterGroup(groupId); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const unpinMember = useCallback(async (userId) => { if (!confirm(t('node.unpin_confirm', { name: userId }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/members/${userId}/unpin`); setActionMsg(t('node.unpin_done')); if (rosterGroup) await loadRoster(rosterGroup); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [rosterGroup, loadRoster]); const rotateGek = useCallback(async (groupId) => { if (!confirm(t('node.gek_rotate_confirm'))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/groups/${groupId}/gek?rotate=true`); setActionMsg(t('node.gek_rotated')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const loadDenylist = useCallback(async () => { setBusy(true); try { const result = await nodeCall('GET', '/api/denylist'); setDenylist(result); setShowDenylist(true); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const clearDenylist = useCallback(async (subject) => { const label = subject || t('node.denylist_clear_all'); if (!confirm(t('node.denylist_clear_confirm', { subject: label }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`); setActionMsg(t('node.denylist_cleared')); await loadDenylist(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [loadDenylist]); const detachGroup = useCallback(async (name) => { if (!confirm(t('node.detach_confirm', { name }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', '/api/groups/detach', { name }); await nodeCall('POST', '/api/reload'); setActionMsg(t('node.detached')); await refresh(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const reloadConfig = useCallback(async () => { setBusy(true); setActionMsg(''); try { await nodeCall('POST', '/api/reload'); setActionMsg(t('node.reloaded')); await refresh(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const saveSettings = useCallback(async () => { if (!editSettings) return; setSavingSettings(true); setActionMsg(''); try { await nodeCall('PUT', '/api/node-settings', editSettings); setNodeSettings({ ...editSettings }); setActionMsg(t('node.settings_saved')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setSavingSettings(false); } }, [editSettings]); const saveStun = useCallback(async () => { if (!editStun) return; setSavingStun(true); setActionMsg(''); try { await nodeCall('PUT', '/api/node-settings', { stun_servers: editStun }); setNodeSettings(s => s ? { ...s, stun_servers: [...editStun] } : s); setActionMsg(t('node.stun_saved')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setSavingStun(false); } }, [editStun]); const addStunServer = useCallback(() => { const url = stunInput.trim(); if (!url) return; if (!url.startsWith('stun:')) { setActionMsg(t('node.stun_invalid')); return; } if (editStun && editStun.includes(url)) { setActionMsg(t('node.stun_duplicate')); return; } setEditStun(s => [...(s || []), url]); setStunInput(''); setActionMsg(''); }, [stunInput, editStun]); const removeStunServer = useCallback((idx) => { setEditStun(s => s.filter((_, i) => i !== idx)); }, []); const moveStunServer = useCallback((idx, dir) => { setEditStun(s => { const a = [...s]; const target = idx + dir; if (target < 0 || target >= a.length) return a; [a[idx], a[target]] = [a[target], a[idx]]; return a; }); }, []); const resetStunDefaults = useCallback(() => { setEditStun([ 'stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302', 'stun:stun.cloudflare.com:3478', 'stun:stun.services.mozilla.com:3478', ]); setActionMsg(''); }, []); const saveIce = useCallback(async () => { if (!editIce) return; setSavingIce(true); setActionMsg(''); try { await nodeCall('PUT', '/api/node-settings', { ice_interfaces: editIce }); setNodeSettings(s => s ? { ...s, ice_interfaces: [...editIce] } : s); setActionMsg(t('node.ice_saved')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setSavingIce(false); } }, [editIce]); const addIceInterface = useCallback(() => { const name = iceInput.trim(); if (!name) return; if (editIce && editIce.includes(name)) { setActionMsg(t('node.ice_duplicate')); return; } setEditIce(s => [...(s || []), name]); setIceInput(''); setActionMsg(''); }, [iceInput, editIce]); const removeIceInterface = useCallback((idx) => { setEditIce(s => s.filter((_, i) => i !== idx)); }, []); const resetIceAuto = useCallback(() => { setEditIce([]); setActionMsg(''); }, []); const doPairOperator = useCallback(async () => { setPairBusy(true); setPairStatus(''); try { const result = await nodeCall('POST', '/api/operator/pair'); if (result && result.code) { await platform.node.setPairingCode(result.code); } setPairStatus('paired'); setOperatorPaired(true); await refresh(); } catch (err) { setPairStatus(platform.bridgeMessage(err)); } finally { setPairBusy(false); } }, [refresh]); if (status === 'idle' || status === 'connecting') { return html`

${t('node.title')}

<${NodeServicePanel} onChanged=${fetchStatus} />

${' '}${t('status.connecting')}

`; } if (status === 'error') { return html`

${t('node.title')}

<${NodeServicePanel} onChanged=${fetchStatus} />

${error}

`; } return html`

${t('node.title')}

<${NodeServicePanel} onChanged=${fetchStatus} /> ${actionMsg && html`
${actionMsg}
`} ${!operatorPaired && html`

${t('node.pair_needed')}

${pairStatus === 'paired' ? html`

${t('node.pair_success')}

` : pairStatus ? html`

${pairStatus}

` : null}
`} ${(() => { const hubIds = new Set((groups || []).map(g => g.id)); return nodeGroups.map(g => { const stale = !hubIds.has(g.id); return html`

${g.name}${stale ? html` ${t('node.stale')}` : ''}

${t('node.files', { n: g.file_count })} ${t('node.peers', { n: g.peers })} ${!g.has_gek && html` ${t('node.no_gek')}`}
${t('node.roots')}
${(g.roots || []).map(r => html`
<${Icon} name="folder" /> ${r.name} ${r.upload && html` ${t('node.upload_root')}`} ${!r.available && html` ${t('node.unavailable')}`}
${(g.roots || []).length > 1 && !r.upload && html` `}
`)}
${t('node.gek')} ${g.has_gek ? (g.visibility !== 'public' ? html` ` : html`

${t('node.gek_public_hint')}

`) : html`

${t('node.gek_init_hint')}

`}
${t('node.roster')}
${roster && rosterGroup === g.id && html`
${(roster.members || []).length === 0 ? html`

${t('node.roster_empty')}

` : html` ${(roster.members || []).map(m => html` `)}
${t('node.roster_user')} ${t('node.roster_role')} ${t('node.roster_status')} ${t('node.roster_via')}
${m.username || m.user_id.slice(0, 12)} ${m.role} ${m.status} ${m.pinned_via || ''} ${m.pk_ed25519 && html` `}
`} ${(roster.invites || []).length > 0 && html`

${t('node.roster_invites', { n: roster.invites.length })}

`}
`}
${stale && html`
`}
`; }); })()}
${t('node.denylist')}
${showDenylist && denylist && html`
${denylist.count === 0 ? html`

${t('node.denylist_empty')}

` : html`

${t('node.denylist_count', { n: denylist.count })}

${(denylist.users || []).map(u => html`
user: ${u}
`)} ${(denylist.groups || []).map(g => html`
group: ${g}
`)} ${(denylist.jtis || []).map(j => html`
token: ${j.slice(0, 16)}
`)} `}
`}
${editSettings && html`
${t('node.settings')}

${t('node.settings_edit_hint')}

`} ${editStun && html`
${t('node.stun_servers')}

${t('node.stun_hint')}

${editStun.length === 0 ? html`

${t('node.stun_empty')}

` : html`
${editStun.map((url, idx) => html`
${url}
`)}
`}
setStunInput(e.target.value)} onKeyDown=${e => { if (e.key === 'Enter') addStunServer(); }} />
`} ${editIce && html`
${t('node.ice_interfaces')}

${editIce.length === 0 ? t('node.ice_auto_hint') : t('node.ice_manual_hint')}

${editIce.length > 0 && html`
${editIce.map((name, idx) => html`
${name}
`)}
`}
setIceInput(e.target.value)} onKeyDown=${e => { if (e.key === 'Enter') addIceInterface(); }} />
${editIce.length > 0 && html` `}
`}
`; }