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`
${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`
<${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.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')}
`}
${roster && rosterGroup === g.id && html`
${(roster.members || []).length === 0 ? html`
${t('node.roster_empty')}
` : html`
| ${t('node.roster_user')} |
${t('node.roster_role')} |
${t('node.roster_status')} |
${t('node.roster_via')} |
|
${(roster.members || []).map(m => html`
| ${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`
`}
`;
});
})()}
${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`
`}
`}
`;
}