const mesh = window.MobuxMesh;
function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else node.setAttribute(k, v);
}
for (const c of [].concat(children)) {
if (c) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return node;
}
function showDialog({ title, fields, confirmLabel = 'OK', body }) {
return new Promise((resolve) => {
const dialog = el('dialog', { class: 'session-dialog' });
const form = el('form', { method: 'dialog' });
form.appendChild(el('h3', { text: title }));
if (body) form.appendChild(body);
const inputs = {};
for (const f of fields || []) {
const input = el('input', {
placeholder: f.placeholder || '',
autocomplete: 'off',
...(f.type ? { type: f.type } : {}),
...(f.value ? { value: f.value } : {}),
...(f.inputmode ? { inputmode: f.inputmode } : {}),
});
if (f.required) input.required = true;
inputs[f.name] = input;
form.appendChild(input);
}
const actions = el('div', { class: 'dialog-actions' });
const cancel = el('button', { type: 'button', class: 'btn-cancel', text: 'Cancel' });
const confirm = el('button', { type: 'submit', class: 'btn-create', text: confirmLabel });
actions.appendChild(cancel);
actions.appendChild(confirm);
form.appendChild(actions);
dialog.appendChild(form);
document.body.appendChild(dialog);
const done = (val) => {
dialog.close();
dialog.remove();
resolve(val);
};
cancel.addEventListener('click', () => done(null));
dialog.addEventListener('cancel', (e) => {
e.preventDefault();
done(null);
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const out = {};
for (const [name, input] of Object.entries(inputs)) out[name] = input.value;
done(out);
});
dialog.showModal();
const first = fields && fields[0] && inputs[fields[0].name];
if (first) first.focus();
});
}
async function promptPeerCred(peer, opts = {}) {
const note = opts.note
? el('p', { class: 'hint', text: opts.note, style: 'padding:0 0 8px;text-align:left' })
: null;
const vals = await showDialog({
title: `Sign in to ${peer}`,
body: note,
fields: [
{ name: 'user', placeholder: 'user', required: true },
{ name: 'pin', placeholder: 'PIN', type: 'password', inputmode: 'numeric', required: true },
],
confirmLabel: 'Connect',
});
if (!vals || !vals.user || !vals.pin) return false;
mesh.setPeerCred(peer, vals.user.trim(), vals.pin.trim());
return true;
}
let onPeerChange = () => {};
async function selectPeer(peer) {
if (!peer) {
mesh.setPeer('');
onPeerChange();
return;
}
mesh.setPeer(peer);
if (!mesh.getPeerCred(peer)) {
const ok = await promptPeerCred(peer);
if (!ok) {
mesh.setPeer('');
}
}
onPeerChange();
}
function statusDot(reachable) {
return el('span', {
class: `peer-status ${reachable ? 'peer-up' : 'peer-down'}`,
title: reachable ? 'reachable' : 'unreachable',
});
}
function peerOption(label, sublabel, selected, reachable) {
const opt = el('button', {
class: `peer-option${selected ? ' selected' : ''}`,
type: 'button',
});
if (reachable != null) opt.appendChild(statusDot(reachable));
const info = el('div', { class: 'peer-info' });
info.appendChild(el('span', { class: 'peer-name', text: label }));
if (sublabel) info.appendChild(el('span', { class: 'peer-sub', text: sublabel }));
opt.appendChild(info);
if (selected) opt.appendChild(el('span', { class: 'peer-check', text: '✓' }));
return opt;
}
async function openPicker(container) {
const list = el('div', { class: 'peer-list' });
list.appendChild(el('p', { class: 'hint', text: 'Loading hosts…' }));
container.replaceChildren(list);
const selected = mesh.getPeer();
const renderList = (peers, errorMsg) => {
list.replaceChildren();
const current = peerOption('This host', 'current node', !selected, null);
current.addEventListener('click', async () => {
await selectPeer('');
container.replaceChildren();
});
list.appendChild(current);
const seen = new Set();
for (const p of peers || []) {
const peerId = `${p.host}:${p.port}`;
seen.add(peerId);
const sub = p.version ? `v${p.version}` : 'unreachable';
const opt = peerOption(p.name, sub, selected === peerId, p.reachable);
opt.addEventListener('click', async () => {
await selectPeer(peerId);
container.replaceChildren();
});
list.appendChild(opt);
}
for (const peerId of mesh.getManualPeers()) {
if (seen.has(peerId)) continue; const opt = peerOption(peerId, 'manual', selected === peerId, null);
opt.addEventListener('click', async () => {
await selectPeer(peerId);
container.replaceChildren();
});
const remove = el('button', { class: 'peer-remove', type: 'button', text: '✕' });
remove.setAttribute('aria-label', `Remove ${peerId}`);
remove.addEventListener('click', (e) => {
e.stopPropagation();
const wasActive = mesh.getPeer() === peerId;
mesh.removeManualPeer(peerId);
if (wasActive) {
onPeerChange();
} else {
renderList(peers, errorMsg);
}
});
opt.appendChild(remove);
list.appendChild(opt);
}
if (errorMsg) {
list.appendChild(el('div', { class: 'peer-error', text: errorMsg }));
} else if ((!peers || !peers.length) && !mesh.getManualPeers().length) {
list.appendChild(el('p', { class: 'hint', text: 'No other hosts discovered.' }));
}
const add = el('button', { class: 'peer-add', type: 'button', text: '+ Add host' });
add.addEventListener('click', async () => {
const vals = await showDialog({
title: 'Add host',
fields: [{ name: 'host', placeholder: 'host or host:port', required: true }],
confirmLabel: 'Add',
});
if (!vals || !vals.host) return;
const peerId = mesh.addManualPeer(vals.host);
if (!peerId) {
alert('Invalid host. Use "host" or "host:port".');
return;
}
await selectPeer(peerId);
container.replaceChildren();
});
list.appendChild(add);
};
try {
const res = await fetch('/api/peers');
if (!res.ok) {
let msg = `Peer discovery failed (${res.status}).`;
try {
const body = await res.json();
if (body && body.error && body.error.message) msg = body.error.message;
} catch (_) {}
renderList([], msg);
return;
}
const body = await res.json();
renderList(body.peers || [], null);
} catch (e) {
renderList([], `Peer discovery failed: ${e.message}`);
}
}
function triggerLabel() {
const peer = mesh.getPeer();
return peer || 'This host';
}
function mount() {
const header = document.querySelector('.app-header');
if (!header) return;
const trigger = el('button', { class: 'host-trigger', type: 'button' });
const refreshTrigger = () => {
trigger.replaceChildren(
el('span', { class: 'host-label', text: triggerLabel() }),
el('span', { class: 'host-caret', text: '▾' }),
);
};
refreshTrigger();
const dropdown = el('div', { class: 'host-dropdown' });
const settings = header.querySelector('.header-icon');
if (settings) header.insertBefore(trigger, settings);
else header.appendChild(trigger);
header.parentNode.insertBefore(dropdown, header.nextSibling);
let open = false;
const close = () => {
open = false;
dropdown.replaceChildren();
dropdown.classList.remove('open');
};
trigger.addEventListener('click', () => {
if (open) {
close();
} else {
open = true;
dropdown.classList.add('open');
openPicker(dropdown);
}
});
onPeerChange = () => {
refreshTrigger();
close();
if (typeof window.refreshSessions === 'function') window.refreshSessions();
};
}
window.MobuxHostPicker = { mount, promptPeerCred, showDialog };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
} else {
mount();
}