import { csvCell } from './csv.js';
import {
html, useState, useEffect, useCallback, useRef,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { ask } from './ask.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
import { HUB, session, hubFetch } from './hub-client.js';
// ── Node management (D5) ────────────────────────────────────────────────────
//
// Electron-only: talks to the local node daemon via its loopback HTTP API
// (platform.node.op), not over MNP/WebRTC. The MNP protocol types remain
// for potential future browser-side use.
// true / false from a node that has read its roster, null from one that has
// not yet (it publishes the roster only once it has signed in to the hub) --
// which is not the same as having no operator.
export function pairedFrom(result) {
const v = result && result.operator_paired;
return v === true || v === false ? v : null;
}
// Why this machine's node cannot serve the signed-in account, if it cannot:
// 'other_account' when it is set up for another account or hub, 'other_node'
// when the account is linked to another node's key. Signing in links this
// node (main.js ensureNode) but never over either, since that would cut off
// the user's other machine or someone else's node; both left a node reading
// "Running" that the hub refused in a loop, with nothing here saying why
// (found on a real install, 2026-10-10). Worked out from the node and the hub
// each time the page looks, not kept from sign-in: a link made or removed on
// the Profile page or on the other machine changes the answer.
export function nodeLinkProblem(info, linkedKey, username, hub) {
if (!info || !username) return null;
const sameHub = (a, b) => String(a || '').replace(/\/+$/, '') === String(b || '').replace(/\/+$/, '');
if ((info.username && info.username !== username)
|| (info.hub_url && hub && !sameHub(info.hub_url, hub))) return 'other_account';
if (linkedKey && info.pk_node_ed25519 && linkedKey !== info.pk_node_ed25519) return 'other_node';
return null;
}
function NodeLinkBanner({ info, token, username, onLinked }) {
const [linkedKey, setLinkedKey] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const nodeKey = info && info.pk_node_ed25519;
useEffect(() => {
if (!username || !token) return;
let cancelled = false;
hubFetch(`/v1/users/${encodeURIComponent(username)}/pubkeys`, { token })
.then((k) => { if (!cancelled) setLinkedKey((k && k.pk_node_ed25519) || null); })
.catch(() => {});
return () => { cancelled = true; };
}, [username, token, nodeKey]);
const problem = nodeLinkProblem(info, linkedKey, username, HUB);
if (!problem) return null;
// Another node's key: this node's replaces it on the account, and a node
// waiting for its account signs in on its next attempt. Not node:start,
// which leaves a node that answers "running" alone, and one can (signed in
// before the account was linked elsewhere). Another account: node:start
// switches it, after asking, restarts it and links it.
const takeOver = async () => {
if (problem === 'other_node' && !await ask(t('node.link_here_confirm'))) return;
setBusy(true);
setErr('');
try {
if (problem === 'other_node') {
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token, body: { pk_node_ed25519: nodeKey },
});
} else {
await platform.node.start({ hubUrl: HUB, username, token, takeOver: true });
}
setLinkedKey(nodeKey);
if (onLinked) onLinked();
} catch (e) {
setErr(platform.bridgeMessage(e));
} finally {
setBusy(false);
}
};
return html`
`;
}
function NodeServicePanel({ onChanged, token, username }) {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState('');
// Two errors, not one: the status poll below cleared the message of an
// action that had just been refused, within five seconds and often before
// it was read (found switching startup mode on a real Store install).
const [pollErr, setPollErr] = useState('');
const [actErr, setActErr] = useState('');
const err = actErr || pollErr;
const refresh = useCallback(async () => {
try {
const r = await platform.node.service.status();
setInfo(r);
setPollErr('');
} catch (e) {
setPollErr(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);
setActErr('');
try {
await fn();
await refresh();
if (onChanged) onChanged();
} catch (e) {
setActErr(platform.bridgeMessage(e));
} finally {
setBusy('');
}
}, [refresh, onChanged]);
// "off" / "signin" / "service" -- derived from the status payload, no new
// backend field needed: mode/autostart already distinguish all three.
const startupMode = (i) => {
if (!i) return 'off';
if (i.mode === 'service') return i.mode;
return i.autostart ? 'signin' : 'off';
};
// Switching mode itself — the installer's own radio page only runs once,
// at install time, so this is the only way back in if service mode was
// declined there, or out if it is no longer wanted. One elevation, task +
// firewall together, same script the installer runs.
//
// The two mechanisms are mutually exclusive by construction here: never
// both installed at once, which would start the daemon twice (once at
// boot via the Scheduled Task, again at sign-in via the Startup .vbs).
// Always remove whichever one is currently active before installing the
// target, so every transition -- not just the two that used to be
// separate toggles -- keeps that invariant.
const changeStartupMode = useCallback((target) => {
const current = startupMode(info);
if (target === current) return;
act('startupMode', async () => {
if (current === 'service') await platform.node.serviceMode.remove();
else if (current === 'signin') await platform.node.autostart.remove();
if (target === 'service') await platform.node.serviceMode.install();
else if (target === 'signin') await platform.node.autostart.install();
});
}, [act, info]);
// The Start button below passes {hubUrl, username, token} to node:start,
// same as create-group-page.js's own startNode() -- node:start only links
// an unlinked node key to the hub account when given credentials to link
// it with (main.js's linkNodeKeyAndAwaitRunning). Without them, a node that
// is not linked yet (a fresh install, or one whose hub-side link was lost)
// just polls for up to 105s and fails with "could not link", pointing at a
// "Link Node" control that lives on Settings, not here. Reproduced live
// 2026-09-14: a fresh non-service install's own Start button hung and
// failed this way, the exact same account and key that had just linked
// fine through the Create Group wizard.
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';
// A node process that does not answer ('unknown': on its way up or down, or
// stuck) is still one to stop -- only "nothing there" takes Stop away.
const stoppable = info.activeState !== 'inactive';
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');
// Own row, below the status/actions card rather than a further item
// crammed into its flex-wrap line -- that (plus two independent toggles
// for what is really one choice) is what made this a mess before.
const showStartupRow = (platform.node.autostart.available
|| platform.node.serviceMode.available) && typeof info.mode === 'string';
return html`
${/* Removing a writable root is allowed now — several can be
writable, and a group with none is a valid read-only
group. The last root is still the one that cannot go. */''}
${(g.roots || []).length > 1 && html`
`}