Files
MrTerm/src/renderer/app.js
T

2610 lines
170 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]));
// crypto.randomUUID gibt es nur in sicheren Kontexten (HTTPS/localhost) – die Web-Version läuft oft per http://<server>
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)));
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
const T = I18N.t;
// Web-Version (Docker, src/web): gleiche Oberfläche im Browser; ohne RDP, VPN, Port-Forwarding und lokale Dateien
const WEB = api.platform === 'web';
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>',
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></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 = [], noEnter = false }) {
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' && !noEnter && 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('Firewall'), icon: 'wall', run: () => openFirewall(hst) }, { label: T('Network'), icon: 'network', run: () => openNetwork(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 (WEB) return toast(T('RDP is not available in the web version.'), 'error');
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,
};
const savedHost = await call('vault:upsert', 'hosts', item);
if (item.password) askShareSecret('hosts', savedHost, item.label || item.address);
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 });
const newKey = await call('vault:upsert', 'keys', { label: r.label, privateKey: k.privateKey, publicKey: k.publicKey, passphrase: r.passphrase || undefined });
askShareSecret('keys', newKey, r.label);
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 });
const savedKey = await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey });
askShareSecret('keys', savedKey, savedKey.label);
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);
askShareSecret('vpns', saved, saved.label);
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
// ---------- Web-Version: Konto und Benutzerverwaltung (HTTP-API des Web-Servers)
async function webApi(path, body) {
const r = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: body === undefined ? undefined : JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(T(j.error || r.statusText));
return j;
}
function webAccountCard() {
const card = h(`<div class="settings-card"><h3>${T('Account')}</h3><div class="acc"></div></div>`);
const draw = async () => {
const box = $('.acc', card);
let me;
try { me = await webApi('/api/me'); } catch (e) { box.textContent = e.message; return; }
box.innerHTML = `<p style="color:var(--muted);margin-top:0">${esc(T('Signed in as {name}', { name: me.name }))}${me.admin ? ` · ${esc(T('Administrator'))}` : ''}</p><div class="row" style="flex-wrap:wrap"></div>`;
const pw = h(`<button class="btn">${esc(T('Change password'))}</button>`);
pw.onclick = async () => {
const r = await modal({ title: T('Change password'), body: `<div class="field"><label>${esc(T('Current password'))}</label><input name="old" type="password"/></div><div class="field"><label>${esc(T('New password'))}</label><input name="a" type="password"/></div><div class="field"><label>${esc(T('Repeat password'))}</label><input name="b" type="password"/></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 { await webApi('/api/password', { old: r.old, password: r.a }); toast(T('Saved'), 'ok'); } catch (e) { toast(e.message, 'error'); }
};
const out = h(`<button class="btn">${esc(T('Sign out'))}</button>`);
out.onclick = async () => { await webApi('/api/logout', {}).catch(() => {}); location.href = '/'; };
$('.row', box).append(pw, out);
if (!me.admin) return;
const users = await webApi('/api/users').catch(() => ({ users: [] }));
const list = h(`<div style="margin-top:16px"><h4 style="margin:0 0 8px">${esc(T('Users'))}</h4></div>`);
for (const u of users.users) {
const row = h(`<div class="lock-row"><div class="grow"><b>${esc(u.name)}</b><div class="sub">${u.admin ? esc(T('Administrator')) : esc(T('User'))}</div></div></div>`);
if (u.id !== me.id) {
const del = h(`<button class="btn ghost" title="${esc(T('Delete'))}">${ICONS.trash}</button>`);
del.onclick = async () => {
if (!(await confirmBox(T('Delete user?'), T('“{name}” and their vault will be permanently deleted.', { name: u.name })))) return;
try { await webApi('/api/users/delete', { id: u.id }); draw(); } catch (e) { toast(e.message, 'error'); }
};
row.append(del);
}
list.append(row);
}
const add = h(`<button class="btn" style="margin-top:10px">${ICONS.plus}${esc(T('Add user'))}</button>`);
add.onclick = async () => {
const r = await modal({ title: T('Add user'), body: `<div class="field"><label>${esc(T('Username'))}</label><input name="name" autocomplete="off"/></div><div class="field"><label>${esc(T('Password'))}</label><input name="password" type="password" autocomplete="new-password"/></div><label class="check"><input type="checkbox" name="admin"/>${esc(T('Administrator'))}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add user'), value: 'form', cls: 'primary' }] });
if (!r) return;
try { await webApi('/api/users', r); toast(T('Saved'), 'ok'); draw(); } catch (e) { toast(e.message, 'error'); }
};
list.append(add);
box.append(list);
};
draw();
return card;
}
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">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : 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 = [
...(WEB ? [] : [[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));
// ---- LAN-Synchronisation
const syncCard = h('<div class="settings-card sync-card"></div>');
renderSyncCard(syncCard);
// ---- 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>`);
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, importCard, dataCard, keysCard);
else c.append(langCard, secCard, syncCard, 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>`));
}
}
// ============================================================ LAN-Synchronisation
S.syncStatus = null;
async function renderSyncCard(card) {
if (!S.syncStatus) { try { S.syncStatus = await api.call('sync:status'); } catch { return; } }
const st = S.syncStatus;
card.innerHTML = `<h3>${T('Synchronization')}</h3><p style="color:var(--muted);margin-top:0">${T('Synchronize hosts, groups, snippets, port forwards, VPNs and known hosts directly between MrTerm devices in your local network. The connection is end-to-end encrypted; no server or cloud is involved.')}</p>`;
const en = check(T('Enable LAN synchronization'), 'syncOn', st.enabled);
$('input', en).onchange = async (e) => {
S.syncStatus = await call('sync:enable', e.target.checked);
renderSyncCard(card);
if (e.target.checked && S.syncStatus.firewall) showFirewallHint();
};
card.append(en);
if (!st.enabled) return;
if (st.firewall && !syncFwDismissed()) card.append(firewallHintBox(() => renderSyncCard(card)));
const name = field(T('Device name'), 'devName', st.deviceName);
$('input', name).onchange = async (e) => { S.syncStatus = await call('sync:enable', true, e.target.value.trim()); };
card.append(name);
const list = h('<div class="sync-peers"></div>');
if (!st.peers.length) list.append(h(`<div class="hint" style="color:var(--faint);padding:6px 0">${T('No paired devices yet.')}</div>`));
for (const p of st.peers) {
const row = h(`<div class="lock-row"><span class="dot ${p.online ? 'on' : ''}"></span><div class="grow"><b>${esc(p.name)}</b><div class="sub">${p.online ? T('online') : T('offline')}${p.lastSync ? ` · ${T('last sync {time}', { time: relTime(p.lastSync) })}` : ''}${p.error ? ` · <span style="color:var(--red)">${esc(p.error)}</span>` : ''}</div></div></div>`);
const rm = h(`<button class="btn ghost" title="${esc(T('Unpair'))}">${ICONS.trash}</button>`);
rm.onclick = async () => { if (await confirmBox(T('Unpair device?'), T('“{name}” will no longer synchronize with this device.', { name: p.name }), T('Unpair'))) { S.syncStatus = await call('sync:unpair', p.id); renderSyncCard(card); } };
row.append(rm);
list.append(row);
}
card.append(list);
const shared = (c, arr, pred = () => true) => `${(st.share[c] || []).filter((id) => arr.some((x) => x.id === id)).length}/${arr.filter(pred).length}`;
card.append(h(`<div class="hint" style="color:var(--muted);font-size:12px;margin-top:10px">${T('Shared secrets: {keys} SSH keys, {pw} host passwords, {vpn} VPN configurations', { keys: shared('keys', S.vault.keys), pw: shared('hosts', S.vault.hosts, (x) => x.password), vpn: shared('vpns', S.vault.vpns) })}</div>`));
const acts = h('<div class="row" style="flex-wrap:wrap;margin-top:12px"></div>');
const pair = h(`<button class="btn primary">${ICONS.plus}${T('Pair new device')}</button>`); pair.onclick = openPairing;
const now = h(`<button class="btn">${ICONS.refresh}${T('Sync now')}</button>`);
now.onclick = async () => { now.disabled = true; try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} now.disabled = false; };
const sec = h(`<button class="btn">${ICONS.key}${T('Choose shared secrets')}</button>`); sec.onclick = chooseSharedSecrets;
acts.append(pair, now, sec);
card.append(acts);
if (!st.firewall || syncFwDismissed()) card.append(h(`<div class="hint" style="color:var(--faint);font-size:11px;margin-top:10px">${T('Devices find each other via UDP port 47811 and synchronize via TCP port 47812. If a firewall is active, allow these ports in your local network (with UFW: sudo ufw allow 47811/udp and sudo ufw allow 47812/tcp).')}</div>`));
}
// Neues Geheimnis bei aktiver Synchronisation: fragen, ob es geteilt werden soll (Antwort wird gemerkt)
async function askShareSecret(c, item, label) {
try {
const st = S.syncStatus || (S.syncStatus = await api.call('sync:status'));
if (!st.enabled || !st.peers.length || !item?.id) return;
if ((st.share[c] || []).includes(item.id) || (st.share.declined?.[c] || []).includes(item.id)) return;
const what = { keys: T('SSH key'), hosts: T('Host password'), vpns: T('VPN configuration') }[c];
const r = await modal({ title: T('Share with paired devices?'), text: `${what}: ${label || ''}`,
body: `<p style="margin-top:0">${T('Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.', { names: st.peers.map((p) => p.name).join(', ') })}</p>`,
buttons: [{ label: T("Don't share"), value: 'no', cls: 'ghost' }, { label: T('Share'), value: 'yes', cls: 'primary' }], noEnter: true });
if (!r) return; // Dialog geschlossen: beim nächsten Speichern erneut fragen
S.syncStatus = await api.call('sync:shareItem', c, item.id, r === 'yes');
} catch {}
}
// Firewall-Hinweis (CachyOS / aktive UFW bzw. firewalld): Ports für die Synchronisation freigeben
const FW_CMDS = { ufw: 'sudo ufw allow 47811/udp\nsudo ufw allow 47812/tcp', firewalld: 'sudo firewall-cmd --permanent --add-port=47811/udp --add-port=47812/tcp\nsudo firewall-cmd --reload' };
const fwCmds = (f) => (f.firewalld && !f.ufw ? FW_CMDS.firewalld : FW_CMDS.ufw);
const syncFwDismissed = () => { try { return localStorage.getItem('mrterm.sync.fwHint') === '1'; } catch { return false; } };
function firewallHintBox(onDismiss) {
const f = S.syncStatus.firewall;
const box = h(`<div class="net-banner warn fw-hint"><div>${ICONS.wall}</div><div class="grow"><b>${f.os === 'cachyos' ? T('CachyOS: allow the sync ports in the firewall') : T('Firewall active: allow the sync ports')}</b>
<p>${T('Otherwise other devices cannot find or reach this device. Run these commands once in a terminal:')}</p><pre class="mono">${esc(fwCmds(f))}</pre>
<div class="row"><button class="btn sm" data-f="copy">${ICONS.copy}${T('Copy commands')}</button><button class="btn sm ghost" data-f="hide">${T('Already done, hide')}</button></div></div></div>`);
box.onclick = (e) => {
const a = e.target.closest('[data-f]')?.dataset.f;
if (a === 'copy') { api.call('clipboard:write', fwCmds(f)); toast(T('Copied'), 'ok'); }
if (a === 'hide') { try { localStorage.setItem('mrterm.sync.fwHint', '1'); } catch {} onDismiss?.(); }
};
return box;
}
async function showFirewallHint() {
const f = S.syncStatus.firewall;
const r = await modal({ title: f.os === 'cachyos' ? T('CachyOS: allow the sync ports in the firewall') : T('Firewall active: allow the sync ports'),
body: `<p style="margin-top:0">${T('CachyOS enables the UFW firewall by default. It blocks the ports MrTerm needs to find and reach other devices (UDP 47811, TCP 47812). Run these commands once in a terminal:')}</p><pre class="mono fw-pre">${esc(fwCmds(f))}</pre>`,
buttons: [{ label: T('Close'), value: null, cls: 'ghost' }, { label: T('Copy commands'), value: 'copy', cls: 'primary' }] });
if (r === 'copy') { await api.call('clipboard:write', fwCmds(f)); toast(T('Copied'), 'ok'); }
}
// Kopplungsdialog: dieses Gerät ist sichtbar, gefundene Geräte können gekoppelt werden
async function openPairing() {
S.syncStatus = await call('sync:pairable', true);
const body = `<p style="margin-top:0">${T('Open “Pair new device” on the other device too, then select it here. Both devices show a code that you confirm on both sides.')}</p><div class="pair-list list"></div>
<div class="row" style="margin-top:12px"><div class="field" style="flex:1;margin:0"><input name="ip" placeholder="${esc(T('Or enter an IP address, e.g. 192.168.0.25'))}"/></div><button class="btn" data-probe type="button">${T('Search')}</button></div>`;
let timer;
const draw = () => {
const box = $('.pair-list');
if (!box) return clearInterval(timer);
const near = S.syncStatus?.nearby || [];
box.innerHTML = near.length ? '' : `<div class="hint" style="color:var(--muted);display:flex;gap:10px;align-items:center"><span class="spinner sm"></span>${T('Searching for devices …')}</div>${S.syncStatus?.firewall ? `<div class="hint" style="color:var(--orange);margin-top:10px;font-size:12px">${T('No devices found? The firewall on this device may block them:')}<pre class="mono fw-pre">${esc(fwCmds(S.syncStatus.firewall))}</pre></div>` : ''}`;
for (const d of near) {
const row = h(`<div class="card" style="padding:10px 12px;cursor:default"><div class="avatar" style="background:var(--icon-bg);color:var(--accent)">${ICONS.screen}</div><div class="meta"><div class="title">${esc(d.name)}</div><div class="sub">${esc(d.address)}${d.pairable ? '' : ` · ${T('not ready for pairing')}`}</div></div></div>`);
const b = h(`<button class="btn sm primary" ${d.pairable ? '' : 'disabled'}>${T('Pair')}</button>`);
b.onclick = async () => {
b.disabled = true; b.innerHTML = `<span class="spinner sm"></span>${T('Pairing …')}`;
try { await call('sync:pair', d.id); $('.pair-list')?.closest('.modal-bg')?.remove(); } catch {}
b.disabled = false; b.textContent = T('Pair');
};
row.append(b);
box.append(row);
}
};
setTimeout(() => {
draw();
timer = setInterval(draw, 1500);
const probe = $('[data-probe]');
if (probe) probe.onclick = async () => { const ip = $('[name=ip]', probe.closest('.modal')).value.trim(); if (ip) { try { await call('sync:probe', ip); toast(T('Device found'), 'ok'); } catch {} } };
}, 0);
await modal({ title: T('Pair new device'), body, buttons: [{ label: T('Close'), value: null, cls: 'ghost' }] });
clearInterval(timer);
S.syncStatus = await call('sync:pairable', false).catch(() => S.syncStatus);
}
// Auswahl, welche Geheimnisse dieses Gerät an gekoppelte Geräte weitergibt
async function chooseSharedSecrets() {
const sh = S.syncStatus?.share || { keys: [], hosts: [], vpns: [] };
const sect = (title, c, items, label) => items.length ? `<h4>${title}</h4>${items.map((x) => `<label class="check"><input type="checkbox" name="${c}|${esc(x.id)}" ${(sh[c] || []).includes(x.id) ? 'checked' : ''}/>${esc(label(x))}</label>`).join('')}` : '';
const hostsPw = S.vault.hosts.filter((x) => x.password);
const body = `<p style="margin-top:0">${T('Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.')}</p>
<div class="share-list">${sect(T('SSH keys'), 'keys', S.vault.keys, (x) => x.label)}${sect(T('Host passwords'), 'hosts', hostsPw, (x) => `${x.label || x.address} (${x.username || '?'}@${x.address})`)}${sect(T('VPN configurations'), 'vpns', S.vault.vpns, (x) => x.label)}
${!S.vault.keys.length && !hostsPw.length && !S.vault.vpns.length ? `<div class="hint">${T('There are no secrets on this device yet.')}</div>` : ''}</div>`;
const r = await modal({ title: T('Choose shared secrets'), body, buttons: [{ label: T('Share nothing'), value: 'none', cls: 'ghost' }, { label: T('Select all'), value: 'all', cls: '' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
const picked = { keys: [], hosts: [], vpns: [] };
if (typeof r === 'object') for (const [k, on] of Object.entries(r)) { const [c, id] = k.split('|'); if (on && picked[c]) picked[c].push(id); }
const sel = r === 'none' ? { keys: [], hosts: [], vpns: [] } : r === 'all' ? { keys: S.vault.keys.map((x) => x.id), hosts: hostsPw.map((x) => x.id), vpns: S.vault.vpns.map((x) => x.id) } : picked;
S.syncStatus = await call('sync:share', sel);
toast(T('Saved'), 'ok');
const card = $('.sync-card'); if (card) renderSyncCard(card);
}
api.on('sync:state', (st) => { S.syncStatus = st; const card = $('.sync-card'); if (card && !card.contains(document.activeElement)) renderSyncCard(card); });
api.on('sync:changed', () => { if (!S.lock?.locked && !$('.lock-screen')) reload(); });
api.on('sync:paired', async (p) => { toast(T('Paired with {name}', { name: p.name }), 'ok'); chooseSharedSecrets(); });
api.on('sync:pairPrompt', async (req) => {
const r = await modal({ title: T('Confirm pairing'), body: `<p style="margin-top:0">${esc(T('Pairing with “{name}”. Does the other device show the same code?', { name: req.name }))}</p><div class="pair-code">${req.code.slice(0, 3)} ${req.code.slice(3)}</div>`,
buttons: [{ label: T('Codes differ'), value: false, cls: 'danger' }, { label: T('Codes match'), value: true, cls: 'primary' }], noEnter: true });
api.pairReply(req.reqId, r === true);
});
// ============================================================ 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, firewall: openFirewall, network: openNetwork }[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="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</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 === 'firewall') openFirewall(this.host);
if (a === 'network') openNetwork(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>
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</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();
if (p === 'upload') this.pickUpload();
});
$('.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
// Web-Version: Dateien aus dem Betriebssystem in den Server-Bereich ziehen = hochladen
const osFiles = (e) => WEB && [...(e.dataTransfer?.types || [])].includes('Files');
this.el.addEventListener('dragover', (e) => { if ((S.drag && S.drag.pane !== this) || osFiles(e)) { 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 (osFiles(e)) return this.uploadFiles([...e.dataTransfer.files]);
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; }
pickUpload() {
const inp = h('<input type="file" multiple style="display:none"/>');
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
document.body.append(inp);
inp.click();
}
async uploadFiles(files) {
for (const f of files) await this.sftp.webTransfer('upload', f, `${this.cwd.replace(/\/$/, '')}/${f.name}`, f.name);
this.go(this.cwd);
}
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'));
if (WEB) {
for (const n of names) {
const f = this.files.find((x) => x.name === n);
if (f?.isDir) { toast(T('Folders cannot be downloaded in the browser: {name}', { name: n })); continue; }
this.sftp.webTransfer('download', null, await this.join(this.cwd, n), n);
}
return;
}
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 = WEB ? null : new FilePane(this, 'local');
this.remote = new FilePane(this, 'remote');
const wrap = $('.sftp', this.el);
if (this.local) wrap.append(this.local.el);
wrap.append(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() {
if (this.local) 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);
}
}
// Web-Version: Upload per HTTP (mit Fortschritt), Download als Browser-Download
webTransfer(dir, file, remotePath, name) {
const q = `sid=${encodeURIComponent(this.id)}&path=${encodeURIComponent(remotePath)}`;
if (dir === 'download') {
const a = h(`<a href="/web/download?${q}" download="${esc(name)}" style="display:none"></a>`);
document.body.append(a); a.click(); a.remove();
return Promise.resolve();
}
const tid = uid();
const row = h(`<div class="transfer"><span>${ICONS.upload}</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);
return new Promise((resolve) => {
const x = new XMLHttpRequest();
x.open('POST', `/web/upload?${q}`);
x.setRequestHeader('X-MrTerm', '1');
x.upload.onprogress = (e) => this.progress(tid, e.loaded, e.total);
x.onload = () => {
if (x.status < 300) { row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done'); setTimeout(() => row.remove(), 5000); }
else { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); const m = (() => { try { return JSON.parse(x.responseText).error; } catch { return x.statusText; } })(); toast(`${name}: ${m}`, 'error'); setTimeout(() => row.remove(), 10000); }
resolve();
};
x.onerror = () => { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); resolve(); };
x.send(file);
});
}
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); }
// ============================================================ Firewall (UFW / iptables über SSH)
class FirewallSession {
constructor(host) {
this.kind = 'firewall'; this.id = uid(); this.host = host;
this.title = `${T('Firewall')} · ${host.label || host.address}`;
this.backend = null; this.data = null;
this.el = h(`<div class="session firewall">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<div class="seg sm backends"></div>
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
</div>
<div class="docker-body fw-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 = $('.fw-body', this.el);
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'refresh') this.refresh();
if (a === 'add') this.backend === 'ufw' ? this.addUfw() : this.addIpt();
if (a === 'save') this.op('ipt', 'save', {}, T('Rules saved permanently'));
});
this.unsub = api.on('firewall:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this);
this.open();
}
async open() {
try {
const r = await api.call('firewall:open', this.id, hostRef(this.host));
$('.rt', this.el).textContent = r.sudo ? ' · sudo' : ' · root';
const opts = [...(r.tools.includes('ufw') ? [['ufw', 'UFW']] : []), ...(r.tools.includes('iptables') ? [['iptables', 'iptables']] : []), ...(r.tools.includes('ip6tables') ? [['ip6tables', 'ip6tables']] : [])];
const seg = $('.backends', this.el);
opts.forEach(([id, l]) => { const b = h(`<button data-b="${id}">${l}</button>`); b.onclick = () => this.switchTo(id); seg.append(b); });
setTabState(this, 'on');
$('[data-a=add]', this.el).disabled = false;
await this.switchTo(opts[0][0]);
} 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:560px;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); openFirewall(this.host); };
$('.pane-empty', this.body).append(b);
}
async switchTo(b) {
this.backend = b;
$$('.backends button', this.el).forEach((x) => x.classList.toggle('active', x.dataset.b === b));
$('[data-a=save]', this.el).style.display = b === 'ufw' ? 'none' : '';
$('[data-a=add]', this.el).disabled = false;
await this.refresh();
}
async refresh() {
try { this.data = await api.call('firewall:list', this.id, this.backend); this.draw(); } catch (e) { toast(e.message, 'error'); }
}
// Aktion ausführen, danach neu laden
async op(kind, op, args, okMsg) {
try { await call(kind === 'ufw' ? 'firewall:ufw' : 'firewall:ipt', this.id, op, args); if (okMsg) toast(okMsg, 'ok'); } catch { return false; }
await this.refresh();
return true;
}
sshPort() { return String(this.host.port || 22); }
draw() {
const scroll = this.body.scrollTop;
this.body.innerHTML = '';
if (this.backend === 'ufw') this.drawUfw(); else this.drawIpt();
this.body.scrollTop = scroll;
}
drawUfw() {
const d = this.data;
const on = d.status === 'active';
const head = h(`<div class="fw-head"><span class="status-pill ${on ? 'on' : ''}">${on ? T('active') : T('inactive')}</span></div>`);
const tgl = h(`<button class="btn sm ${on ? '' : 'primary'}">${on ? T('Disable') : T('Enable')}</button>`);
tgl.onclick = async () => {
if (on) { if (await confirmBox(T('Disable firewall?'), T('All UFW rules will stop filtering traffic.'), T('Disable'))) this.op('ufw', 'disable', {}); return; }
const sshOk = d.rules.some((r) => r.action !== 'DENY' && r.action !== 'REJECT' && r.dir === 'IN' && new RegExp(`(^|[^0-9])${this.sshPort()}(/tcp)?($|[^0-9])|OpenSSH|ssh`, 'i').test(r.to));
if (!sshOk) {
const r = await modal({ title: T('Enable firewall?'), text: T('There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.', { port: this.sshPort() }),
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Enable anyway'), value: 'force', cls: 'danger' }, { label: T('Allow SSH and enable'), value: 'ssh', cls: 'primary' }] });
if (!r) return;
if (r === 'ssh' && !(await this.op('ufw', 'add', { action: 'allow', dir: 'in', port: this.sshPort(), proto: 'tcp', comment: 'SSH (MrTerm)' }))) return;
}
this.op('ufw', 'enable', {}, T('Firewall enabled'));
};
head.append(tgl);
if (d.defaults) {
for (const dir of ['incoming', 'outgoing']) {
const sel = h(`<label class="fw-def">${dir === 'incoming' ? T('Incoming') : T('Outgoing')}<select>${['allow', 'deny', 'reject'].map((p) => `<option value="${p}" ${d.defaults[dir] === p ? 'selected' : ''}>${{ allow: T('Allow'), deny: T('Deny'), reject: T('Reject') }[p]}</option>`).join('')}</select></label>`);
$('select', sel).onchange = async (e) => {
const pol = e.target.value;
if (dir === 'incoming' && pol !== 'allow' && on && !(await confirmBox(T('Change default policy?'), T('Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.'), T('Change')))) return this.draw();
this.op('ufw', 'default', { policy: pol, dir });
};
head.append(sel);
}
}
this.body.append(head);
$('[data-a=add]', this.el).disabled = !on;
if (!on) {
this.body.append(h(`<div class="pane-empty" style="height:auto;padding:48px 20px"><div class="fw-off">${ICONS.wall}<b>${T('UFW is disabled')}</b><span>${T('Enable UFW to view or add rules.')}</span></div></div>`));
return;
}
if (!d.rules.length) { this.body.append(h(`<div class="pane-empty" style="height:auto;padding:40px"><div style="color:var(--muted)">${T('No rules yet.')}</div></div>`)); return; }
const t = h(`<table class="docker-table fw-table"><thead><tr><th>#</th><th>${T('To')}</th><th>${T('Action')}</th><th>${T('From')}</th><th>${T('Comment')}</th><th></th></tr></thead><tbody></tbody></table>`);
for (const r of d.rules) {
const tr = h(`<tr><td class="num">${r.num}</td><td><b>${esc(r.to)}</b></td><td><span class="fw-act ${r.action.toLowerCase()}">${esc(r.action)} ${esc(r.dir)}</span></td><td class="muted">${esc(r.from)}</td><td class="muted">${esc(r.comment)}</td>
<td class="acts"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
$('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${r.to} · ${r.action} ${r.dir} · ${r.from}`)) this.op('ufw', 'delete', { num: r.num }, T('Rule deleted')); };
$('tbody', t).append(tr);
}
this.body.append(t);
}
drawIpt() {
const fam = this.backend === 'ip6tables' ? 6 : 4;
const builtin = ['INPUT', 'FORWARD', 'OUTPUT'];
const chains = [...this.data.chains].sort((a, b) => ((builtin.indexOf(a.name) + 1 || 99) - (builtin.indexOf(b.name) + 1 || 99)));
this.body.append(h(`<div class="fw-note">${T('Changes apply immediately but are lost after a reboot unless you click “Save permanently”.')}</div>`));
for (const c of chains) {
const sec = h(`<div class="fw-chain"><div class="fw-chain-head"><b>${esc(c.name)}</b><span class="muted">${c.rules.length} ${T('rules')}</span></div></div>`);
if (c.policy && builtin.includes(c.name)) {
const sel = h(`<label class="fw-def">${T('Policy')}<select>${['ACCEPT', 'DROP'].map((p) => `<option ${c.policy === p ? 'selected' : ''}>${p}</option>`).join('')}</select></label>`);
$('select', sel).onchange = async (e) => {
if (e.target.value === 'DROP' && c.name !== 'FORWARD' && !(await confirmBox(T('Change default policy?'), T('Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.'), T('Change')))) return this.draw();
this.op('ipt', 'policy', { family: fam, chain: c.name, policy: e.target.value });
};
$('.fw-chain-head', sec).append(sel);
}
if (c.rules.length) {
const t = h(`<table class="docker-table fw-table"><tbody></tbody></table>`);
for (const r of c.rules) {
const tgt = (r.spec.match(/-j (\S+)/) || [])[1] || '';
const tr = h(`<tr><td class="num" style="width:36px">${r.num}</td><td class="mono spec">${esc(r.spec)}</td><td style="width:90px"><span class="fw-act ${tgt === 'ACCEPT' ? 'allow' : /DROP|REJECT/.test(tgt) ? 'deny' : ''}">${esc(tgt)}</span></td>
<td class="acts" style="width:50px"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
$('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${c.name} #${r.num}: ${r.spec}`)) this.op('ipt', 'delete', { family: fam, chain: c.name, num: r.num }, T('Rule deleted')); };
$('tbody', t).append(tr);
}
sec.append(t);
}
this.body.append(sec);
}
}
async addUfw() {
const r = await modal({ title: T('Add UFW rule'), body: `
<div class="row"><div class="field"><label>${T('Action')}</label><select name="action"><option value="allow">${T('Allow')}</option><option value="deny">${T('Deny')}</option><option value="reject">${T('Reject')}</option><option value="limit">${T('Limit (rate-limit)')}</option></select></div>
<div class="field"><label>${T('Direction')}</label><select name="dir"><option value="in">${T('Incoming')}</option><option value="out">${T('Outgoing')}</option></select></div></div>
<div class="row"><div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div>
<div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="any">${T('Any')}</option><option value="tcp">TCP</option><option value="udp">UDP</option></select></div></div>
<div class="field"><label>${T('From (IP or network)')}</label><input name="from" placeholder="any, 192.168.0.0/24"/></div>
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
<label class="check"><input type="checkbox" name="top"/>${T('Insert at the top (highest priority)')}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
if (!r) return;
this.op('ufw', 'add', { ...r, port: r.port.replace(/\s/g, ''), from: r.from.trim() || 'any', comment: r.comment.trim() }, T('Rule added'));
}
async addIpt() {
const fam = this.backend === 'ip6tables' ? 6 : 4;
const chains = this.data.chains.map((c) => c.name);
const r = await modal({ title: T('Add {bin} rule', { bin: this.backend }), body: `
<div class="row"><div class="field"><label>${T('Chain')}</label><select name="chain">${chains.map((c) => `<option ${c === 'INPUT' ? 'selected' : ''}>${esc(c)}</option>`).join('')}</select></div>
<div class="field"><label>${T('Target')}</label><select name="target"><option>ACCEPT</option><option>DROP</option><option>REJECT</option><option>LOG</option><option>RETURN</option></select></div></div>
<div class="row"><div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="tcp">TCP</option><option value="udp">UDP</option><option value="${fam === 6 ? 'icmpv6' : 'icmp'}">ICMP</option><option value="all">${T('Any')}</option></select></div>
<div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div></div>
<div class="row"><div class="field"><label>${T('Source (IP or network)')}</label><input name="source" placeholder="any, ${fam === 6 ? 'fd00::/8' : '192.168.0.0/24'}"/></div>
<div class="field small"><label>${T('Interface')}</label><input name="iface" placeholder="eth0"/></div></div>
<div class="field"><label>${T('Connection state')}</label><select name="state"><option value="">${T('Any')}</option><option value="NEW">NEW</option><option value="ESTABLISHED,RELATED">ESTABLISHED,RELATED</option></select></div>
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
<label class="check"><input type="checkbox" name="top" checked/>${T('Insert at the top (highest priority)')}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
if (!r) return;
this.op('ipt', 'add', { ...r, family: fam, port: r.port.replace(/\s/g, ''), source: r.source.trim(), iface: r.iface.trim(), comment: r.comment.trim() }, T('Rule added'));
}
dispose() { this.unsub(); api.call('firewall:close', this.id).catch(() => {}); }
}
function openFirewall(host) { return new FirewallSession(host); }
// ============================================================ Netzwerk (Ubuntu netplan / Debian ifupdown über SSH)
const NET_KINDS = { ethernet: 'Ethernet', bond: 'Bond', bridge: 'Bridge', vlan: 'VLAN' };
const BOND_MODES = [['802.3ad', '802.3ad (LACP)'], ['active-backup', 'active-backup'], ['balance-rr', 'balance-rr'], ['balance-xor', 'balance-xor'], ['balance-tlb', 'balance-tlb'], ['balance-alb', 'balance-alb'], ['broadcast', 'broadcast']];
const VIRTUAL_IF = /^(veth|docker\d|br-[0-9a-f]{12}|tap\d|fwbr|fwpr|fwln|vnet|virbr|cali|flannel|cni|kube|tun|wg|tailscale|zt)/;
const splitList = (v) => String(v || '').split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
class NetworkSession {
constructor(host) {
this.kind = 'network'; this.id = uid(); this.host = host;
this.title = `${T('Network')} · ${host.label || host.address}`;
this.data = null; this.showVirtual = false;
this.el = h(`<div class="session network">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
</div>
<div class="docker-body net-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 = $('.net-body', this.el);
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'refresh') this.refresh();
if (a === 'files') this.files();
if (a === 'new') ctxMenu(e.clientX, e.clientY, [
{ label: T('New bond'), icon: 'plus', run: () => this.edit(null, 'bond') },
{ label: T('New bridge'), icon: 'plus', run: () => this.edit(null, 'bridge') },
{ label: T('New VLAN'), icon: 'plus', run: () => this.edit(null, 'vlan') },
]);
});
$('[data-a=virtual]', this.el).onchange = (e) => { this.showVirtual = e.target.checked; this.draw(); };
this.unsub = api.on('network:closed', (sid) => { if (sid === this.id && !this.applying) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this);
this.open();
}
async open() {
try {
this.apply(await api.call('network:open', this.id, hostRef(this.host)));
setTabState(this, 'on');
} 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:560px;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); openNetwork(this.host); };
$('.pane-empty', this.body).append(b);
}
apply(d) {
this.data = d;
const be = { netplan: 'netplan', ifupdown: 'ifupdown', networkmanager: 'NetworkManager', networkd: 'systemd-networkd' }[d.backend] || d.backend;
$('.rt', this.el).textContent = ` · ${d.os || ''} · ${be}`;
$('[data-a=new]', this.el).disabled = !d.editable;
this.draw();
}
async refresh(tries = 1) {
for (let i = 1; ; i++) {
try { return this.apply(await api.call('network:read', this.id)); } catch (e) {
if (i >= tries) return toast(e.message, 'error');
await new Promise((r) => setTimeout(r, 3000));
}
}
}
cfg(name) { return this.data.config.find((c) => c.name === name); }
draw() {
const d = this.data;
const scroll = this.body.scrollTop;
this.body.innerHTML = '';
const wrap = h('<div class="net-wrap"></div>');
if (d.rolledBack) wrap.append(h(`<div class="net-banner warn">${ICONS.refresh}<span>${esc(T('The last network change was rolled back automatically ({time}) because MrTerm could not reconnect.', { time: d.rolledBack }))}</span></div>`));
if (!d.editable) wrap.append(h(`<div class="net-banner">${ICONS.network}<span>${esc(d.note || T('Editing is supported for Ubuntu (netplan) and Debian/Proxmox (ifupdown). This host uses {backend}, so interfaces are shown read-only. Config files can still be edited.', { backend: d.backend }))}</span></div>`));
// System: Hostname, Gateway, DNS
const gw = d.routes.map((r) => `${esc(r.via || '—')} <span class="muted">(${esc(r.dev)})</span>`).join('<br>') || `<span class="muted">${T('none')}</span>`;
const sys = h(`<div class="net-sys">
<div><label>${T('Hostname')}</label><div class="v"><b>${esc(d.hostname)}</b><button class="btn ghost sm" data-s="host">${ICONS.edit}</button></div></div>
<div><label>${T('Default gateway')}</label><div class="v">${gw}</div></div>
<div><label>${T('DNS servers')}</label><div class="v">${esc(d.dns.servers.join(', ') || '—')}${d.dns.search.length ? `<br><span class="muted">${T('Search')}: ${esc(d.dns.search.join(' '))}</span>` : ''}${d.dns.editable && !d.dns.servers.includes('127.0.0.53') ? `<button class="btn ghost sm" data-s="dns">${ICONS.edit}</button>` : ''}</div>
${d.dns.servers.includes('127.0.0.53') ? `<div class="hint">${T('systemd-resolved is used: set DNS servers per interface.')}</div>` : ''}</div></div>`);
sys.onclick = (e) => { const a = e.target.closest('[data-s]')?.dataset.s; if (a === 'host') this.hostname(); if (a === 'dns') this.resolv(); };
wrap.append(sys);
// Schnittstellen: live + nur konfigurierte
const live = d.interfaces.filter((i) => this.showVirtual || !VIRTUAL_IF.test(i.name));
const names = new Set(live.map((i) => i.name));
const cfgOnly = d.config.filter((c) => !d.interfaces.some((i) => i.name === c.name)).map((c) => ({ name: c.name, kind: c.kind, state: 'ABSENT', addrs: [], master: '' }));
const order = { bond: 1, bridge: 2, vlan: 3, ethernet: 0 };
const list = [...live, ...cfgOnly.filter((c) => !names.has(c.name))].sort((a, b) => ((order[this.kindOf(a)] ?? 4) - (order[this.kindOf(b)] ?? 4)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
const grid = h('<div class="net-grid"></div>');
for (const i of list) grid.append(this.card(i));
wrap.append(grid);
this.body.append(wrap);
this.body.scrollTop = scroll;
}
kindOf(i) { return this.cfg(i.name)?.kind || (['bond', 'bridge', 'vlan'].includes(i.kind) ? i.kind : 'ethernet'); }
card(i) {
const c = this.cfg(i.name);
const kind = this.kindOf(i);
const up = i.state === 'UP' || i.state === 'UNKNOWN';
const summary = !c ? `<span class="muted">${T('Not configured')}</span>`
: c.method4 === 'dhcp' ? 'DHCP' : c.method4 === 'static' ? `${T('Static')} ${esc(c.addresses.join(', '))}${c.gateway ? ` → ${esc(c.gateway)}` : ''}` : `<span class="muted">${T('No IPv4')}</span>`;
const b = i.bonding;
const bondInfo = b ? `<div class="net-bond"><div><span class="muted">${T('Mode')}:</span> ${esc(b.mode || '')}${b.lacpRate ? ` · LACP ${esc(b.lacpRate)}` : ''}${b.hashPolicy ? ` · ${esc(b.hashPolicy)}` : ''}</div>
${b.slaves.map((s) => `<div class="slave"><span class="dot ${s.mii === 'up' ? 'on' : 'err'}"></span>${esc(s.name)} <span class="muted">${esc(s.speed || '')}${s.aggregator ? ` · Agg ${esc(s.aggregator)}` : ''}</span></div>`).join('')}</div>` : '';
const el = h(`<div class="net-card ${i.state === 'ABSENT' ? 'absent' : ''}">
<div class="nh"><span class="dot ${i.state === 'ABSENT' ? '' : up ? 'on' : 'err'}"></span><b>${esc(i.name)}</b><span class="kind">${NET_KINDS[kind] || esc(i.kind || '')}</span>
${i.master ? `<span class="muted">→ ${esc(i.master)}</span>` : ''}<span class="grow"></span>
${this.data.editable ? `<button class="btn ghost sm" data-n="edit" title="${T('Edit')}">${ICONS.edit}</button>` : ''}
${this.data.editable && c && kind !== 'ethernet' ? `<button class="btn ghost sm" data-n="del" title="${T('Delete')}">${ICONS.trash}</button>` : ''}</div>
<div class="nm muted">${i.mac ? esc(i.mac) : ''}${i.mtu ? ` · MTU ${i.mtu}` : ''}${i.state === 'ABSENT' ? T('Configured, but not present') : ''}</div>
<div class="na">${i.addrs.map((a) => `<div>${esc(a.addr)}${a.dynamic ? ' <span class="tag">DHCP</span>' : ''}</div>`).join('') || `<div class="muted">${T('No address')}</div>`}</div>
${bondInfo}
<div class="ncfg"><span class="muted">${T('Configured')}:</span> ${summary}</div></div>`);
el.onclick = (e) => {
const a = e.target.closest('[data-n]')?.dataset.n;
if (a === 'edit') this.edit(i.name, kind);
if (a === 'del') this.remove(c);
};
return el;
}
// Anwenden mit Rollback-Anzeige
async run(label, fn) {
this.applying = true;
const ov = h(`<div class="overlay"><div class="spinner"></div><div class="msg">${esc(label)}</div><div class="msg" style="font-size:12px;color:var(--faint)">${T('MrTerm reconnects to confirm the change. Without confirmation the server restores the previous configuration after 90 seconds.')}</div></div>`);
this.el.append(ov);
try {
const r = await fn();
ov.remove();
if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(4); }
else {
setTabState(this, 'err');
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--orange);max-width:560px;text-align:center">${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}</div></div>`;
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Reconnect')}</button>`);
b.onclick = () => { closeTab(this.id); openNetwork(this.host); };
$('.pane-empty', this.body).append(b);
}
} catch (e) { ov.remove(); toast(e.message, 'error'); }
this.applying = false;
}
async confirmApply(what) {
const r = await modal({ title: T('Apply network change?'), text: what,
body: `<p style="margin-top:0">${T('The change is applied immediately. If MrTerm cannot reconnect within 90 seconds, the server restores the previous configuration automatically.')}</p>
<div class="field"><label>${T('Reconnect via')}</label><input name="verify" value="${esc(this.host.address)}"/><div class="hint">${T('Change this if the change affects the address MrTerm uses to connect.')}</div></div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Apply'), value: 'form', cls: 'primary' }] });
return r ? (r.verify.trim() === this.host.address ? '' : r.verify.trim()) : null;
}
edit(name, kind) {
const d = this.data;
const isNew = !name;
const m = structuredClone(this.cfg(name) || { name: name || '', kind, method4: 'none', addresses: [], gateway: '', dns: [], search: [], mtu: '', method6: 'auto', addresses6: [], gateway6: '',
bond: kind === 'bond' ? { members: [], mode: '802.3ad', lacpRate: 'fast', hashPolicy: 'layer3+4', miimon: 100 } : undefined,
bridge: kind === 'bridge' ? { members: [], stp: false } : undefined, vlan: kind === 'vlan' ? { id: '', link: '' } : undefined });
if (!this.cfg(name) && name) { const live = d.interfaces.find((i) => i.name === name); if (live?.addrs.some((a) => a.dynamic)) m.method4 = 'dhcp'; }
const phys = [...new Set([...d.interfaces.filter((i) => !VIRTUAL_IF.test(i.name) && (i.kind === 'ethernet' || i.kind === 'bond' || i.kind === 'vlan')).map((i) => i.name), ...d.config.filter((c) => c.kind !== 'bridge').map((c) => c.name)])].filter((x) => x !== m.name).sort();
const memberBox = (sel, filter) => {
const box = h('<div class="vpn-hosts"></div>');
phys.filter(filter).forEach((x) => box.append(h(`<label class="check"><input type="checkbox" data-m="${esc(x)}" ${sel.includes(x) ? 'checked' : ''}/>${esc(x)}<span class="muted" style="margin-left:6px;font-size:11px">${esc(d.interfaces.find((i) => i.name === x)?.mac || '')}</span></label>`)));
if (!box.children.length) box.append(h(`<div class="hint">${T('No suitable interfaces found.')}</div>`));
return box;
};
const fields = [];
if (isNew) fields.push(field(T('Name'), 'name', kind === 'bond' ? 'bond0' : kind === 'bridge' ? (d.backend === 'ifupdown' ? 'vmbr1' : 'br0') : '', { placeholder: kind === 'vlan' ? 'eno1.100' : '', hint: kind === 'vlan' ? T('Usually <parent>.<VLAN ID>, e.g. eno1.100') : '' }));
let members = null;
if (m.kind === 'bond') {
members = memberBox(m.bond.members, (x) => this.kindOf({ name: x, kind: d.interfaces.find((i) => i.name === x)?.kind }) === 'ethernet');
fields.push(heading(T('Bond')), h(`<label class="flabel">${T('Members')}</label>`), members,
field(T('Mode'), 'bmode', m.bond.mode, { type: 'select', options: BOND_MODES }),
row(field(T('LACP rate'), 'lacp', m.bond.lacpRate || 'slow', { type: 'select', options: [['fast', 'fast (1 s)'], ['slow', 'slow (30 s)']] }),
field(T('Hash policy'), 'hash', m.bond.hashPolicy || 'layer2', { type: 'select', options: ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4'].map((x) => [x, x]) })),
field(T('MII monitoring (ms)'), 'miimon', m.bond.miimon ?? 100, { type: 'number' }));
}
if (m.kind === 'bridge') {
members = memberBox(m.bridge.members, () => true);
fields.push(heading(T('Bridge')), h(`<label class="flabel">${T('Ports')}</label>`), members, check(T('Spanning Tree (STP)'), 'stp', m.bridge.stp));
}
if (m.kind === 'vlan') fields.push(heading('VLAN'), row(field(T('VLAN ID'), 'vid', m.vlan.id, { type: 'number', placeholder: '100' }), field(T('Parent interface'), 'vlink', m.vlan.link, { type: 'select', options: [['', '—'], ...phys.map((x) => [x, x])] })));
const v4 = h('<div></div>');
v4.append(field(T('IPv4 addresses (one per line, CIDR)'), 'addresses', m.addresses.join('\n'), { type: 'textarea', placeholder: '192.168.1.10/24' }), field(T('Gateway'), 'gateway', m.gateway, { placeholder: '192.168.1.1' }));
const v6 = h('<div></div>');
v6.append(field(T('IPv6 addresses (one per line, CIDR)'), 'addresses6', m.addresses6.join('\n'), { type: 'textarea', placeholder: '2001:db8::10/64' }), field(T('IPv6 gateway'), 'gateway6', m.gateway6));
fields.push(heading('IPv4'), field(T('Method'), 'method4', m.method4, { type: 'select', options: [['dhcp', 'DHCP'], ['static', T('Static')], ['none', T('No IPv4 address')]] }), v4,
heading('DNS'), row(field(T('DNS servers'), 'dns', m.dns.join(', '), { placeholder: '1.1.1.1, 9.9.9.9' }), field(T('Search domains'), 'search', m.search.join(' '), { placeholder: 'example.lan' })),
heading('IPv6'), field(T('Method'), 'method6', m.method6, { type: 'select', options: [['auto', T('Automatic (SLAAC)')], ['dhcp', 'DHCPv6'], ['static', T('Static')], ['none', T('Disabled')]] }), v6,
heading(T('Advanced')), field('MTU', 'mtu', m.mtu, { type: 'number', placeholder: '1500' }));
const form = openDrawer(isNew ? T('New {kind}', { kind: NET_KINDS[kind] }) : `${NET_KINDS[m.kind]} ${m.name}`, fields, async () => {
const f = formValues(form);
const out = { ...m, name: isNew ? f.name.trim() : m.name, method4: f.method4, addresses: f.method4 === 'static' ? splitList(f.addresses) : [], gateway: f.method4 === 'static' ? f.gateway.trim() : '',
dns: splitList(f.dns), search: splitList(f.search), mtu: f.mtu, method6: f.method6, addresses6: f.method6 === 'static' ? splitList(f.addresses6) : [], gateway6: f.method6 === 'static' ? f.gateway6.trim() : '' };
const sel = members ? $$('[data-m]', members).filter((x) => x.checked).map((x) => x.dataset.m) : [];
if (m.kind === 'bond') { if (!sel.length) throw new Error(T('Select at least one member.')); out.bond = { members: sel, mode: f.bmode, lacpRate: f.bmode === '802.3ad' ? f.lacp : '', hashPolicy: f.hash, miimon: f.miimon }; }
if (m.kind === 'bridge') out.bridge = { members: sel, stp: f.stp };
if (m.kind === 'vlan') { out.vlan = { id: f.vid, link: f.vlink }; if (!f.vlink) throw new Error(T('Select the parent interface.')); }
if (isNew && d.config.some((c) => c.name === out.name)) throw new Error(T('An interface with this name already exists.'));
const verify = await this.confirmApply(`${NET_KINDS[out.kind]} ${out.name}`);
if (verify === null) return false;
closeDrawer();
this.run(T('Applying network configuration …'), () => api.call('network:save', this.id, out, verify));
return false;
});
const sync = () => {
v4.style.display = $('[name=method4]', form).value === 'static' ? '' : 'none';
v6.style.display = $('[name=method6]', form).value === 'static' ? '' : 'none';
const bm = $('[name=bmode]', form);
if (bm) $('[name=lacp]', form).closest('.field').style.display = bm.value === '802.3ad' ? '' : 'none';
};
form.addEventListener('change', sync);
sync();
}
async remove(c) {
if (!(await confirmBox(T('Delete {kind} {name}?', { kind: NET_KINDS[c.kind], name: c.name }), T('The interface is removed from the configuration. Members keep their current settings.')))) return;
const verify = await this.confirmApply(`${T('Delete')} ${c.name}`);
if (verify === null) return;
this.run(T('Applying network configuration …'), () => api.call('network:remove', this.id, c, verify));
}
async hostname() {
const n = await promptBox(T('Change hostname'), T('Hostname'), this.data.hostname);
if (!n || n === this.data.hostname) return;
try { await call('network:hostname', this.id, n.trim()); toast(T('Hostname changed'), 'ok'); this.refresh(); } catch {}
}
async resolv() {
const r = await modal({ title: T('DNS servers'), body: `<div class="field"><label>${T('DNS servers')}</label><input name="s" value="${esc(this.data.dns.servers.join(', '))}"/></div><div class="field"><label>${T('Search domains')}</label><input name="d" value="${esc(this.data.dns.search.join(' '))}"/></div><div class="hint">${T('Written to /etc/resolv.conf.')}</div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
try { await call('network:resolv', this.id, splitList(r.s), splitList(r.d)); toast(T('Saved'), 'ok'); this.refresh(); } catch {}
}
async files() {
const list = [...new Set([...this.data.files, '/etc/hosts', ...(this.data.dns.editable ? ['/etc/resolv.conf'] : [])])];
const f = await new Promise((res) => ctxMenu(innerWidth - 320, 90, list.map((p) => ({ label: p, icon: 'file', run: () => res(p) }))));
let content;
try { content = await call('network:readFile', this.id, f); } catch { return; }
const net = !/^\/etc\/(hosts|resolv\.conf)$/.test(f);
const form = openDrawer(f, [field(T('Content'), 'content', content, { type: 'textarea', hint: net ? T('Saving applies the file with automatic rollback.') : '' })], async () => {
const v = formValues(form).content;
if (!net) { this.run(T('Saving …'), () => api.call('network:writeFile', this.id, f, v, '')); return; }
const verify = await this.confirmApply(f);
if (verify === null) return false;
closeDrawer();
this.run(T('Applying network configuration …'), () => api.call('network:writeFile', this.id, f, v, verify));
return false;
});
const ta = $('textarea', form); ta.classList.add('mono'); ta.style.minHeight = '60vh';
}
dispose() { this.unsub(); api.call('network:close', this.id).catch(() => {}); }
}
function openNetwork(host) { return new NetworkSession(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) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.wall}</div>`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent)">${ICONS.network}</div>`, label: `${T('Network')}: ${x.label || x.address}`, run: () => openNetwork(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]);
}
});
// Web-Version: Hostschlüssel bestätigen (in Electron zeigt der Hauptprozess dafür einen Systemdialog)
api.on('hostkey:request', async (req) => {
const changed = !!req.previous;
const r = await modal({
title: changed ? T('Host key has changed!') : T('Unknown host'),
body: `<p style="margin-top:0">${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}</p>
<div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.fingerprint)}</div>
${changed ? `<p>${esc(T('Previously stored:'))}</p><div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.previous)}</div>` : ''}`,
buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }],
noEnter: changed,
});
api.replySecret(req.reqId, r === true);
});
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);