/* global Terminal, FitAddon, WebLinksAddon, SearchAddon, TERM_THEMES */
'use strict';
// ============================================================ Helpers
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const uid = () => crypto.randomUUID();
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
const T = I18N.t;
const ICONS = {
search: ' ',
plus: ' ',
edit: ' ',
trash: ' ',
more: ' ',
close: ' ',
folder: ' ',
file: ' ',
term: ' ',
screen: ' ',
key: ' ',
copy: ' ',
play: ' ',
stop: ' ',
up: ' ',
refresh: ' ',
home: ' ',
newdir: ' ',
arrowR: ' ',
arrowL: ' ',
code: ' ',
shield: ' ',
lock: ' ',
unlock: ' ',
eye: ' ',
fwd: ' ',
dup: ' ',
clock: ' ',
download: ' ',
upload: ' ',
docker: ' ',
logs: ' ',
};
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
function colorFor(h) {
if (h.color) return h.color;
let n = 0; for (const c of h.label || h.address || '') n = (n * 31 + c.charCodeAt(0)) >>> 0;
return COLORS[n % COLORS.length];
}
function avatar(hst, size) {
const letter = esc((hst.label || hst.address || '?').trim()[0]?.toUpperCase() || '?');
const proto = hst.protocol === 'rdp' ? 'RDP' : 'SSH';
return `
${hst.protocol === 'rdp' ? ICONS.screen : letter}${proto}
`;
}
function fmtSize(n) {
if (n == null) return '';
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
const fmtDate = (t) => (t ? new Date(t).toLocaleString(I18N.lang, { dateStyle: 'short', timeStyle: 'short' }) : '');
function relTime(t) {
const s = (Date.now() - t) / 1000;
if (s < 60) return T('just now');
if (s < 3600) return T('{n} min ago', { n: Math.floor(s / 60) });
if (s < 86400) return T('{n} h ago', { n: Math.floor(s / 3600) });
return T('{n} days ago', { n: Math.floor(s / 86400) });
}
function toast(msg, type = '') {
const el = h(`${esc(msg)}
`);
$('#toasts').append(el);
setTimeout(() => el.remove(), type === 'error' ? 7000 : 3500);
}
async function call(ch, ...a) {
try { return await api.call(ch, ...a); }
catch (e) { toast(e.message, 'error'); throw e; }
}
// ---------- Modals
function modal({ title, text = '', body = '', buttons = [] }) {
return new Promise((resolve) => {
const bg = h(`${esc(title)} ${text ? `
${esc(text)}
` : ''}${body}
`);
const foot = $('.mfoot', bg);
const done = (v) => { bg.remove(); resolve(v); };
buttons.forEach((b) => {
const btn = h(`${esc(b.label)} `);
btn.onclick = () => done(b.value === 'form' ? Object.fromEntries($$('[name]', bg).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value])) : b.value);
foot.append(btn);
});
bg.addEventListener('mousedown', (e) => { if (e.target === bg) done(null); });
bg.addEventListener('keydown', (e) => {
if (e.key === 'Escape') done(null);
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); foot.lastElementChild.click(); }
});
$('#modalRoot').append(bg);
($('input,select,textarea', bg) || foot.lastElementChild).focus();
});
}
const confirmBox = (title, text, label = T('Delete')) =>
modal({ title, text, buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label, value: true, cls: label === T('Delete') ? 'danger' : 'primary' }] });
async function promptBox(title, label, value = '', type = 'text') {
const r = await modal({ title, body: `${esc(label)}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('OK'), value: 'form', cls: 'primary' }] });
return r ? r.v : null;
}
// ---------- Kontextmenü
function ctxMenu(x, y, items) {
$$('.ctx').forEach((c) => c.remove());
const m = h('
');
for (const it of items) {
if (it === '-') { m.append(h(' ')); continue; }
const b = h(`${it.icon ? ICONS[it.icon] : ''}${esc(it.label)} `);
b.onclick = () => { m.remove(); it.run(); };
m.append(b);
}
document.body.append(m);
const r = m.getBoundingClientRect();
m.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';
m.style.top = Math.min(y, innerHeight - r.height - 8) + 'px';
setTimeout(() => document.addEventListener('mousedown', function off(e) { if (!m.contains(e.target)) { m.remove(); document.removeEventListener('mousedown', off); } }), 0);
}
// ============================================================ State
const S = {
vault: null,
view: 'hosts',
group: null,
search: '',
tag: null,
tabs: [],
active: 'home',
activeForwards: new Set(),
};
const settings = () => S.vault.settings;
async function reload() {
S.lock = await call('lock:status');
$('#lockBtn').style.display = S.lock.enabled ? '' : 'none';
if (S.lock.locked) return showLock();
S.vault = await call('vault:get');
I18N.setLanguage(settings().language, navigator.language);
applyStatic();
const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}${T('Vault encrypted')} ` : `${ICONS.unlock}${T('Vault not encrypted')} `;
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
applyAppTheme();
S.activeForwards = new Set(await call('fw:active'));
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
render();
}
// Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title)
function applyStatic() {
document.documentElement.lang = I18N.lang;
$$('[data-i18n]').forEach((el) => { el.textContent = T(el.dataset.i18n); });
$$('[data-i18n-title]').forEach((el) => { el.title = T(el.dataset.i18nTitle); });
// Tooltips für die eingeklappte Symbolansicht
$$('#navMenu .nav').forEach((n) => { n.title = n.querySelector('span')?.textContent || ''; });
$('#navToggle').title = $('#sidebar').classList.contains('nav-collapsed') ? T('Expand menu') : T('Collapse menu');
}
// ============================================================ Navigation
$$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => {
S.view = n.dataset.view;
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x === n));
closeDrawer();
activateTab('home');
render();
}));
// ============================================================ Einklappbares Menü (Sidebar)
(() => {
const KEY = 'mrterm.nav.collapsed';
const sb = $('#sidebar');
const apply = (collapsed) => {
sb.classList.toggle('nav-collapsed', collapsed);
$('#navToggle').title = collapsed ? T('Expand menu') : T('Collapse menu');
};
let collapsed = false;
try { collapsed = localStorage.getItem(KEY) === '1'; } catch {}
apply(collapsed);
$('#navToggle').onclick = () => {
collapsed = !collapsed;
apply(collapsed);
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch {}
};
})();
// ============================================================ Verzeichnisbaum (Sidebar)
const TREE_KEY = 'mrterm.tree.open';
const treeOpen = new Set((() => { try { return JSON.parse(localStorage.getItem(TREE_KEY) || '[]'); } catch { return []; } })());
const saveTreeOpen = () => { try { localStorage.setItem(TREE_KEY, JSON.stringify([...treeOpen])); } catch {} };
const CHEV = ' ';
function goGroup(id) {
S.view = 'hosts'; S.group = id; S.search = ''; S.tag = null;
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x.dataset.view === 'hosts'));
closeDrawer();
activateTab('home');
render();
}
async function moveHostToGroup(hostId, groupId) {
const hst = S.vault.hosts.find((x) => x.id === hostId);
if (!hst || (hst.groupId || null) === groupId) return;
await call('vault:upsert', 'hosts', { ...hst, groupId });
if (groupId) { treeOpen.add(groupId); saveTreeOpen(); }
reload();
}
function dropTarget(el, groupId) {
el.addEventListener('dragover', (e) => { if (e.dataTransfer.types.includes('mrterm/host')) { e.preventDefault(); el.classList.add('drop'); } });
el.addEventListener('dragleave', () => el.classList.remove('drop'));
el.addEventListener('drop', (e) => {
e.preventDefault(); el.classList.remove('drop');
const id = e.dataTransfer.getData('mrterm/host');
if (id) moveHostToGroup(id, groupId);
});
}
function renderTree() {
const box = $('#tree');
if (!box || !S.vault) return;
const scroll = box.scrollTop;
box.innerHTML = '';
const { groups, hosts } = S.vault;
const byLabel = (x, y) => (x.label || x.address || '').localeCompare(y.label || y.address || '', undefined, { numeric: true });
const countIn = (gid) => hosts.filter((x) => x.groupId === gid).length + groups.filter((g) => g.parentId === gid).reduce((n, g) => n + countIn(g.id), 0);
const head = h(`${T('Connections')} ${ICONS.up}
`);
$('button', head).onclick = () => { treeOpen.clear(); saveTreeOpen(); renderTree(); };
dropTarget(head, null); // auf die Überschrift ziehen = Stammebene
box.append(head);
if (!groups.length && !hosts.length) { box.append(h(`${T('No hosts yet')}
`)); return; }
const pad = (depth) => `padding-left:${4 + depth * 14}px`;
const walk = (parentId, depth) => {
for (const g of groups.filter((x) => (x.parentId || null) === parentId).sort(byLabel)) {
const open = treeOpen.has(g.id);
const active = S.view === 'hosts' && S.group === g.id && S.active === 'home';
const row = h(`
${CHEV} ${ICONS.folder} ${esc(g.label)} ${countIn(g.id)}
`);
const toggle = () => { if (open) treeOpen.delete(g.id); else treeOpen.add(g.id); saveTreeOpen(); renderTree(); };
$('.chev', row).onclick = (e) => { e.stopPropagation(); toggle(); };
row.onclick = () => { if (!open) { treeOpen.add(g.id); saveTreeOpen(); } goGroup(g.id); };
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
{ label: T('Open'), icon: 'folder', run: () => goGroup(g.id) },
{ label: T('Add host'), icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } },
{ label: T('Create subfolder'), icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } },
{ label: T('Edit'), icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } },
'-',
{ label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } },
]);
dropTarget(row, g.id);
box.append(row);
if (open) walk(g.id, depth + 1);
}
for (const x of hosts.filter((y) => (y.groupId || null) === parentId).sort(byLabel)) {
const live = S.tabs.some((t) => t.host?.id === x.id);
const row = h(`
${x.protocol === 'rdp' ? ICONS.screen : ICONS.term} ${esc(x.label || x.address)} ${live ? '● ' : ''}
`);
row.ondblclick = () => connectHost(x);
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(x));
row.ondragstart = (e) => { e.dataTransfer.setData('mrterm/host', x.id); e.dataTransfer.effectAllowed = 'move'; };
box.append(row);
}
};
walk(null, 0);
box.scrollTop = scroll;
}
function render() {
const page = $('#homeView');
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, vpns: viewVpns, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
const scroll = page.scrollTop;
page.innerHTML = '';
views[S.view](page);
page.scrollTop = scroll;
renderTree();
}
function toolbar(placeholder, buttons = []) {
const t = h(`${ICONS.search}
`);
const inp = $('input', t);
inp.addEventListener('input', () => { S.search = inp.value; const pos = inp.selectionStart; render(); const n = $('#homeView .search input'); n.focus(); n.setSelectionRange(pos, pos); });
buttons.forEach((b) => { const el = h(`${b.icon ? ICONS[b.icon] : ''}${esc(b.label)} `); el.onclick = b.run; t.append(el); });
return t;
}
const matches = (...fields) => { const q = S.search.trim().toLowerCase(); return !q || fields.some((f) => String(f || '').toLowerCase().includes(q)); };
function emptyState(icon, title, text, btnLabel, run) {
const e = h(`${ICONS[icon]}
${esc(title)} ${esc(text)}
`);
if (btnLabel) { const b = h(`${ICONS.plus}${esc(btnLabel)} `); b.onclick = run; e.append(b); }
return e;
}
// ============================================================ Hosts
function hostSub(hst) {
const u = hst.username ? `${hst.username}@` : '';
const port = hst.port && Number(hst.port) !== (hst.protocol === 'rdp' ? 3389 : 22) ? `:${hst.port}` : '';
return `${hst.protocol === 'rdp' ? 'rdp' : 'ssh'}, ${u}${hst.address}${port}`;
}
function viewHosts(page) {
page.append(toolbar(T('Search hosts or enter user@host …'), [
{ label: T('Group'), icon: 'newdir', run: () => editGroup({ parentId: S.group }) },
{ label: T('New host'), icon: 'plus', cls: 'primary', run: () => editHost({ groupId: S.group }) },
]));
const c = h('
');
page.append(c);
const { groups, hosts } = S.vault;
const searching = !!S.search.trim();
if (S.group) {
const trail = []; let g = groups.find((x) => x.id === S.group);
while (g) { trail.unshift(g); g = groups.find((x) => x.id === g.parentId); }
const bc = h('
');
const root = h(`${esc(T('All hosts'))} `); root.onclick = () => { S.group = null; render(); }; bc.append(root);
trail.forEach((t) => { bc.append(' / '); const a = h(`${esc(t.label)} `); a.onclick = () => { S.group = t.id; render(); }; bc.append(a); });
c.append(bc);
}
const allTags = [...new Set(hosts.flatMap((x) => x.tags || []))].sort();
if (allTags.length) {
const ch = h('
');
allTags.forEach((t) => { const b = h(`#${esc(t)} `); b.onclick = () => { S.tag = S.tag === t ? null : t; render(); }; ch.append(b); });
c.append(ch);
}
const subGroups = searching ? groups.filter((g) => matches(g.label)) : groups.filter((g) => (g.parentId || null) === S.group);
if (subGroups.length && !S.tag) {
c.append(h(`${T('Groups')} ${subGroups.length}
`));
const grid = h('
');
for (const g of subGroups) {
const count = hosts.filter((x) => x.groupId === g.id).length;
const card = h(`${ICONS.folder}
${ICONS.edit}
`);
card.onclick = (e) => { if (e.target.closest('[data-a=edit]')) return editGroup(g); S.group = g.id; S.search = ''; render(); };
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
{ label: T('Edit'), icon: 'edit', run: () => editGroup(g) },
{ label: T('Add host'), icon: 'plus', run: () => editHost({ groupId: g.id }) },
'-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } },
]);
grid.append(card);
}
c.append(grid);
}
let list = hosts.filter((x) => (searching || S.tag ? true : (x.groupId || null) === S.group));
list = list.filter((x) => matches(x.label, x.address, x.username, ...(x.tags || [])));
if (S.tag) list = list.filter((x) => (x.tags || []).includes(S.tag));
list.sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
if (list.length) {
c.append(h(`Hosts ${list.length}
`));
const grid = h('
');
list.forEach((hst) => grid.append(hostCard(hst)));
c.append(grid);
} else if (!subGroups.length) {
const q = S.search.trim();
if (q && /^[^\s@]+@[^\s]+$|^[\w.-]+(:\d+)?$/.test(q)) {
c.append(emptyState('term', T('Connect to {q}?', { q }), T('Quick Connect without saving the host.'), T('Connect'), () => quickConnect(q)));
} else if (!hosts.length) {
const e = emptyState('screen', T('No hosts yet'), T('Add your first SSH or RDP host or import ~/.ssh/config.'), T('New host'), () => editHost({}));
const imp = h(`${esc(T('Import ~/.ssh/config'))} `);
imp.onclick = importSshConfig; e.append(imp);
c.append(e);
} else c.append(emptyState('search', T('No results'), T('Adjust the search or filters.')));
}
}
function hostCard(hst) {
const tags = (hst.tags || []).length ? `${hst.tags.map((t) => `${esc(t)} `).join('')}
` : '';
const card = h(`${avatar(hst)}
${hst.protocol !== 'rdp' ? `${ICONS.folder} ` : ''}${ICONS.edit}
`);
card.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'edit') return editHost(hst);
if (a === 'sftp') return openSftp(hst);
});
card.addEventListener('dblclick', (e) => { if (!e.target.closest('[data-a]')) connectHost(hst); });
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(hst));
return card;
}
function hostMenu(hst) {
return [
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }] : []),
'-',
{ label: T('Edit'), icon: 'edit', run: () => editHost(hst) },
{ label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } },
{ label: T('Copy address'), icon: 'copy', run: () => call('clipboard:write', `${hst.username ? hst.username + '@' : ''}${hst.address}`) },
'-',
{ label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); reload(); } } },
];
}
function connectHost(hst) {
if (hst.protocol === 'rdp') return openRdp(hst);
openTerminal(hst);
}
async function openRdp(hst) {
if (settings().rdpEmbed !== false) {
const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message }));
if (s.ok) return new RdpSession(hst);
toast(T('RDP in a tab is not possible ({reason}) – opening a separate window.', { reason: s.reason }));
}
launchRdp(hst);
}
async function launchRdp(hst) {
try {
const client = await call('rdp:launch', hst.id || hst);
toast(T('RDP session to {name} started ({client})', { name: hst.label || hst.address, client }), 'ok');
} catch {}
}
function parseQuick(q) {
const m = q.trim().match(/^(?:(rdp|ssh):\/\/)?(?:([^@\s]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/i);
if (!m) return null;
return { protocol: (m[1] || 'ssh').toLowerCase(), username: m[2], address: m[3].replace(/^\[|\]$/g, ''), port: m[4] ? Number(m[4]) : undefined, label: q.trim() };
}
function quickConnect(q) {
const hst = parseQuick(q);
if (!hst) return toast(T('Invalid address. Format: user@host:port'), 'error');
connectHost(hst);
}
async function importSshConfig() {
try { const n = await call('vault:importSshConfig'); toast(T('{n} host(s) imported', { n }), 'ok'); reload(); } catch {}
}
// ============================================================ Drawer / Editoren
function openDrawer(title, fields, onSave, extraFooter) {
const d = $('#drawer');
d.innerHTML = '';
const form = h('
');
const hd = h(`${esc(title)} ${ICONS.close} `);
$('button', hd).onclick = closeDrawer;
const ft = h('');
if (extraFooter) ft.prepend(extraFooter);
const cancel = h(`${esc(T('Cancel'))} `); cancel.onclick = closeDrawer;
const save = h(`${esc(T('Save'))} `);
save.onclick = async () => { try { if ((await onSave()) !== false) closeDrawer(); } catch (e) { if (e.message) toast(e.message, 'error'); } };
ft.append(cancel, save);
form.append(...[].concat(fields));
d.append(hd, form, ft);
d.classList.add('open');
d.onkeydown = (e) => { if (e.key === 'Escape') closeDrawer(); if (e.key === 'Enter' && e.ctrlKey) save.click(); };
setTimeout(() => $('input,select,textarea', form)?.focus(), 50);
return form;
}
function closeDrawer() { $('#drawer').classList.remove('open'); }
function field(label, name, value = '', opts = {}) {
const { type = 'text', placeholder = '', hint = '', cls = '', options } = opts;
let input;
if (type === 'select') input = `${options.map(([v, l]) => `${esc(l)} `).join('')} `;
else if (type === 'textarea') input = ``;
else if (type === 'password') input = `${ICONS.eye}
`;
else input = ` `;
const el = h(`${esc(label)} ${input}${hint ? `
${esc(hint)}
` : ''}
`);
const eye = $('.pw-wrap button', el);
if (eye) eye.onclick = () => { const i = $('input', el); i.type = i.type === 'password' ? 'text' : 'password'; };
return el;
}
const row = (...els) => { const r = h('
'); r.append(...els); return r; };
const check = (label, name, checked) => h(` ${esc(label)} `);
const heading = (t) => h(`${esc(t)} `);
function formValues(root) {
const o = {};
$$('[name]', root).forEach((i) => { o[i.name] = i.type === 'checkbox' ? i.checked : i.value; });
return o;
}
function colorPicker(current) {
const el = h(``);
const wrap = $('.colors', el);
['', ...COLORS].forEach((c) => {
const b = h(` `);
b.onclick = () => { $('input', wrap).value = c; $$('button', wrap).forEach((x) => x.classList.toggle('active', x === b)); };
wrap.append(b);
});
return el;
}
function groupOptions() {
const out = [['', T('— No group —')]];
const walk = (parent, depth) => S.vault.groups.filter((g) => (g.parentId || null) === parent).forEach((g) => { out.push([g.id, `${' '.repeat(depth)}${g.label}`]); walk(g.id, depth + 1); });
walk(null, 0);
return out;
}
function editHost(hst = {}) {
let proto = hst.protocol || 'ssh';
const isNew = !hst.id;
const seg = h(`SSH RDP
`);
const sshBox = h('
');
const rdpBox = h('
');
const portField = field('Port', 'port', hst.port || '', { type: 'number', placeholder: proto === 'rdp' ? '3389' : '22', cls: 'small' });
const setProto = (p) => {
proto = p;
$$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.p === p));
sshBox.style.display = p === 'ssh' ? '' : 'none';
rdpBox.style.display = p === 'rdp' ? '' : 'none';
$('input', portField).placeholder = p === 'rdp' ? '3389' : '22';
};
$$('button', seg).forEach((b) => (b.onclick = () => setProto(b.dataset.p)));
const keys = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])];
const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((x) => x.id !== hst.id && x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address])];
sshBox.append(
field(T('SSH key'), 'keyId', hst.keyId, { type: 'select', options: keys }),
check(T('Use SSH agent'), 'useAgent', hst.useAgent !== false),
heading(T('Advanced')),
field(T('Jump host (ProxyJump)'), 'jumpHostId', hst.jumpHostId, { type: 'select', options: jumps }),
field(T('Startup command'), 'startupCommand', hst.startupCommand, { placeholder: T('e.g. tmux attach || tmux') }),
field(T('Environment variables'), 'env', hst.env, { type: 'textarea', placeholder: 'LANG=de_DE.UTF-8', hint: T('One per line, KEY=VALUE (server must allow AcceptEnv).') }),
);
rdpBox.append(
field(T('Domain'), 'domain', hst.domain, { placeholder: 'optional' }),
row(field(T('Width'), 'rdpWidth', hst.rdpWidth, { type: 'number', placeholder: 'auto' }), field(T('Height'), 'rdpHeight', hst.rdpHeight, { type: 'number', placeholder: 'auto' })),
field(T('On resize'), 'rdpResize', hst.rdpResize || 'dynamic', { type: 'select', options: [['dynamic', T('Adjust resolution dynamically')], ['scale', T('Scale image (fixed resolution)')]] }),
check(T('Fullscreen'), 'rdpFullscreen', hst.rdpFullscreen),
check(T('Share clipboard'), 'rdpClipboard', hst.rdpClipboard !== false),
check(T('Play audio'), 'rdpAudio', hst.rdpAudio !== false),
check(T('Share local drives/home'), 'rdpDrives', hst.rdpDrives),
);
const form = openDrawer(isNew ? T('New host') : T('Edit host'), [
seg,
row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField),
field(T('Label'), 'label', hst.label, { placeholder: T('My server') }),
field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }),
...(S.vault.vpns.length ? [field(T('VPN'), 'vpnId', hst.vpnId, { type: 'select', options: [['', T('— No VPN —')], ...S.vault.vpns.map((v) => [v.id, v.label])], hint: T('Connected automatically before connecting to this host.') })] : []),
field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }),
heading(T('Authentication')),
field(T('Username'), 'username', hst.username, { placeholder: 'root' }),
field(T('Password'), 'password', hst.password, { type: 'password', placeholder: T('stored encrypted') }),
sshBox, rdpBox,
heading(T('Appearance')),
colorPicker(hst.color),
], async () => {
const v = formValues(form);
if (!v.address.trim()) throw new Error(T('Please enter an address.'));
const item = {
...hst, ...v, protocol: proto,
address: v.address.trim(),
port: v.port ? Number(v.port) : undefined,
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null, vpnId: v.vpnId || null,
};
await call('vault:upsert', 'hosts', item);
reload();
}, isNew ? null : (() => { const b = h(`${ICONS.trash} `); b.title = T('Delete'); b.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); closeDrawer(); reload(); } }; return b; })());
setProto(proto);
return form;
}
function editGroup(g = {}) {
const parents = groupOptions().filter(([id]) => id !== g.id);
const form = openDrawer(g.id ? T('Edit group') : T('New group'), [
field('Name', 'label', g.label, { placeholder: T('Production') }),
field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: parents }),
colorPicker(g.color),
], async () => {
const v = formValues(form);
if (!v.label.trim()) throw new Error(T('Name is missing.'));
await call('vault:upsert', 'groups', { ...g, ...v, parentId: v.parentId || null });
reload();
});
}
// ============================================================ Keychain
function viewKeys(page) {
page.append(toolbar(T('Search keys …'), [
{ label: T('Import'), icon: 'download', run: () => editKey({}, true) },
{ label: T('Generate'), icon: 'plus', cls: 'primary', run: generateKey },
]));
const c = h('
'); page.append(c);
const list = S.vault.keys.filter((k) => matches(k.label, k.publicKey));
if (!list.length) return c.append(emptyState('key', T('No keys'), T('Generate a new Ed25519 key or import an existing one.'), T('Generate key'), generateKey));
c.append(h(`${T('Keys')} ${list.length}
`));
const grid = h('
');
for (const k of list) {
const type = (k.publicKey || '').split(' ')[0] || 'key';
const used = S.vault.hosts.filter((x) => x.keyId === k.id).length;
const card = h(`${ICONS.key}
${ICONS.copy} ${ICONS.edit}
`);
card.onclick = (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'copy') { call('clipboard:write', k.publicKey || ''); toast(T('Public key copied'), 'ok'); }
else editKey(k);
};
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
{ label: T('Copy public key'), icon: 'copy', run: () => { call('clipboard:write', k.publicKey || ''); toast(T('Public key copied'), 'ok'); } },
{ label: T('Edit'), icon: 'edit', run: () => editKey(k) },
'-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete key?'), T('“{name}” will be removed from the vault.', { name: k.label }))) { await call('vault:remove', 'keys', k.id); reload(); } } },
]);
grid.append(card);
}
c.append(grid);
}
async function generateKey() {
const r = await modal({
title: T('Generate SSH key'),
body: `Name
${T('Type')} Ed25519 (${T('recommended')}) ECDSA RSA 4096
Passphrase (optional)
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Generate'), value: 'form', cls: 'primary' }],
});
if (!r) return;
const k = await call('key:generate', { type: r.type, passphrase: r.passphrase, comment: r.label });
await call('vault:upsert', 'keys', { label: r.label, privateKey: k.privateKey, publicKey: k.publicKey, passphrase: r.passphrase || undefined });
await call('clipboard:write', k.publicKey);
toast(T('Key generated – public key copied to clipboard'), 'ok');
reload();
}
function editKey(k = {}, pick = false) {
const form = openDrawer(k.id ? T('Edit key') : T('Import key'), [
field('Name', 'label', k.label, { placeholder: 'id_ed25519' }),
field('Private Key', 'privateKey', k.privateKey, { type: 'textarea', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----' }),
field('Passphrase', 'passphrase', k.passphrase, { type: 'password' }),
field('Public Key', 'publicKey', k.publicKey, { type: 'textarea', hint: T('Derived from the private key automatically if left empty.') }),
], async () => {
const v = formValues(form);
if (!v.privateKey.trim()) throw new Error(T('Private key is missing.'));
const parsed = await call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase });
await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey });
reload();
}, (() => { const b = h(`${ICONS.folder}${T('File…')} `); b.onclick = loadFile; return b; })());
async function loadFile() {
const f = await call('key:pickFile');
if (!f) return;
$('[name=privateKey]', form).value = f.content;
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name;
if (f.publicKey) $('[name=publicKey]', form).value = f.publicKey;
}
if (pick) loadFile();
}
// ============================================================ Snippets
function viewSnippets(page) {
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
const c = h('
'); page.append(c);
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
if (!list.length) return c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet()));
const l = h('
');
for (const s of list) {
const card = h(`${ICONS.code}
${ICONS.play} ${ICONS.copy} ${ICONS.trash}
`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'run') runSnippet(s);
else if (a === 'copy') { call('clipboard:write', s.command); toast(T('Copied'), 'ok'); }
else if (a === 'del') { if (await confirmBox(T('Delete snippet?'), s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } }
else editSnippet(s);
};
l.append(card);
}
c.append(l);
}
function editSnippet(s = {}) {
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
field('Name', 'label', s.label, { placeholder: T('System update') }),
field(T('Command(s)'), 'command', s.command, { type: 'textarea', placeholder: 'sudo pacman -Syu' }),
check(T('Send Enter automatically'), 'autoRun', s.autoRun !== false),
], async () => {
const v = formValues(form);
if (!v.command.trim()) throw new Error(T('Command is missing.'));
await call('vault:upsert', 'snippets', { ...s, ...v, label: v.label || v.command.split('\n')[0].slice(0, 40) });
reload();
});
}
function runSnippet(s, session) {
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
if (!t) return toast(T('No open terminal'), 'error');
t.sendText(s.command.replace(/\r?\n/g, '\r') + (s.autoRun !== false ? '\r' : ''));
activateTab(t.id);
t.term.focus();
}
// ============================================================ Port Forwarding
function viewForwards(page) {
page.append(toolbar(T('Search rules …'), [{ label: T('New rule'), icon: 'plus', cls: 'primary', run: () => editForward() }]));
const c = h('
'); page.append(c);
const list = S.vault.forwards.filter((f) => matches(f.label, f.destHost));
if (!list.length) return c.append(emptyState('fwd', T('No port forwards'), T('Local (-L), remote (-R) or dynamic as SOCKS5 proxy (-D).'), T('New rule'), () => editForward()));
const l = h('
');
for (const f of list) {
const hst = S.vault.hosts.find((x) => x.id === f.hostId);
const on = S.activeForwards.has(f.id);
const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}`
: f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}`
: `${T('Local')} ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`;
const card = h(`${ICONS.fwd}
${on ? ICONS.stop + T('Stop') : ICONS.play + T('Start')} ${ICONS.trash}
`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'toggle') {
const b = e.target.closest('button'); b.disabled = true;
try {
if (on) await call('fw:stop', f.id);
else { await call('fw:start', f.id); toast(T('Forward active'), 'ok'); }
} catch {}
reload();
} else if (a === 'del') { if (await confirmBox(T('Delete rule?'), f.label || desc)) { await call('fw:stop', f.id); await call('vault:remove', 'forwards', f.id); reload(); } }
else editForward(f);
};
l.append(card);
}
c.append(l);
}
function editForward(f = {}) {
const hosts = S.vault.hosts.filter((x) => x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address]);
if (!hosts.length) return toast(T('Add an SSH host first.'), 'error');
const form = openDrawer(f.id ? T('Edit rule') : T('New forward'), [
field(T('Label'), 'label', f.label, { placeholder: 'Postgres Prod' }),
field(T('Type'), 'type', f.type || 'local', { type: 'select', options: [['local', T('Local (-L)')], ['remote', 'Remote (-R)'], ['dynamic', T('Dynamic / SOCKS5 (-D)')]] }),
field(T('Via host'), 'hostId', f.hostId, { type: 'select', options: hosts }),
row(field(T('Bind address'), 'bindAddress', f.bindAddress, { placeholder: '127.0.0.1' }), field(T('Bind port'), 'bindPort', f.bindPort, { type: 'number', placeholder: '8080', cls: 'small' })),
row(field(T('Target host'), 'destHost', f.destHost, { placeholder: 'localhost' }), field(T('Target port'), 'destPort', f.destPort, { type: 'number', placeholder: '5432', cls: 'small' })),
], async () => {
const v = formValues(form);
if (!v.bindPort) throw new Error(T('Bind port is missing.'));
if (v.type !== 'dynamic' && !v.destPort) throw new Error(T('Target port is missing.'));
await call('vault:upsert', 'forwards', { ...f, ...v });
reload();
});
}
// ============================================================ VPN (WireGuard / OpenVPN)
S.vpnStatus = {};
const VPN_TYPES = { wireguard: 'WireGuard', openvpn: 'OpenVPN' };
function viewVpns(page) {
page.append(toolbar(T('Search VPNs …'), [
{ label: T('Import'), icon: 'download', run: () => editVpn({}, true) },
{ label: T('New VPN'), icon: 'plus', cls: 'primary', run: () => editVpn() },
]));
const c = h('
'); page.append(c);
const list = S.vault.vpns.filter((v) => matches(v.label, VPN_TYPES[v.type]));
if (!list.length) return c.append(emptyState('shield', T('No VPNs'), T('Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.'), T('New VPN'), () => editVpn()));
const l = h('
');
for (const v of list) {
const on = !!S.vpnStatus[v.id];
const n = S.vault.hosts.filter((x) => x.vpnId === v.id).length;
const card = h(`${ICONS.shield}
${on ? ICONS.stop + T('Disconnect') : ICONS.play + T('Connect')} ${ICONS.edit} ${ICONS.trash}
`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'toggle') {
const b = e.target.closest('button'); b.disabled = true; b.innerHTML = ` ${on ? T('Disconnect') : T('Connecting …')}`;
try { await call(on ? 'vpn:down' : 'vpn:up', v.id); if (!on) toast(T('VPN “{name}” connected', { name: v.label }), 'ok'); } catch {}
S.vpnStatus = await call('vpn:status').catch(() => ({}));
render();
} else if (a === 'del') {
if (await confirmBox(T('Delete VPN?'), T('“{name}” will be removed. Assigned hosts will connect without VPN.', { name: v.label }))) { await call('vault:remove', 'vpns', v.id); reload(); }
} else editVpn(v);
};
l.append(card);
}
c.append(l);
}
function editVpn(v = {}, pick = false) {
let type = v.type || 'wireguard';
const seg = h('WireGuard OpenVPN
');
const ovpnBox = h('
');
ovpnBox.append(
heading(T('Authentication')),
field(T('Username'), 'username', v.username, { placeholder: T('optional') }),
field(T('Password'), 'password', v.password, { type: 'password', placeholder: T('stored encrypted') }),
);
const cfg = field(T('Configuration'), 'config', v.config, { type: 'textarea', placeholder: '[Interface]\nPrivateKey = …', hint: '' });
$('textarea', cfg).style.minHeight = '180px';
const setType = (t) => {
type = t;
$$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.t === t));
ovpnBox.style.display = t === 'openvpn' ? '' : 'none';
$('textarea', cfg).placeholder = t === 'openvpn' ? 'client\ndev tun\nremote vpn.example.com 1194\n… ' : '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\n\n[Peer]\n…';
const hint = $('.hint', cfg) || cfg.appendChild(h('
'));
hint.textContent = t === 'openvpn' ? T('Certificates and keys must be embedded in the .ovpn file (, , …).') : T('Contents of a WireGuard .conf file.');
};
$$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t)));
// Hosts zuordnen
const hostBox = h(`
`);
const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
if (!sorted.length) hostBox.append(h(`${T('No hosts yet')}
`));
sorted.forEach((x) => hostBox.append(h(` ${esc(x.label || x.address)}${esc(hostSub(x))} `)));
const form = openDrawer(v.id ? T('Edit VPN') : T('New VPN'), [
seg,
field(T('Name'), 'label', v.label, { placeholder: T('Office VPN') }),
cfg,
ovpnBox,
check(T('Disconnect when MrTerm closes'), 'disconnectOnQuit', v.disconnectOnQuit !== false),
heading(T('Assigned hosts')),
hostBox,
], async () => {
const f = formValues(form);
if (!f.config.trim()) throw new Error(T('Configuration is missing.'));
if (type === 'wireguard' && !/\[Interface\]/i.test(f.config)) throw new Error(T('This does not look like a WireGuard configuration.'));
const item = { ...v, type, label: f.label.trim() || VPN_TYPES[type], config: f.config, disconnectOnQuit: f.disconnectOnQuit,
username: type === 'openvpn' ? f.username : '', password: type === 'openvpn' ? f.password : '' };
const saved = await call('vault:upsert', 'vpns', item);
for (const cb of $$('[data-host]', hostBox)) {
const hst = S.vault.hosts.find((x) => x.id === cb.dataset.host);
const want = cb.checked ? saved.id : (hst.vpnId === saved.id ? null : hst.vpnId || null);
if ((hst.vpnId || null) !== want) await call('vault:upsert', 'hosts', { ...hst, vpnId: want });
}
reload();
}, (() => { const b = h(`${ICONS.folder}${T('File…')} `); b.onclick = loadFile; return b; })());
async function loadFile() {
const f = await call('import:pickFile', T('VPN configuration'), ['conf', 'ovpn']);
if (!f) return;
$('[name=config]', form).value = f.content;
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name.replace(/\.(conf|ovpn)$/i, '');
setType(/\.ovpn$/i.test(f.name) || /^\s*(client|remote)\b/m.test(f.content) ? 'openvpn' : 'wireguard');
}
setType(type);
if (pick) loadFile();
}
// ============================================================ Known Hosts & Verlauf
function viewKnown(page) {
page.append(toolbar(T('Search known hosts …')));
const c = h('
'); page.append(c);
const entries = Object.entries(S.vault.knownHosts).filter(([id, v]) => matches(id, v.fingerprint));
if (!entries.length) return c.append(emptyState('shield', T('No known hosts'), T('Host keys are stored here on first connection.')));
const l = h('
');
for (const [id, v] of entries) {
const card = h(`${ICONS.shield}
${ICONS.trash}
`);
$('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); };
l.append(card);
}
c.append(l);
}
function viewHistory(page) {
page.append(toolbar(T('Search history …')));
const c = h('
'); page.append(c);
const items = S.vault.history.map((x) => ({ ...x, host: S.vault.hosts.find((y) => y.id === x.hostId) })).filter((x) => x.host && matches(x.host.label, x.host.address));
if (!items.length) return c.append(emptyState('clock', T('No connections yet'), T('Recently used hosts appear here.')));
const l = h('
');
for (const it of items) {
const card = h(`${avatar(it.host)}
${T('Connect')} `);
card.onclick = () => connectHost(it.host);
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(it.host));
l.append(card);
}
c.append(l);
}
// ============================================================ Einstellungen
async function viewSettings(page) {
const st = settings();
page.append(h(`
${T('Settings')} `));
const c = h('
'); page.append(c);
const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); };
// ---- Sprache
const langCard = h(`
${T('Language')} `);
const langField = field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System default')], ...I18N.LANGUAGES] });
langField.onchange = async () => { await save({ language: $('select', langField).value }); I18N.setLanguage(settings().language, navigator.language); reload(); };
langCard.append(langField);
// ---- App-Design
const designCard = h(``);
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(``);
p.onclick = async () => { await save({ appTheme: id }); applyAppTheme(); render(); };
$('.design-list', designCard).append(p);
}
['', ...COLORS.slice(0, 7), '#21c7a8', '#cba6f7'].forEach((col) => {
const b = h(` `);
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b);
});
const themeCard = h(`${T('Terminal color scheme')}
`);
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
const autoP = h(`${T('Match theme')}
user@host :
~ $ ls
${T('follows the theme')} `);
autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); };
$('.theme-list', themeCard).append(autoP);
for (const [id, t] of Object.entries(TERM_THEMES)) {
const p = h(`${esc(t.name)}
user@host :
~ $ ls
docs run.sh err `);
p.onclick = async () => { await save({ terminalTheme: id }); render(); };
$('.theme-list', themeCard).append(p);
}
const termCard = h('
Terminal ');
termCard.append(
row(field(T('Font'), 'fontFamily', st.fontFamily), field(T('Size'), 'fontSize', st.fontSize, { type: 'number', cls: 'small' })),
row(field('Cursor', 'cursorStyle', st.cursorStyle, { type: 'select', options: [['block', 'Block'], ['bar', T('Bar')], ['underline', T('Underline')]] }), field(T('Scrollback lines'), 'scrollback', st.scrollback, { type: 'number' })),
check(T('Blinking cursor'), 'cursorBlink', st.cursorBlink),
check(T('Copy on select'), 'copyOnSelect', st.copyOnSelect),
field(T('SSH keep-alive (seconds, 0 = off)'), 'keepAlive', st.keepAlive, { type: 'number' }),
);
termCard.addEventListener('change', () => {
const v = formValues(termCard);
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
});
const rdpCard = h(``);
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
emb.onchange = async () => {
await save({ rdpEmbed: $('input', emb).checked });
if (api.platform === 'linux') toast(T('Takes effect after restarting MrTerm (X11/XWayland mode).'));
};
rdpCard.append(emb);
if (api.platform === 'linux') rdpCard.append(h(`${T('Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.')}
`));
if (api.platform !== 'win32') {
const f = field(T('FreeRDP client (separate window)'), 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', T('Automatic')], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] });
f.onchange = () => save({ rdpClientLinux: $('select', f).value }).then(render);
rdpCard.append(f);
}
api.call('rdp:detect').then((d) => {
$('.rdp-info', rdpCard).textContent = d.available ? T('Client in use: {client}', { client: d.client }) : T('No RDP client found. Install: {hint}', { hint: d.hint });
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
}).catch(() => {});
const dataCard = h(`${T('Data')} ${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.')}
`);
const btns = [
[T('Import ~/.ssh/config'), importSshConfig],
[T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }],
[T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }],
];
btns.forEach(([l, fn]) => { const b = h(`${esc(l)} `); b.onclick = fn; $('.row', dataCard).append(b); });
// ---- Import aus anderen Programmen
const importCard = h(`${T('Import')} ${T('Import folders and hosts (RDP & SSH) from Devolutions Remote Desktop Manager . In RDM: File → Export → Export entries as .rdm (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.')}
`);
const rdmBtn = h(`${T('Import from Remote Desktop Manager…')} `);
rdmBtn.onclick = importRdm;
importCard.append(rdmBtn);
// ---- App-Sperre
const secCard = h(`${T('App lock')} ${T('Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.')}
`);
renderLockSettings($('.lock-rows', secCard));
// ---- Updates
const updCard = h(`Updates ${T('Installed version: {v}', { v: '…' })}
`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
const updFields = h('
');
updFields.append(
field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: T('Gitea/Forgejo repository whose releases contain the installation packages.') }),
field(T('Access token (private repos only)'), 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }),
check(T('Check for updates on startup'), 'updateAutoCheck', st.updateAutoCheck),
check(T('Include pre-releases'), 'updatePrerelease', st.updatePrerelease),
);
updFields.addEventListener('change', () => save(formValues(updFields)));
const chk = h(`${ICONS.refresh}${T('Check for updates now')} `);
chk.onclick = async () => {
chk.disabled = true;
try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r);
else if (r.repoVersion) toast(T('The repository has version {repo}, but there is no release with installation packages for it yet. Installed: {current}.', { repo: r.repoVersion, current: r.current }), 'error');
else if (r.noRelease) toast(T('The repository has no releases yet.'), 'error');
else toast(T('MrTerm is up to date ({v}).', { v: r.current }), 'ok'); } catch {}
chk.disabled = false;
};
updCard.append(updFields, chk);
const C = `${T('Ctrl')} `;
const keysCard = h(`${T('Keyboard shortcuts')}
${C}+Shift +K / T ${T('Quick Connect / command palette (outside the terminal also {k})', { k: `${C}+K ` })}
${C}+Shift +W ${T('Close tab')}
${C}+Tab ${T('Next tab')}
${C}+1..9 ${T('Switch to tab')}
${C}+Shift +C/V ${T('Copy / paste in terminal')}
${C}+Shift +F ${T('Search in terminal')}
${C}++/−/0 ${T('Font size')}
${C}+Shift +L ${T('Lock now')}
`);
c.append(langCard, secCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
}
// ============================================================ App-Sperre
function showLock() {
if ($('.lock-screen')) return;
const m = S.lock.meta || {};
I18N.setLanguage(m.language, navigator.language);
applyStatic();
applyAppTheme(m);
closeDrawer();
$$('.ctx').forEach((x) => x.remove());
$('.palette')?.closest('.modal-bg')?.remove();
const el = h(`
${T('MrTerm is locked')}
${S.lock.hasPassword ? `
` : ''}
${S.lock.fido.length ? `
${ICONS.key}${T('Unlock with security key')} ` : ''}
`);
const err = $('.lock-err', el);
const done = async () => { el.remove(); await reload(); };
const form = $('.lock-pw', el);
if (form) form.onsubmit = async (e) => {
e.preventDefault();
const inp = $('input', form);
err.textContent = '';
try { await api.call('lock:unlockPassword', inp.value); done(); } catch (x) { err.textContent = x.message; inp.select(); }
};
const fb = $('.lock-fido', el);
if (fb) fb.onclick = async () => {
err.textContent = ''; fb.disabled = true;
try { await api.call('lock:unlockFido'); done(); } catch (x) { err.textContent = x.message; }
fb.disabled = false;
};
document.body.append(el);
($('input', el) || fb)?.focus();
}
async function lockNow() {
if (!S.lock?.enabled) return;
S.lock = await call('lock:lock');
if (S.lock.locked) showLock();
}
$('#lockBtn').onclick = lockNow;
// Automatische Sperre nach Inaktivität (Einstellung autoLock in Minuten, 0 = aus)
let lastActivity = Date.now();
['keydown', 'mousedown', 'mousemove', 'wheel'].forEach((ev) => document.addEventListener(ev, () => { lastActivity = Date.now(); }, { capture: true, passive: true }));
setInterval(() => {
const min = Number(S.vault?.settings.autoLock) || 0;
if (!min || !S.lock?.enabled || S.lock.locked || $('.lock-screen')) return;
// In einem eingebetteten RDP-Fenster sieht MrTerm keine Eingaben – dort nicht automatisch sperren
if (S.tabs.some((t) => t.id === S.active && t.kind === 'rdp')) { lastActivity = Date.now(); return; }
if (Date.now() - lastActivity > min * 60000) lockNow();
}, 15000);
function renderLockSettings(box) {
const L = S.lock;
box.innerHTML = '';
const refresh = (st) => { S.lock = st; $('#lockBtn').style.display = st.enabled ? '' : 'none'; renderLockSettings(box); };
const disableWarn = async () => (L.hasPassword ? 1 : 0) + L.fido.length > 1 || confirmBox(T('Disable app lock?'), T('This is the last unlock method. MrTerm will no longer be locked.'), T('Disable'));
// Passwort
const pwRow = h(`${T('Password')} ${L.hasPassword ? T('Set') : T('Not set')}
`);
const pwBtn = h(`${L.hasPassword ? T('Change password') : T('Set password')} `);
pwBtn.onclick = async () => {
const r = await modal({ title: L.hasPassword ? T('Change password') : T('Set password'),
body: `${T('New password')}
${T('Repeat password')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
try { refresh(await call('lock:setPassword', r.a)); toast(T('Password saved'), 'ok'); } catch {}
};
pwRow.append(pwBtn);
if (L.hasPassword) {
const rm = h(`${ICONS.trash} `);
rm.onclick = async () => { if (await disableWarn()) refresh(await call('lock:removePassword')); };
pwRow.append(rm);
}
box.append(pwRow);
// Sicherheitsschlüssel
for (const k of L.fido) {
const row = h(`${ICONS.key}${esc(k.label)} ${T('Security key (FIDO2)')}
`);
const rm = h(`${ICONS.trash} `);
rm.onclick = async () => { if (await disableWarn() && await confirmBox(T('Remove security key?'), k.label, T('Remove'))) refresh(await call('lock:removeFido', k.id)); };
row.append(rm);
box.append(row);
}
const add = h(`${ICONS.plus}${T('Add security key')} `);
add.onclick = async () => {
const label = await promptBox(T('Add security key'), T('Name'), 'YubiKey');
if (label == null) return;
add.disabled = true;
try { refresh(await call('lock:addFido', label)); toast(T('Security key added'), 'ok'); } catch {}
add.disabled = false;
};
const actions = h('
');
actions.append(add);
if (L.enabled) {
const now = h(`${ICONS.lock}${T('Lock now')} `);
now.onclick = lockNow;
actions.append(now);
}
box.append(actions);
if (L.enabled) {
const al = field(T('Lock automatically after inactivity'), 'autoLock', String(settings().autoLock || 0), { type: 'select',
options: [['0', T('Never')], ...[1, 5, 10, 15, 30, 60].map((n) => [String(n), T('{n} minutes', { n })])] });
al.style.marginTop = '14px';
al.onchange = async () => { const v = Number($('select', al).value); await call('vault:settings', { autoLock: v }); S.vault.settings.autoLock = v; };
box.append(al);
box.append(h(`${T('If you forget the password and lose all security keys, the vault cannot be recovered.')}
`));
}
}
// ============================================================ Design
function termTheme() {
const id = settings().terminalTheme;
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
return TERM_THEMES[id] || TERM_THEMES.mrterm;
}
function applyAppTheme(st = settings()) {
const root = document.documentElement;
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
S.tabs.forEach((t) => t.applySettings?.());
}
// ============================================================ RDM-Import
async function importRdm() {
const f = await call('import:pickFile', 'Remote Desktop Manager Export', ['rdm', 'xml', 'json', 'csv']);
if (!f) return;
let entries;
try { entries = parseRdmExport(f.name, f.content); } catch (e) { return toast(T('Import failed: {msg}', { msg: e.message }), 'error'); }
const hosts = entries.filter((e) => e.host);
if (!hosts.length) return toast(T('No RDP or SSH entries found in the file.'), 'error');
const folders = new Set(entries.map((e) => e.folder.join(' / ')).filter(Boolean));
const rdpN = hosts.filter((e) => e.host.protocol === 'rdp').length;
const preview = hosts.slice(0, 8).map((e) => ``).join('');
const ok = await modal({
title: T('Import from Remote Desktop Manager'),
text: T('{file}: found {n} hosts ({rdp} RDP, {ssh} SSH) in {folders} folders.', { file: f.name, n: hosts.length, rdp: rdpN, ssh: hosts.length - rdpN, folders: folders.size }),
body: `${preview}
${hosts.length > 8 ? `${T('… and {n} more', { n: hosts.length - 8 })}
` : ''}`,
buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: T('Import'), value: true, cls: 'primary' }],
});
if (!ok) return;
const r = await call('vault:bulkImport', entries);
toast(T('{n} hosts imported', { n: r.hosts }) + (r.skipped ? T(', {n} already existed', { n: r.skipped }) : ''), 'ok');
await reload();
$('[data-view=hosts]').click();
}
// ============================================================ Updates
let updateInfo = null;
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
const r = await modal({
title: T('MrTerm {v} is available', { v: info.version }),
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
body: `${info.notes ? `${esc(info.notes)}
` : ''}
${info.kind === 'dev' ? `${T('Development mode: please update via git pull.')}
` : !info.asset ? `${T('The release contains no package for this system.')}
` : ''}
`,
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
});
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast(T('Downloading update …'));
try { await call('update:download'); toast(T('Installing update, MrTerm will restart …'), 'ok'); await call('update:install'); } catch {}
}
api.on('update:available', (info) => showUpdate(info));
api.on('update:progress', (done, total) => {
const b = $('#updateBadge span');
if (b) b.textContent = total ? `Update ${Math.round((done / total) * 100)}%` : 'Update …';
});
// ============================================================ Tabs
function addTab(session) {
S.tabs.push(session);
const tab = h(`${esc(session.title)} ${ICONS.close}
`);
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker }[session.kind] || openTerminal)(session.host) }] : []),
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
{ label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) },
]);
session.tabEl = tab;
$('#tabs').append(tab);
$('#sessions').append(session.el);
activateTab(session.id);
}
function setTabState(session, state) { const d = $('.dot', session.tabEl); if (d) d.className = 'dot ' + state; }
function activateTab(id) {
S.active = id;
$$('#tabs .tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === id));
$('#homeView').classList.toggle('active', id === 'home');
document.body.classList.toggle('in-session', id !== 'home');
S.tabs.forEach((t) => {
t.el.classList.toggle('active', t.id === id);
if (t.id === id) requestAnimationFrame(() => t.onShow?.());
else t.onHide?.();
});
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
renderTree();
}
function closeTab(id) {
const i = S.tabs.findIndex((t) => t.id === id);
if (i < 0) return;
const t = S.tabs[i];
t.dispose();
t.el.remove(); t.tabEl.remove();
S.tabs.splice(i, 1);
if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home');
}
$('.tab.home').onclick = () => activateTab('home');
// ============================================================ Terminal-Session
class TerminalSession {
constructor(host) {
this.kind = 'ssh';
this.id = uid();
this.host = host;
this.title = host.label || host.address;
this.el = h(`
${avatar(host, 22).replace('SSH ', '')}${esc(hostSub(host))}
${ICONS.folder}SFTP
${host.execCommand ? '' : `
${ICONS.docker}Docker `}
${ICONS.code}Snippets
${ICONS.search}
`);
this.overlay = null;
this.alive = false;
this.initTerm();
this.bindUi();
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this);
this.connect();
}
initTerm() {
const st = settings();
this.term = new Terminal({
fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink,
scrollback: st.scrollback, theme: termTheme(), allowProposedApi: true, macOptionIsMeta: true,
});
this.fit = new FitAddon.FitAddon();
this.search = new SearchAddon.SearchAddon();
this.term.loadAddon(this.fit);
this.term.loadAddon(this.search);
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_e, url) => api.call('shell:open', url)));
this.term.open($('.term', this.el));
$('.term', this.el).style.background = termTheme().background; // Innenabstand in Terminalfarbe
this.term.onData((d) => { if (this.alive) api.ssh.write(this.id, d); else if (d === '\r' && this.ended) this.reconnect(); });
this.term.onResize(({ cols, rows }) => { if (this.alive) api.ssh.resize(this.id, cols, rows); });
this.term.onSelectionChange(() => { if (settings().copyOnSelect && this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); });
this.term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
if (e.ctrlKey && e.shiftKey && e.code === 'KeyC') { if (this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); return false; }
if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') { this.paste(); return false; }
if (e.ctrlKey && e.shiftKey && e.code === 'KeyF') { this.toggleFind(true); return false; }
// App-Kürzel im Terminal nur mit Strg+Shift, damit Strg+W/K/T (nano, bash, …) im Terminal ankommen
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW', 'KeyL'].includes(e.code)) return false;
if (e.ctrlKey && e.code === 'Tab') return false;
if (e.ctrlKey && /^Digit[1-9]$/.test(e.code)) return false;
if (e.ctrlKey && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) { this.zoom(e.key); return false; }
return true;
});
$('.term', this.el).addEventListener('contextmenu', (e) => {
e.preventDefault();
if (this.term.hasSelection()) { api.call('clipboard:write', this.term.getSelection()); this.term.clearSelection(); toast(T('Copied')); }
else this.paste();
});
this.ro = new ResizeObserver(() => { if (this.el.classList.contains('active')) this.doFit(); });
this.ro.observe($('.term', this.el));
}
bindUi() {
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'sftp') openSftp(this.host);
if (a === 'docker') openDocker(this.host);
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
if (a === 'snipnew') editSnippet();
if (a === 'find') this.toggleFind();
const f = e.target.closest('[data-f]')?.dataset.f;
const inp = $('.findbar input', this.el);
if (f === 'next') this.search.findNext(inp.value);
if (f === 'prev') this.search.findPrevious(inp.value);
if (f === 'close') this.toggleFind(false);
});
$('.findbar input', this.el).addEventListener('keydown', (e) => {
if (e.key === 'Enter') (e.shiftKey ? this.search.findPrevious : this.search.findNext).call(this.search, e.target.value);
if (e.key === 'Escape') this.toggleFind(false);
});
}
renderSnips() {
const box = $('.snip-panel .items', this.el);
box.innerHTML = '';
if (!S.vault.snippets.length) box.append(h(`${T('No snippets yet.')}
`));
S.vault.snippets.forEach((s) => {
const it = h(`${esc(s.label)}
${esc(s.command)}
`);
it.onclick = () => runSnippet(s, this);
box.append(it);
});
}
toggleFind(open) {
const fb = $('.findbar', this.el);
const o = open ?? !fb.classList.contains('open');
fb.classList.toggle('open', o);
if (o) { $('input', fb).focus(); $('input', fb).select(); } else { this.search.clearDecorations?.(); this.term.focus(); }
}
async paste() { const t = await api.call('clipboard:read'); if (t) this.term.paste(t); }
sendText(t) { if (this.alive) api.ssh.write(this.id, t); }
zoom(k) {
const s = this.term.options.fontSize;
this.term.options.fontSize = k === '0' ? settings().fontSize : Math.max(8, Math.min(32, s + (k === '-' ? -1 : 1)));
this.doFit();
}
doFit() { try { this.fit.fit(); } catch {} }
onShow() { this.doFit(); this.term.focus(); }
applySettings() {
const st = settings();
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
$('.term', this.el).style.background = termTheme().background;
this.doFit();
}
showOverlay(msg, err) {
if (!this.overlay) { this.overlay = h('
'); $('.term-wrap', this.el).append(this.overlay); }
this.overlay.innerHTML = err
? `${esc(msg)}
${T('Edit host')} ${ICONS.refresh}${T('Reconnect')}
`
: `
${esc(msg)}
${T('Cancel')} `;
this.overlay.onclick = (e) => {
const o = e.target.closest('[data-o]')?.dataset.o;
if (o === 'retry') this.reconnect();
if (o === 'cancel') closeTab(this.id);
if (o === 'edit' && this.host.id) { activateTab('home'); editHost(S.vault.hosts.find((x) => x.id === this.host.id) || this.host); }
};
}
hideOverlay() { this.overlay?.remove(); this.overlay = null; }
async connect() {
this.ended = false;
setTabState(this, 'wait');
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
this.doFit();
try {
await api.call('ssh:open', this.id, hostRef(this.host), { cols: this.term.cols, rows: this.term.rows });
this.alive = true;
this.hideOverlay();
setTabState(this, 'on');
this.doFit();
api.ssh.resize(this.id, this.term.cols, this.term.rows);
if (S.active === this.id) this.term.focus();
} catch (e) {
setTabState(this, 'err');
this.showOverlay(e.message, true);
}
}
reconnect() {
api.ssh.close(this.id);
this.alive = false;
this.term.write(`\r\n\x1b[90m— ${T('Reconnecting')} —\x1b[0m\r\n`);
this.connect();
}
onEvent(type, payload) {
if (type === 'data') this.term.write(payload);
else if (type === 'status' && this.overlay) $('.msg', this.overlay).textContent = payload;
else if (type === 'error') this.term.write(`\r\n\x1b[31m${payload}\x1b[0m\r\n`);
else if (type === 'closed') {
if (!this.alive) return;
this.alive = false; this.ended = true;
setTabState(this, '');
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
}
}
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
}
function openTerminal(host) { return new TerminalSession(host); }
// ============================================================ RDP-Session (eingebettet im Tab)
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
S.covered = false;
function updateCover() {
const covered = !!document.querySelector('.modal-bg, .ctx, .lock-screen');
if (covered === S.covered) return;
S.covered = covered;
const t = S.tabs.find((x) => x.id === S.active && x.kind === 'rdp');
if (t) covered ? t.onHide() : t.onShow();
}
new MutationObserver(updateCover).observe(document.body, { childList: true, subtree: true });
class RdpSession {
constructor(host) {
this.kind = 'rdp';
this.id = uid();
this.host = host;
this.title = host.label || host.address;
this.attached = false;
this.el = h(`
${avatar(host, 22)}${esc(hostSub(host))}
${ICONS.refresh}${T('Reconnect')}
${api.platform === 'win32' ? `
${ICONS.screen}${T('Detach as window')} ` : ''}
${T('RDP session hidden while a dialog is open.')}
`);
this.area = $('.rdp-area', this.el);
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'reconnect') this.reconnect();
if (a === 'detach') this.detach();
});
let raf = 0;
this.ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => this.pushBounds()); });
this.ro.observe(this.area);
this.unsub = api.on('rdp:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this);
this.connect();
}
bounds() {
const r = this.area.getBoundingClientRect();
const d = window.devicePixelRatio || 1;
return { x: Math.round(r.left * d), y: Math.round(r.top * d), width: Math.round(r.width * d), height: Math.round(r.height * d) };
}
pushBounds() { if (this.attached && this.area.offsetWidth) api.rdp.bounds(this.id, this.bounds()); }
isVisible() { return S.active === this.id && !S.covered; }
showOverlay(msg, err) {
this.overlay?.remove();
this.overlay = h(`${err ? '' : '
'}
${esc(msg)}
${err ? `${ICONS.screen}${T('In separate window')} ${ICONS.refresh}${T('Reconnect')} ` : `${T('Cancel')} `}
`);
this.overlay.onclick = (e) => {
const o = e.target.closest('[data-o]')?.dataset.o;
if (o === 'retry') this.reconnect();
if (o === 'cancel') closeTab(this.id);
if (o === 'window') { launchRdp(this.host); closeTab(this.id); }
};
this.area.append(this.overlay);
}
async connect() {
setTabState(this, 'wait');
this.showOverlay(T('Connecting to {host} … Login or certificate prompts may appear in a separate window.', { host: this.host.address }));
await new Promise((r) => requestAnimationFrame(r));
try {
const ok = await api.call('rdp:open', this.id, this.host.id || this.host, this.bounds());
if (!ok && !this.attached && !this.disposed && !this.exited) this.showOverlay(T('The RDP window could not be embedded.'), true);
} catch (e) {
setTabState(this, 'err');
this.showOverlay(e.message, true);
}
}
onEvent(type, payload) {
if (type === 'attached') {
this.attached = true; this.exited = false;
this.overlay?.remove(); this.overlay = null;
setTabState(this, 'on');
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
} else if (type === 'exit') {
this.attached = false; this.exited = true;
setTabState(this, payload?.code ? 'err' : '');
this.showOverlay((payload?.code ? T('RDP session ended (code {code}).', { code: payload.code }) : T('RDP session ended.')) + (payload?.message ? ' ' + payload.message : ''), true);
}
}
reconnect() { api.rdp.close(this.id); this.attached = false; this.exited = false; this.connect(); }
async detach() {
if (!this.attached) return;
if (await api.call('rdp:detach', this.id)) { this.attached = false; closeTab(this.id); toast(T('Session continues in a separate window'), 'ok'); }
}
onShow() { if (this.attached && this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } }
onHide() { if (this.attached) api.rdp.hide(this.id); }
dispose() { this.disposed = true; this.unsub(); this.ro.disconnect(); api.rdp.close(this.id); }
}
// ============================================================ SFTP
function viewSftpPicker(page) {
page.append(toolbar(T('Choose a host for SFTP …')));
const c = h('
'); page.append(c);
const list = S.vault.hosts.filter((x) => x.protocol !== 'rdp' && matches(x.label, x.address, x.username));
if (!list.length) return c.append(emptyState('folder', T('No SSH hosts'), T('SFTP requires an SSH host.'), T('New host'), () => editHost({})));
c.append(h(`${T('Open host in SFTP browser')}
`));
const grid = h('
');
list.forEach((hst) => {
const card = h(``);
card.onclick = () => openSftp(hst);
grid.append(card);
});
c.append(grid);
}
class FilePane {
constructor(sftp, side) {
this.sftp = sftp; this.side = side; this.cwd = ''; this.files = []; this.sel = new Set(); this.sort = { k: 'name', dir: 1 };
this.el = h(``);
this.el.addEventListener('click', (e) => {
const p = e.target.closest('[data-p]')?.dataset.p;
if (p === 'up') this.up();
if (p === 'home') this.go(this.home);
if (p === 'refresh') this.go(this.cwd);
if (p === 'mkdir') this.mkdir();
if (p === 'xfer') this.transferSelected();
});
$('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); });
const files = $('.files', this.el);
files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } });
// Drag & Drop zwischen den Panes
this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } });
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; });
}
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
async init(home) {
this.home = home;
if (this.side === 'local' && api.platform === 'win32') {
const sel = $('.roots', this.el);
(await api.call('local:roots')).forEach((r) => sel.append(h(`${esc(r)} `)));
sel.onchange = () => this.go(sel.value);
}
return this.go(home);
}
async up() { this.go(await this.parent(this.cwd)); }
async go(dir) {
try {
const files = await this.list(dir);
this.cwd = dir; this.files = files; this.sel.clear();
$('.path', this.el).value = dir;
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 2).toUpperCase() + '\\';
this.draw();
} catch (e) { toast(e.message, 'error'); }
}
sorted() {
const { k, dir } = this.sort;
return [...this.files].filter((f) => f.name !== '.' && f.name !== '..').sort((a, b) => (b.isDir - a.isDir) || (k === 'name' ? a.name.localeCompare(b.name, undefined, { numeric: true }) : (a[k] - b[k])) * dir);
}
draw() {
const box = $('.files', this.el);
const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : '');
const rows = this.view = this.sorted();
box.innerHTML = `Name${arrow('name')} ${T('Size')}${arrow('size')} ${T('Modified')}${arrow('mtime')}
${ICONS.folder} ..
${rows.map((f, i) => `${f.isDir ? ICONS.folder : ICONS.file} ${esc(f.name)}
${f.isDir ? '' : fmtSize(f.size)} ${fmtDate(f.mtime)} `).join('')}
`;
$$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); }));
$('tr[data-up]', box).ondblclick = () => this.up();
$$('tr[data-i]', box).forEach((tr) => {
const f = rows[Number(tr.dataset.i)];
tr.onclick = (e) => {
if (e.ctrlKey) this.sel.has(f.name) ? this.sel.delete(f.name) : this.sel.add(f.name);
else if (e.shiftKey && this.lastIdx != null) { const [a, b] = [this.lastIdx, Number(tr.dataset.i)].sort((x, y) => x - y); rows.slice(a, b + 1).forEach((x) => this.sel.add(x.name)); }
else { this.sel.clear(); this.sel.add(f.name); }
this.lastIdx = Number(tr.dataset.i);
$$('tr[data-i]', box).forEach((r) => r.classList.toggle('sel', this.sel.has(rows[Number(r.dataset.i)].name)));
};
tr.ondblclick = async () => {
if (f.isDir) this.go(await this.join(this.cwd, f.name));
else if (this.side === 'local') api.call('local:op', 'open', await this.join(this.cwd, f.name));
else { this.sel = new Set([f.name]); this.transferSelected(); }
};
tr.ondragstart = (e) => { if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); } S.drag = { pane: this }; e.dataTransfer.effectAllowed = 'copy'; };
tr.oncontextmenu = (e) => {
e.preventDefault();
if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); this.draw(); }
ctxMenu(e.clientX, e.clientY, [
{ label: this.side === 'local' ? T('Upload') : T('Download'), icon: this.side === 'local' ? 'upload' : 'download', run: () => this.transferSelected() },
...(f.isDir ? [{ label: T('Open'), icon: 'folder', run: async () => this.go(await this.join(this.cwd, f.name)) }] : []),
{ label: T('Rename'), icon: 'edit', run: () => this.rename(f) },
{ label: T('Copy path'), icon: 'copy', run: async () => api.call('clipboard:write', await this.join(this.cwd, f.name)) },
...(this.side === 'remote' && !f.isDir ? [{ label: T('Permissions (chmod)…'), icon: 'lock', run: () => this.chmod(f) }] : []),
'-', { label: this.side === 'local' ? T('Move to trash') : T('Delete'), icon: 'trash', danger: true, run: () => this.remove() },
]);
};
});
}
async mkdir() {
const n = await promptBox(T('New folder'), T('Name')); if (!n) return;
const p = await this.join(this.cwd, n);
try { this.side === 'local' ? await api.call('local:op', 'mkdir', p) : await api.call('sftp:op', this.sftp.id, 'mkdir', p); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async rename(f) {
const n = await promptBox(T('Rename'), T('New name'), f.name); if (!n || n === f.name) return;
const a = await this.join(this.cwd, f.name), b = await this.join(this.cwd, n);
try { this.side === 'local' ? await api.call('local:op', 'rename', a, b) : await api.call('sftp:op', this.sftp.id, 'rename', a, b); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async chmod(f) {
const m = await promptBox(T('Change permissions'), T('Octal (e.g. 644)'), (f.mode & 0o777).toString(8)); if (!m) return;
try { await api.call('sftp:op', this.sftp.id, 'chmod', await this.join(this.cwd, f.name), parseInt(m, 8)); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async remove() {
const names = [...this.sel]; if (!names.length) return;
if (!(await confirmBox(T('Delete?'), T(this.side === 'local' ? 'Move {n} item(s) to the trash: {list}' : 'Permanently delete {n} item(s) from the server: {list}', { n: names.length, list: names.slice(0, 5).join(', ') + (names.length > 5 ? ' …' : '') })))) return;
for (const n of names) {
const f = this.files.find((x) => x.name === n);
const p = await this.join(this.cwd, n);
try {
if (this.side === 'local') await api.call('local:op', 'delete', p);
else await api.call('sftp:op', this.sftp.id, f.isDir ? 'rmdir' : 'unlink', p);
} catch (e) { toast(`${n}: ${e.message}`, 'error'); }
}
this.go(this.cwd);
}
async transferSelected() {
const names = [...this.sel];
if (!names.length) return toast(T('Nothing selected'));
const target = this.other;
for (const n of names) {
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
const dir = this.side === 'local' ? 'upload' : 'download';
await this.sftp.transfer(dir, dir === 'upload' ? src : dst, dir === 'upload' ? dst : src, n);
}
target.go(target.cwd);
}
}
class SftpSession {
constructor(host) {
this.kind = 'sftp'; this.id = uid(); this.host = host;
this.title = `SFTP · ${host.label || host.address}`;
this.el = h(``);
this.local = new FilePane(this, 'local');
this.remote = new FilePane(this, 'remote');
const wrap = $('.sftp', this.el);
wrap.append(this.local.el, h(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`));
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
this.xfers = new Map();
addTab(this);
this.open();
}
async open() {
this.local.init(await api.call('local:home'));
try {
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
await this.remote.init(home);
setTabState(this, 'on');
} catch (e) {
setTabState(this, 'err');
const pe = $('.pane-empty', this.el);
pe.innerHTML = `${esc(e.message)}
`;
const b = h(`${ICONS.refresh}${T('Try again')} `);
b.onclick = () => { closeTab(this.id); openSftp(this.host); };
pe.append(b);
}
}
async transfer(dir, localPath, remotePath, name) {
const tid = uid();
const row = h(`${dir === 'upload' ? ICONS.upload : ICONS.download} ${esc(name)} 0%
`);
$('.transfers', this.el).prepend(row);
this.xfers.set(tid, row);
try {
await api.call('sftp:transfer', this.id, dir, localPath, remotePath, tid);
row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done');
setTimeout(() => row.remove(), 5000);
} catch (e) {
row.classList.add('fail'); $('.pct', row).textContent = T('Error'); row.title = e.message; toast(`${name}: ${e.message}`, 'error');
setTimeout(() => row.remove(), 10000);
}
}
progress(tid, done, total) {
const row = this.xfers.get(tid); if (!row) return;
const p = total ? Math.round((done / total) * 100) : 0;
$('.bar i', row).style.width = p + '%';
$('.pct', row).textContent = `${p}% · ${fmtSize(done)} / ${fmtSize(total)}`;
}
dispose() { this.unsub(); api.call('sftp:close', this.id).catch(() => {}); }
}
function openSftp(host) { return new SftpSession(host); }
// Gespeicherte Hosts per ID übergeben; Container-Shells mit Zusatzbefehl
const hostRef = (host) => (host.id ? (host.execCommand ? { ref: host.id, execCommand: host.execCommand, label: host.label } : host.id) : host);
// ============================================================ Docker (Container über SSH)
class DockerSession {
constructor(host) {
this.kind = 'docker'; this.id = uid(); this.host = host;
this.title = `Docker · ${host.label || host.address}`;
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
this.el = h(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`);
this.body = $('.docker-body', this.el);
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); });
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this);
this.open();
}
async open() {
try {
this.apply(await api.call('docker:open', this.id, hostRef(this.host)));
setTabState(this, 'on');
this.loadStats();
this.timer = setInterval(() => { if (S.active === this.id && !$('.modal-bg')) this.refresh(); }, 10000);
} catch (e) {
setTabState(this, 'err');
this.error(e.message);
}
}
error(msg) {
this.body.innerHTML = ``;
const b = h(`${ICONS.refresh}${T('Try again')} `);
b.onclick = () => { closeTab(this.id); openDocker(this.host); };
$('.pane-empty', this.body).append(b);
}
apply(r) {
this.containers = r.containers;
$('.rt', this.el).textContent = ` · ${r.runtime === 'podman' ? 'Podman' : 'Docker'}${r.sudo ? ' (sudo)' : ''}`;
this.draw();
}
async refresh(withStats) {
if (this.busy) return;
this.busy = true;
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); }
this.busy = false;
}
async loadStats() {
try { this.stats = await api.call('docker:stats', this.id); this.draw(); } catch {}
}
draw() {
const running = (c) => c.state === 'running';
const list = this.containers
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
.sort((a, b) => (running(b) - running(a)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
if (!this.containers.length) { this.body.innerHTML = `${T('No containers on this host.')}
`; return; }
const scroll = this.body.scrollTop;
this.body.innerHTML = `${T('Name')} ${T('Status')} ${T('Ports')} CPU ${T('Memory')}
${list.map((c) => {
const st = this.stats[c.id];
const on = running(c);
return `
${esc(c.name)} ${esc(c.image)}
${esc(c.status)} ${esc(c.ports)}
${on && st ? esc(st.cpu) : ''} ${on && st ? esc(st.mem.split('/')[0].trim()) : ''}
${on ? `${ICONS.term} ` : ''}
${ICONS.logs}
${on ? `${ICONS.refresh} ${ICONS.stop} `
: `${ICONS.play} ${ICONS.trash} `}
`;
}).join('')}
`;
this.body.scrollTop = scroll;
$$('tr[data-id]', this.body).forEach((tr) => {
const c = this.containers.find((x) => x.id === tr.dataset.id);
tr.onclick = (e) => { const a = e.target.closest('[data-c]')?.dataset.c; if (a) this.act(a, c, e.target.closest('button')); };
tr.ondblclick = (e) => { if (!e.target.closest('[data-c]') && running(c)) this.act('shell', c); };
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(running(c) ? [{ label: T('Open shell'), icon: 'term', run: () => this.act('shell', c) }] : []),
{ label: T('Logs'), icon: 'logs', run: () => this.act('logs', c) },
'-',
...(running(c) ? [{ label: T('Restart'), icon: 'refresh', run: () => this.act('restart', c) }, { label: T('Stop'), icon: 'stop', run: () => this.act('stop', c) }]
: [{ label: T('Start'), icon: 'play', run: () => this.act('start', c) }]),
{ label: T('Copy ID'), icon: 'copy', run: () => api.call('clipboard:write', c.id) },
'-',
{ label: T('Delete'), icon: 'trash', danger: true, run: () => this.act('remove', c) },
]);
});
}
async act(a, c, btn) {
if (a === 'shell' || a === 'logs') {
const cmd = await call('docker:command', this.id, a, c.id);
openTerminal({ ...this.host, execCommand: cmd, label: `${a === 'logs' ? T('Logs') : '🐳'} ${c.name}` });
return;
}
if (a === 'remove' && !(await confirmBox(T('Delete container?'), T('“{name}” will be removed including its writable layer. Volumes are kept.', { name: c.name })))) return;
if (a === 'stop' && !(await confirmBox(T('Stop container?'), c.name, T('Stop')))) return;
if (btn) { btn.disabled = true; btn.innerHTML = ' '; }
try { await call('docker:action', this.id, a, c.id); toast(T('{action}: {name}', { action: { start: T('Started'), stop: T('Stopped'), restart: T('Restarted'), remove: T('Deleted') }[a], name: c.name }), 'ok'); } catch {}
await this.refresh(true);
}
onShow() { if (this.containers.length) this.refresh(); }
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
}
function openDocker(host) { return new DockerSession(host); }
// ============================================================ Command Palette / Quick Connect
function openPalette() {
if ($('.palette')) return;
const bg = h(``);
const inp = $('input', bg), res = $('.res', bg);
let items = [], idx = 0;
const close = () => bg.remove();
const draw = () => {
const q = inp.value.trim().toLowerCase();
items = [];
const quick = parseQuick(inp.value);
if (q && quick) items.push({ grp: 'Quick Connect', icon: avatar({ ...quick, label: quick.address }), label: `${quick.protocol.toUpperCase()} → ${inp.value.trim()}`, run: () => connectHost(quick) });
const hosts = S.vault.hosts.filter((x) => !q || [x.label, x.address, x.username, ...(x.tags || [])].some((f) => String(f || '').toLowerCase().includes(q)));
const recent = S.vault.history.map((x) => x.hostId);
hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999)));
hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `${ICONS.folder}
`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `${ICONS.docker}
`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
});
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `${ICONS.code}
`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: T('Actions'), icon: `${ICONS.plus}
`, label: l, run }));
idx = Math.min(idx, Math.max(0, items.length - 1));
let last = '';
res.innerHTML = '';
items.forEach((it, i) => {
if (it.grp !== last) { res.append(h(`${esc(it.grp)}
`)); last = it.grp; }
const el = h(`${it.icon}
${i === idx ? '
↵ ' : ''}
`);
el.onclick = () => { close(); it.run(); };
el.onmousemove = () => { if (idx !== i) { idx = i; draw(); } };
res.append(el);
});
$('.item.active', res)?.scrollIntoView({ block: 'nearest' });
};
inp.oninput = () => { idx = 0; draw(); };
inp.onkeydown = (e) => {
if (e.key === 'ArrowDown') { idx = Math.min(items.length - 1, idx + 1); draw(); e.preventDefault(); }
if (e.key === 'ArrowUp') { idx = Math.max(0, idx - 1); draw(); e.preventDefault(); }
if (e.key === 'Enter' && items[idx]) { close(); items[idx].run(); }
if (e.key === 'Escape') close();
};
bg.onmousedown = (e) => { if (e.target === bg) close(); };
$('#modalRoot').append(bg);
draw();
inp.focus();
}
// ============================================================ Globale Events
$('#newTabBtn').onclick = openPalette;
$('#winMin').onclick = api.win.min;
$('#winMax').onclick = api.win.max;
$('#winClose').onclick = api.win.close;
$('#titlebar').addEventListener('dblclick', (e) => { if (e.target.classList.contains('drag-fill') || e.target.id === 'titlebar') api.win.max(); });
if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none';
document.addEventListener('keydown', (e) => {
if (!e.ctrlKey) return;
const inTerm = !!e.target.closest?.('.xterm');
if ($('.lock-screen')) return;
if (e.shiftKey && e.code === 'KeyL') { e.preventDefault(); lockNow(); }
else if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
else if (e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); }
else if (e.code === 'Tab') {
e.preventDefault();
const ids = ['home', ...S.tabs.map((t) => t.id)];
const i = ids.indexOf(S.active);
activateTab(ids[(i + (e.shiftKey ? -1 : 1) + ids.length) % ids.length]);
} else if (/^Digit[1-9]$/.test(e.code)) {
e.preventDefault();
const ids = ['home', ...S.tabs.map((t) => t.id)];
const n = Number(e.code.slice(5));
activateTab(n === 9 ? ids[ids.length - 1] : ids[n - 1] || ids[ids.length - 1]);
}
});
api.on('secret:request', async (req) => {
const r = await modal({
title: req.title || T('Authentication'),
text: req.host,
body: `${esc(req.prompt || T('Password'))}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Log in'), value: 'form', cls: 'primary' }],
});
api.replySecret(req.reqId, r ? r.v : null);
});
api.on('fw:event', () => reload());
api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch(() => ({})); if (S.view === 'vpns' && S.active === 'home') render(); });
api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? ' ' : ' '; });
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);