Files
MrTerm/src/renderer/app.js
T

1882 lines
114 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
edit: '<svg viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z"/></svg>',
trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
more: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg>',
close: '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>',
folder: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
file: '<svg viewBox="0 0 24 24"><path d="M6 3h8l4 4v14H6z M14 3v4h4"/></svg>',
term: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M12 15h5"/></svg>',
screen: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>',
key: '<svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg>',
copy: '<svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>',
play: '<svg viewBox="0 0 24 24"><path d="M7 5l12 7-12 7z"/></svg>',
stop: '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>',
up: '<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
refresh: '<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6"/></svg>',
home: '<svg viewBox="0 0 24 24"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
newdir: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6"/></svg>',
arrowR: '<svg viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
arrowL: '<svg viewBox="0 0 24 24"><path d="M19 12H5M11 6l-6 6 6 6"/></svg>',
code: '<svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg>',
shield: '<svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/></svg>',
lock: '<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>',
unlock: '<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.5-2"/></svg>',
eye: '<svg viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>',
fwd: '<svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg>',
dup: '<svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V5a1 1 0 0 1 1-1h11"/></svg>',
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
};
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 `<div class="avatar" style="background:${colorFor(hst)}${size ? `;width:${size}px;height:${size}px` : ''}">${hst.protocol === 'rdp' ? ICONS.screen : letter}<span class="proto">${proto}</span></div>`;
}
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(`<div class="toast ${type}">${esc(msg)}</div>`);
$('#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(`<div class="modal-bg"><div class="modal"><div class="mbody"><h3>${esc(title)}</h3>${text ? `<p>${esc(text)}</p>` : ''}${body}</div><div class="mfoot"></div></div></div>`);
const foot = $('.mfoot', bg);
const done = (v) => { bg.remove(); resolve(v); };
buttons.forEach((b) => {
const btn = h(`<button class="btn ${b.cls || ''}">${esc(b.label)}</button>`);
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: `<div class="field"><label>${esc(label)}</label><input name="v" type="${type}" value="${esc(value)}" /></div>`,
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('<div class="ctx"></div>');
for (const it of items) {
if (it === '-') { m.append(h('<hr>')); continue; }
const b = h(`<button class="${it.danger ? 'danger' : ''}">${it.icon ? ICONS[it.icon] : ''}<span>${esc(it.label)}</span></button>`);
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}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
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 = '<svg viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>';
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(`<div class="tree-head"><span>${T('Connections')}</span><button title="${T('Collapse all')}">${ICONS.up}</button></div>`);
$('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(`<div class="tree-empty">${T('No hosts yet')}</div>`)); 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(`<div class="tree-row group ${active ? 'active' : ''}" style="${pad(depth)}" title="${esc(g.label)}">
<span class="chev ${open ? 'open' : ''}">${CHEV}</span><span class="ti" style="color:${g.color || 'var(--folder)'}">${ICONS.folder}</span><span class="tl">${esc(g.label)}</span><span class="tc">${countIn(g.id)}</span></div>`);
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(`<div class="tree-row host" style="${pad(depth)}" draggable="true" title="${esc(hostSub(x))}">
<span class="chev"></span><span class="ti" style="background:${colorFor(x)}">${x.protocol === 'rdp' ? ICONS.screen : ICONS.term}</span><span class="tl">${esc(x.label || x.address)}</span>${live ? '<span class="tc" style="color:var(--green)">●</span>' : ''}</div>`);
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(`<div class="toolbar"><label class="search">${ICONS.search}<input placeholder="${esc(placeholder)}" value="${esc(S.search)}" /></label></div>`);
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(`<button class="btn ${b.cls || ''}">${b.icon ? ICONS[b.icon] : ''}${esc(b.label)}</button>`); 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(`<div class="empty"><div class="big">${ICONS[icon]}</div><h3>${esc(title)}</h3><p>${esc(text)}</p></div>`);
if (btnLabel) { const b = h(`<button class="btn primary">${ICONS.plus}${esc(btnLabel)}</button>`); 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('<div class="content"></div>');
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('<div class="breadcrumb"></div>');
const root = h(`<a>${esc(T('All hosts'))}</a>`); root.onclick = () => { S.group = null; render(); }; bc.append(root);
trail.forEach((t) => { bc.append(' / '); const a = h(`<a>${esc(t.label)}</a>`); 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('<div class="chips" style="margin-top:12px"></div>');
allTags.forEach((t) => { const b = h(`<button class="chip ${S.tag === t ? 'active' : ''}">#${esc(t)}</button>`); 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(`<div class="section-title">${T('Groups')} <span class="count">${subGroups.length}</span></div>`));
const grid = h('<div class="grid"></div>');
for (const g of subGroups) {
const count = hosts.filter((x) => x.groupId === g.id).length;
const card = h(`<div class="card group-card"><div class="avatar" ${g.color ? `style="background:${g.color}22;color:${g.color}"` : ''}>${ICONS.folder}</div><div class="meta"><div class="title">${esc(g.label)}</div><div class="sub">${count} Host${count === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button></div></div>`);
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(`<div class="section-title">Hosts <span class="count">${list.length}</span></div>`));
const grid = h('<div class="grid"></div>');
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(`<button class="btn" style="margin-left:8px">${esc(T('Import ~/.ssh/config'))}</button>`);
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 ? `<div class="tags">${hst.tags.map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</div>` : '';
const card = h(`<div class="card">${avatar(hst)}<div class="meta"><div class="title">${esc(hst.label || hst.address)}</div><div class="sub">${esc(hostSub(hst))}</div>${tags}</div>
<div class="actions">${hst.protocol !== 'rdp' ? `<button data-a="sftp" title="SFTP">${ICONS.folder}</button>` : ''}<button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button></div></div>`);
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('<div class="form"></div>');
const hd = h(`<header><h2>${esc(title)}</h2><button class="btn ghost sm">${ICONS.close}</button></header>`);
$('button', hd).onclick = closeDrawer;
const ft = h('<footer><div class="grow"></div></footer>');
if (extraFooter) ft.prepend(extraFooter);
const cancel = h(`<button class="btn ghost">${esc(T('Cancel'))}</button>`); cancel.onclick = closeDrawer;
const save = h(`<button class="btn primary">${esc(T('Save'))}</button>`);
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 = `<select name="${name}">${options.map(([v, l]) => `<option value="${esc(v)}" ${String(v) === String(value ?? '') ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
else if (type === 'textarea') input = `<textarea name="${name}" placeholder="${esc(placeholder)}" spellcheck="false">${esc(value)}</textarea>`;
else if (type === 'password') input = `<div class="pw-wrap"><input name="${name}" type="password" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off" /><button type="button" tabindex="-1">${ICONS.eye}</button></div>`;
else input = `<input name="${name}" type="${type}" value="${esc(value)}" placeholder="${esc(placeholder)}" spellcheck="false" />`;
const el = h(`<div class="field ${cls}"><label>${esc(label)}</label>${input}${hint ? `<div class="hint">${esc(hint)}</div>` : ''}</div>`);
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('<div class="row"></div>'); r.append(...els); return r; };
const check = (label, name, checked) => h(`<label class="check"><input type="checkbox" name="${name}" ${checked ? 'checked' : ''}/>${esc(label)}</label>`);
const heading = (t) => h(`<h4>${esc(t)}</h4>`);
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(`<div class="field"><label>${esc(T('Color'))}</label><div class="colors"><input type="hidden" name="color" value="${esc(current || '')}"/></div></div>`);
const wrap = $('.colors', el);
['', ...COLORS].forEach((c) => {
const b = h(`<button type="button" title="${c || T('Automatic')}" style="background:${c || 'conic-gradient(#6e7bff,#3ecf8e,#ff5f6d,#ffb454,#6e7bff)'}" class="${(current || '') === c ? 'active' : ''}"></button>`);
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(`<div class="seg"><button data-p="ssh">SSH</button><button data-p="rdp">RDP</button></div>`);
const sshBox = h('<div></div>');
const rdpBox = h('<div></div>');
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(`<button class="btn danger">${ICONS.trash}</button>`); 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('<div class="content"></div>'); 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(`<div class="section-title">${T('Keys')} <span class="count">${list.length}</span></div>`));
const grid = h('<div class="grid"></div>');
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(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="${T('Copy public key')}">${ICONS.copy}</button><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button></div></div>`);
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: `<div class="field"><label>Name</label><input name="label" value="mrterm-${new Date().toISOString().slice(0, 10)}"/></div>
<div class="field"><label>${T('Type')}</label><select name="type"><option value="ed25519">Ed25519 (${T('recommended')})</option><option value="ecdsa">ECDSA</option><option value="rsa">RSA 4096</option></select></div>
<div class="field"><label>Passphrase (optional)</label><input name="passphrase" type="password"/></div>`,
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(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); 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('<div class="content"></div>'); 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('<div class="list"></div>');
for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
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('<div class="content"></div>'); 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('<div class="list"></div>');
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(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? T('active') : T('inactive')}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + T('Stop') : ICONS.play + T('Start')}</button><div class="actions"><button data-a="del">${ICONS.trash}</button></div></div>`);
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('<div class="content"></div>'); 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('<div class="list"></div>');
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(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.shield}</div><div class="meta"><div class="title">${esc(v.label)} <span class="status-pill ${on ? 'on' : ''}">${on ? T('connected') : T('disconnected')}</span></div><div class="sub">${VPN_TYPES[v.type] || v.type} · ${n} Host${n === 1 ? '' : 's'}</div></div>
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + T('Disconnect') : ICONS.play + T('Connect')}</button><div class="actions"><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
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 = `<span class="spinner sm"></span>${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('<div class="seg"><button data-t="wireguard">WireGuard</button><button data-t="openvpn">OpenVPN</button></div>');
const ovpnBox = h('<div></div>');
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<ca>…</ca>' : '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\n\n[Peer]\n…';
const hint = $('.hint', cfg) || cfg.appendChild(h('<div class="hint"></div>'));
hint.textContent = t === 'openvpn' ? T('Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).') : T('Contents of a WireGuard .conf file.');
};
$$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t)));
// Hosts zuordnen
const hostBox = h(`<div class="vpn-hosts"></div>`);
const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
if (!sorted.length) hostBox.append(h(`<div class="hint">${T('No hosts yet')}</div>`));
sorted.forEach((x) => hostBox.append(h(`<label class="check"><input type="checkbox" data-host="${x.id}" ${x.vpnId && x.vpnId === v.id ? 'checked' : ''}/>${esc(x.label || x.address)}<span style="color:var(--faint);margin-left:6px;font-size:11px">${esc(hostSub(x))}</span></label>`)));
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(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); 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('<div class="content"></div>'); 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('<div class="list"></div>');
for (const [id, v] of entries) {
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="${T('Remove')}">${ICONS.trash}</button></div></div>`);
$('.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('<div class="content"></div>'); 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('<div class="list"></div>');
for (const it of items) {
const card = h(`<div class="card">${avatar(it.host)}<div class="meta"><div class="title">${esc(it.host.label || it.host.address)}</div><div class="sub">${esc(hostSub(it.host))} · ${relTime(it.at)}</div></div><button class="btn sm">${T('Connect')}</button></div>`);
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(`<div class="toolbar"><h2 style="margin:0;font-size:18px">${T('Settings')}</h2></div>`));
const c = h('<div class="content settings-grid"></div>'); 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(`<div class="settings-card"><h3>${T('Language')}</h3></div>`);
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(`<div class="settings-card"><h3>Design</h3><div class="theme-list design-list"></div><div class="field" style="margin:16px 0 0"><label>${T('Accent color')}</label><div class="colors accent-colors"></div></div></div>`);
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(`<div class="theme-prev design-prev ${st.appTheme === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div><div class="nm">${esc(T(t.name))}</div></div>`);
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(`<button type="button" title="${col || T('Theme default')}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b);
});
const themeCard = h(`<div class="settings-card"><h3>${T('Terminal color scheme')}</h3><div class="theme-list"></div></div>`);
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
const autoP = h(`<div class="theme-prev ${st.terminalTheme === 'auto' ? 'active' : ''}" style="background:${autoT.background};color:${autoT.foreground}"><div class="nm">${T('Match theme')}</div><span style="color:${autoT.green}">user@host</span>:<span style="color:${autoT.blue}">~</span>$ ls<br><span style="color:${autoT.blue}">${T('follows the theme')}</span></div>`);
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(`<div class="theme-prev ${st.terminalTheme === id ? 'active' : ''}" style="background:${t.background};color:${t.foreground}"><div class="nm">${esc(t.name)}</div><span style="color:${t.green}">user@host</span>:<span style="color:${t.blue}">~</span>$ ls<br><span style="color:${t.blue}">docs</span> <span style="color:${t.green}">run.sh</span> <span style="color:${t.red}">err</span></div>`);
p.onclick = async () => { await save({ terminalTheme: id }); render(); };
$('.theme-list', themeCard).append(p);
}
const termCard = h('<div class="settings-card"><h3>Terminal</h3></div>');
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(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
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(`<div class="hint" style="color:var(--faint);margin:-4px 0 12px 26px;font-size:11px">${T('Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.')}</div>`));
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(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${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 <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
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(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
// ---- Import aus anderen Programmen
const importCard = h(`<div class="settings-card"><h3>${T('Import')}</h3><p style="color:var(--muted);margin-top:0">${T('Import folders and hosts (RDP &amp; SSH) from <b>Devolutions Remote Desktop Manager</b>. In RDM: <i>File → Export → Export entries</i> as <code>.rdm</code> (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.')}</p></div>`);
const rdmBtn = h(`<button class="btn primary">${T('Import from Remote Desktop Manager…')}</button>`);
rdmBtn.onclick = importRdm;
importCard.append(rdmBtn);
// ---- App-Sperre
const secCard = h(`<div class="settings-card"><h3>${T('App lock')}</h3><p style="color:var(--muted);margin-top:0">${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.')}</p><div class="lock-rows"></div></div>`);
renderLockSettings($('.lock-rows', secCard));
// ---- Updates
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">${T('Installed version: {v}', { v: '…' })}</p></div>`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
const updFields = h('<div></div>');
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(`<button class="btn">${ICONS.refresh}${T('Check for updates now')}</button>`);
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 = `<kbd>${T('Ctrl')}</kbd>`;
const keysCard = h(`<div class="settings-card"><h3>${T('Keyboard shortcuts')}</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 18px;color:var(--muted)">
<span>${C}+<kbd>Shift</kbd>+<kbd>K</kbd> / <kbd>T</kbd></span><span>${T('Quick Connect / command palette (outside the terminal also {k})', { k: `${C}+<kbd>K</kbd>` })}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>W</kbd></span><span>${T('Close tab')}</span>
<span>${C}+<kbd>Tab</kbd></span><span>${T('Next tab')}</span>
<span>${C}+<kbd>1..9</kbd></span><span>${T('Switch to tab')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>${T('Copy / paste in terminal')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
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(`<div class="lock-screen"><div class="lock-box">
<img class="lock-logo" src="logo.png" alt=""/><h2>${T('MrTerm is locked')}</h2>
${S.lock.hasPassword ? `<form class="lock-pw"><input type="password" name="pw" placeholder="${esc(T('Password'))}" autocomplete="off"/><button class="btn primary">${ICONS.unlock}${T('Unlock')}</button></form>` : ''}
${S.lock.fido.length ? `<button class="btn lock-fido">${ICONS.key}${T('Unlock with security key')}</button>` : ''}
<div class="lock-err"></div></div></div>`);
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(`<div class="lock-row"><div class="grow"><b>${T('Password')}</b><div class="sub">${L.hasPassword ? T('Set') : T('Not set')}</div></div></div>`);
const pwBtn = h(`<button class="btn">${L.hasPassword ? T('Change password') : T('Set password')}</button>`);
pwBtn.onclick = async () => {
const r = await modal({ title: L.hasPassword ? T('Change password') : T('Set password'),
body: `<div class="field"><label>${T('New password')}</label><input name="a" type="password" autocomplete="off"/></div><div class="field"><label>${T('Repeat password')}</label><input name="b" type="password" autocomplete="off"/></div>`,
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(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
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(`<div class="lock-row"><div class="grow"><b>${ICONS.key}${esc(k.label)}</b><div class="sub">${T('Security key (FIDO2)')}</div></div></div>`);
const rm = h(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
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(`<button class="btn">${ICONS.plus}${T('Add security key')}</button>`);
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('<div class="row" style="flex-wrap:wrap;margin-top:12px"></div>');
actions.append(add);
if (L.enabled) {
const now = h(`<button class="btn primary">${ICONS.lock}${T('Lock now')}</button>`);
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(`<div class="hint" style="color:var(--faint);font-size:11px;margin-top:10px">${T('If you forget the password and lose all security keys, the vault cannot be recovered.')}</div>`));
}
}
// ============================================================ 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) => `<div class="card" style="padding:8px 10px;cursor:default">${avatar(e.host)}<div class="meta"><div class="title">${esc(e.host.label)}</div><div class="sub">${esc(e.folder.join(' / ') || T('Root level'))} · ${esc(hostSub(e.host))}</div></div></div>`).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: `<div class="list" style="max-height:300px;overflow:auto">${preview}</div>${hosts.length > 8 ? `<p style="margin:10px 0 0">${T('… and {n} more', { n: hosts.length - 8 })}</p>` : ''}`,
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 ? `<div class="mono" style="white-space:pre-wrap;max-height:240px;overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:6px">${esc(info.notes)}</div>` : ''}
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
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(`<div class="tab" data-tab="${session.id}"><span class="dot wait"></span><span>${esc(session.title)}</span><button class="x" title="${T('Close (Ctrl+Shift+W)')}">${ICONS.close}</button></div>`);
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(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button>`}
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
</div>
<div class="term-wrap"><div class="term"></div>
<div class="findbar"><input placeholder="${T('Search …')}"/><button class="btn ghost sm" data-f="prev">↑</button><button class="btn ghost sm" data-f="next">↓</button><button class="btn ghost sm" data-f="close">${ICONS.close}</button></div>
<div class="snip-panel"><div class="head">Snippets<button class="btn ghost sm" data-a="snipnew">${ICONS.plus}</button></div><div class="items"></div></div>
</div></div>`);
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(`<div style="color:var(--muted);padding:8px">${T('No snippets yet.')}</div>`));
S.vault.snippets.forEach((s) => {
const it = h(`<div class="it"><div>${esc(s.label)}</div><div class="mono">${esc(s.command)}</div></div>`);
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('<div class="overlay"></div>'); $('.term-wrap', this.el).append(this.overlay); }
this.overlay.innerHTML = err
? `<div class="msg err">${esc(msg)}</div><div class="row"><button class="btn" data-o="edit">${T('Edit host')}</button><button class="btn primary" data-o="retry">${ICONS.refresh}${T('Reconnect')}</button></div>`
: `<div class="spinner"></div><div class="msg">${esc(msg)}</div><button class="btn ghost" data-o="cancel">${T('Cancel')}</button>`;
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(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="reconnect" title="${T('Reconnect')}">${ICONS.refresh}${T('Reconnect')}</button>
${api.platform === 'win32' ? `<button class="btn ghost sm" data-a="detach" title="${T('Continue session in a separate window')}">${ICONS.screen}${T('Detach as window')}</button>` : ''}
</div>
<div class="term-wrap rdp-area"><div class="rdp-hint">${T('RDP session hidden while a dialog is open.')}</div></div></div>`);
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(`<div class="overlay">${err ? '' : '<div class="spinner"></div>'}<div class="msg ${err ? 'err' : ''}">${esc(msg)}</div>
<div class="row">${err ? `<button class="btn" data-o="window">${ICONS.screen}${T('In separate window')}</button><button class="btn primary" data-o="retry">${ICONS.refresh}${T('Reconnect')}</button>` : `<button class="btn ghost" data-o="cancel">${T('Cancel')}</button>`}</div></div>`);
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('<div class="content"></div>'); 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(`<div class="section-title">${T('Open host in SFTP browser')}</div>`));
const grid = h('<div class="grid"></div>');
list.forEach((hst) => {
const card = h(`<div class="card">${avatar(hst)}<div class="meta"><div class="title">${esc(hst.label || hst.address)}</div><div class="sub">${esc(hostSub(hst))}</div></div></div>`);
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(`<div class="pane"><div class="phead"><span class="title">${side === 'local' ? ICONS.screen + T('Local') : ICONS.folder + esc(sftp.host.label || sftp.host.address)}</span>
${side === 'local' && api.platform === 'win32' ? '<select class="roots"></select>' : ''}
<button class="btn ghost sm" data-p="up" title="${T('Parent folder')}">${ICONS.up}</button>
<button class="btn ghost sm" data-p="home" title="Home">${ICONS.home}</button>
<input class="path" spellcheck="false"/>
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button></div>
<div class="files"></div></div>`);
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(`<option>${esc(r)}</option>`)));
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 = `<table><thead><tr><th data-s="name">Name${arrow('name')}</th><th data-s="size" style="text-align:right">${T('Size')}${arrow('size')}</th><th data-s="mtime">${T('Modified')}${arrow('mtime')}</th></tr></thead><tbody>
<tr data-up="1"><td class="name"><div><span class="ico-dir">${ICONS.folder}</span><span>..</span></div></td><td></td><td></td></tr>
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><div><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></div></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
$$('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(`<div class="session"><div class="sftp"></div><div class="transfers"></div></div>`);
this.local = new FilePane(this, 'local');
this.remote = new FilePane(this, 'remote');
const wrap = $('.sftp', this.el);
wrap.append(this.local.el, h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
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 = `<div style="color:var(--red);max-width:420px;text-align:center">${esc(e.message)}</div>`;
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
b.onclick = () => { closeTab(this.id); openSftp(this.host); };
pe.append(b);
}
}
async transfer(dir, localPath, remotePath, name) {
const tid = uid();
const row = h(`<div class="transfer"><span>${dir === 'upload' ? ICONS.upload : ICONS.download}</span><span class="nm">${esc(name)}</span><span class="pct">0%</span><div class="bar"><i></i></div></div>`);
$('.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(`<div class="session docker">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search containers …'))}"/></label>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
</div>
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
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 = `<div class="pane-empty"><div style="color:var(--red);max-width:520px;text-align:center">${esc(msg)}</div></div>`;
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
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 = `<div class="pane-empty"><div style="color:var(--muted)">${T('No containers on this host.')}</div></div>`; return; }
const scroll = this.body.scrollTop;
this.body.innerHTML = `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Ports')}</th><th class="num">CPU</th><th class="num">${T('Memory')}</th><th></th></tr></thead><tbody>
${list.map((c) => {
const st = this.stats[c.id];
const on = running(c);
return `<tr data-id="${esc(c.id)}"><td><span class="dot ${on ? 'on' : c.state === 'paused' || c.state === 'restarting' ? 'wait' : ''}"></span></td>
<td class="name"><div><b>${esc(c.name)}</b><span class="img">${esc(c.image)}</span></div></td>
<td class="muted">${esc(c.status)}</td><td class="muted ports" title="${esc(c.ports)}">${esc(c.ports)}</td>
<td class="num">${on && st ? esc(st.cpu) : ''}</td><td class="num">${on && st ? esc(st.mem.split('/')[0].trim()) : ''}</td>
<td class="acts">
${on ? `<button class="btn ghost sm" data-c="shell" title="${T('Open shell')}">${ICONS.term}</button>` : ''}
<button class="btn ghost sm" data-c="logs" title="${T('Logs')}">${ICONS.logs}</button>
${on ? `<button class="btn ghost sm" data-c="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-c="stop" title="${T('Stop')}">${ICONS.stop}</button>`
: `<button class="btn ghost sm" data-c="start" title="${T('Start')}">${ICONS.play}</button><button class="btn ghost sm" data-c="remove" title="${T('Delete')}">${ICONS.trash}</button>`}
</td></tr>`;
}).join('')}</tbody></table>`;
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 = '<span class="spinner sm"></span>'; }
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(`<div class="modal-bg"><div class="modal palette"><div class="pin">${ICONS.search}<input placeholder="${T('Search hosts or enter user@host:port or rdp://host …')}"/></div><div class="res"></div></div></div>`);
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: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, 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: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, 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: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.plus}</div>`, 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(`<div class="grp">${esc(it.grp)}</div>`)); last = it.grp; }
const el = h(`<div class="item ${i === idx ? 'active' : ''}">${it.icon}<div class="meta"><div>${esc(it.label)}</div>${it.sub ? `<div class="sub mono" style="color:var(--muted)">${esc(it.sub.split('\n')[0])}</div>` : ''}</div>${i === idx ? '<span class="k">↵</span>' : ''}</div>`);
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: `<div class="field"><label>${esc(req.prompt || T('Password'))}</label><input name="v" type="${req.echo ? 'text' : 'password'}" autocomplete="off"/></div>`,
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 ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);