…).') : T('Contents of a WireGuard .conf file.');
};
$$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t)));
// Hosts zuordnen
const hostBox = h(`
`);
const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
if (!sorted.length) hostBox.append(h(`${T('No hosts yet')}
`));
sorted.forEach((x) => hostBox.append(h(` ${esc(x.label || x.address)}${esc(hostSub(x))} `)));
const form = openDrawer(v.id ? T('Edit VPN') : T('New VPN'), [
seg,
field(T('Name'), 'label', v.label, { placeholder: T('Office VPN') }),
cfg,
ovpnBox,
check(T('Disconnect when MrTerm closes'), 'disconnectOnQuit', v.disconnectOnQuit !== false),
heading(T('Assigned hosts')),
hostBox,
], async () => {
const f = formValues(form);
if (!f.config.trim()) throw new Error(T('Configuration is missing.'));
if (type === 'wireguard' && !/\[Interface\]/i.test(f.config)) throw new Error(T('This does not look like a WireGuard configuration.'));
const item = { ...v, type, label: f.label.trim() || VPN_TYPES[type], config: f.config, disconnectOnQuit: f.disconnectOnQuit,
username: type === 'openvpn' ? f.username : '', password: type === 'openvpn' ? f.password : '' };
const saved = await call('vault:upsert', 'vpns', item);
for (const cb of $$('[data-host]', hostBox)) {
const hst = S.vault.hosts.find((x) => x.id === cb.dataset.host);
const want = cb.checked ? saved.id : (hst.vpnId === saved.id ? null : hst.vpnId || null);
if ((hst.vpnId || null) !== want) await call('vault:upsert', 'hosts', { ...hst, vpnId: want });
}
reload();
}, (() => { const b = h(`${ICONS.folder}${T('File…')} `); b.onclick = loadFile; return b; })());
async function loadFile() {
const f = await call('import:pickFile', T('VPN configuration'), ['conf', 'ovpn']);
if (!f) return;
$('[name=config]', form).value = f.content;
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name.replace(/\.(conf|ovpn)$/i, '');
setType(/\.ovpn$/i.test(f.name) || /^\s*(client|remote)\b/m.test(f.content) ? 'openvpn' : 'wireguard');
}
setType(type);
if (pick) loadFile();
}
// ============================================================ Known Hosts & Verlauf
function viewKnown(page) {
page.append(toolbar(T('Search known hosts …')));
const c = h('
'); page.append(c);
const entries = Object.entries(S.vault.knownHosts).filter(([id, v]) => matches(id, v.fingerprint));
if (!entries.length) return c.append(emptyState('shield', T('No known hosts'), T('Host keys are stored here on first connection.')));
const l = h('
');
for (const [id, v] of entries) {
const card = h(`${ICONS.shield}
${ICONS.trash}
`);
$('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); };
l.append(card);
}
c.append(l);
}
function viewHistory(page) {
page.append(toolbar(T('Search history …')));
const c = h('
'); page.append(c);
const items = S.vault.history.map((x) => ({ ...x, host: S.vault.hosts.find((y) => y.id === x.hostId) })).filter((x) => x.host && matches(x.host.label, x.host.address));
if (!items.length) return c.append(emptyState('clock', T('No connections yet'), T('Recently used hosts appear here.')));
const l = h('
');
for (const it of items) {
const card = h(`${avatar(it.host)}
${T('Connect')} `);
card.onclick = () => connectHost(it.host);
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(it.host));
l.append(card);
}
c.append(l);
}
// ============================================================ Einstellungen
async function viewSettings(page) {
const st = settings();
page.append(h(`
${T('Settings')} `));
const c = h('
'); page.append(c);
const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); };
// ---- Sprache
const langCard = h(`
${T('Language')} `);
const langField = field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System default')], ...I18N.LANGUAGES] });
langField.onchange = async () => { await save({ language: $('select', langField).value }); I18N.setLanguage(settings().language, navigator.language); reload(); };
langCard.append(langField);
// ---- App-Design
const designCard = h(``);
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(``);
p.onclick = async () => { await save({ appTheme: id }); applyAppTheme(); render(); };
$('.design-list', designCard).append(p);
}
['', ...COLORS.slice(0, 7), '#21c7a8', '#cba6f7'].forEach((col) => {
const b = h(` `);
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b);
});
const themeCard = h(`${T('Terminal color scheme')}
`);
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
const autoP = h(`${T('Match theme')}
user@host :
~ $ ls
${T('follows the theme')} `);
autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); };
$('.theme-list', themeCard).append(autoP);
for (const [id, t] of Object.entries(TERM_THEMES)) {
const p = h(`${esc(t.name)}
user@host :
~ $ ls
docs run.sh err `);
p.onclick = async () => { await save({ terminalTheme: id }); render(); };
$('.theme-list', themeCard).append(p);
}
const termCard = h('
Terminal ');
termCard.append(
row(field(T('Font'), 'fontFamily', st.fontFamily), field(T('Size'), 'fontSize', st.fontSize, { type: 'number', cls: 'small' })),
row(field('Cursor', 'cursorStyle', st.cursorStyle, { type: 'select', options: [['block', 'Block'], ['bar', T('Bar')], ['underline', T('Underline')]] }), field(T('Scrollback lines'), 'scrollback', st.scrollback, { type: 'number' })),
check(T('Blinking cursor'), 'cursorBlink', st.cursorBlink),
check(T('Copy on select'), 'copyOnSelect', st.copyOnSelect),
field(T('SSH keep-alive (seconds, 0 = off)'), 'keepAlive', st.keepAlive, { type: 'number' }),
);
termCard.addEventListener('change', () => {
const v = formValues(termCard);
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
});
const rdpCard = h(``);
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
emb.onchange = async () => {
await save({ rdpEmbed: $('input', emb).checked });
if (api.platform === 'linux') toast(T('Takes effect after restarting MrTerm (X11/XWayland mode).'));
};
rdpCard.append(emb);
if (api.platform === 'linux') rdpCard.append(h(`${T('Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.')}
`));
if (api.platform !== 'win32') {
const f = field(T('FreeRDP client (separate window)'), 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', T('Automatic')], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] });
f.onchange = () => save({ rdpClientLinux: $('select', f).value }).then(render);
rdpCard.append(f);
}
api.call('rdp:detect').then((d) => {
$('.rdp-info', rdpCard).textContent = d.available ? T('Client in use: {client}', { client: d.client }) : T('No RDP client found. Install: {hint}', { hint: d.hint });
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
}).catch(() => {});
const dataCard = h(`${T('Data')} ${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.')}
`);
const btns = [
[T('Import ~/.ssh/config'), importSshConfig],
[T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }],
[T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }],
];
btns.forEach(([l, fn]) => { const b = h(`${esc(l)} `); b.onclick = fn; $('.row', dataCard).append(b); });
// ---- Import aus anderen Programmen
const importCard = h(`${T('Import')} ${T('Import folders and hosts (RDP & SSH) from Devolutions Remote Desktop Manager . In RDM: File → Export → Export entries as .rdm (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.')}
`);
const rdmBtn = h(`${T('Import from Remote Desktop Manager…')} `);
rdmBtn.onclick = importRdm;
importCard.append(rdmBtn);
// ---- App-Sperre
const secCard = h(`${T('App lock')} ${T('Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.')}
`);
renderLockSettings($('.lock-rows', secCard));
// ---- LAN-Synchronisation
const syncCard = h('
');
renderSyncCard(syncCard);
// ---- Updates
const updCard = h(`Updates ${T('Installed version: {v}', { v: '…' })}
`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
const updFields = h('
');
updFields.append(
field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: T('Gitea/Forgejo repository whose releases contain the installation packages.') }),
field(T('Access token (private repos only)'), 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }),
check(T('Check for updates on startup'), 'updateAutoCheck', st.updateAutoCheck),
check(T('Include pre-releases'), 'updatePrerelease', st.updatePrerelease),
);
updFields.addEventListener('change', () => save(formValues(updFields)));
const chk = h(`${ICONS.refresh}${T('Check for updates now')} `);
chk.onclick = async () => {
chk.disabled = true;
try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r);
else if (r.repoVersion) toast(T('The repository has version {repo}, but there is no release with installation packages for it yet. Installed: {current}.', { repo: r.repoVersion, current: r.current }), 'error');
else if (r.noRelease) toast(T('The repository has no releases yet.'), 'error');
else toast(T('MrTerm is up to date ({v}).', { v: r.current }), 'ok'); } catch {}
chk.disabled = false;
};
updCard.append(updFields, chk);
const C = `${T('Ctrl')} `;
const keysCard = h(`${T('Keyboard shortcuts')}
${C}+Shift +K / T ${T('Quick Connect / command palette (outside the terminal also {k})', { k: `${C}+K ` })}
${C}+Shift +W ${T('Close tab')}
${C}+Tab ${T('Next tab')}
${C}+1..9 ${T('Switch to tab')}
${C}+Shift +C/V ${T('Copy / paste in terminal')}
${C}+Shift +F ${T('Search in terminal')}
${C}++/−/0 ${T('Font size')}
${C}+Shift +L ${T('Lock now')}
`);
c.append(langCard, secCard, 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(`
${T('MrTerm is locked')}
${S.lock.hasPassword ? `
` : ''}
${S.lock.fido.length ? `
${ICONS.key}${T('Unlock with security key')} ` : ''}
`);
const err = $('.lock-err', el);
const done = async () => { el.remove(); await reload(); };
const form = $('.lock-pw', el);
if (form) form.onsubmit = async (e) => {
e.preventDefault();
const inp = $('input', form);
err.textContent = '';
try { await api.call('lock:unlockPassword', inp.value); done(); } catch (x) { err.textContent = x.message; inp.select(); }
};
const fb = $('.lock-fido', el);
if (fb) fb.onclick = async () => {
err.textContent = ''; fb.disabled = true;
try { await api.call('lock:unlockFido'); done(); } catch (x) { err.textContent = x.message; }
fb.disabled = false;
};
document.body.append(el);
($('input', el) || fb)?.focus();
}
async function lockNow() {
if (!S.lock?.enabled) return;
S.lock = await call('lock:lock');
if (S.lock.locked) showLock();
}
$('#lockBtn').onclick = lockNow;
// Automatische Sperre nach Inaktivität (Einstellung autoLock in Minuten, 0 = aus)
let lastActivity = Date.now();
['keydown', 'mousedown', 'mousemove', 'wheel'].forEach((ev) => document.addEventListener(ev, () => { lastActivity = Date.now(); }, { capture: true, passive: true }));
setInterval(() => {
const min = Number(S.vault?.settings.autoLock) || 0;
if (!min || !S.lock?.enabled || S.lock.locked || $('.lock-screen')) return;
// In einem eingebetteten RDP-Fenster sieht MrTerm keine Eingaben – dort nicht automatisch sperren
if (S.tabs.some((t) => t.id === S.active && t.kind === 'rdp')) { lastActivity = Date.now(); return; }
if (Date.now() - lastActivity > min * 60000) lockNow();
}, 15000);
function renderLockSettings(box) {
const L = S.lock;
box.innerHTML = '';
const refresh = (st) => { S.lock = st; $('#lockBtn').style.display = st.enabled ? '' : 'none'; renderLockSettings(box); };
const disableWarn = async () => (L.hasPassword ? 1 : 0) + L.fido.length > 1 || confirmBox(T('Disable app lock?'), T('This is the last unlock method. MrTerm will no longer be locked.'), T('Disable'));
// Passwort
const pwRow = h(`${T('Password')} ${L.hasPassword ? T('Set') : T('Not set')}
`);
const pwBtn = h(`${L.hasPassword ? T('Change password') : T('Set password')} `);
pwBtn.onclick = async () => {
const r = await modal({ title: L.hasPassword ? T('Change password') : T('Set password'),
body: `${T('New password')}
${T('Repeat password')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
try { refresh(await call('lock:setPassword', r.a)); toast(T('Password saved'), 'ok'); } catch {}
};
pwRow.append(pwBtn);
if (L.hasPassword) {
const rm = h(`${ICONS.trash} `);
rm.onclick = async () => { if (await disableWarn()) refresh(await call('lock:removePassword')); };
pwRow.append(rm);
}
box.append(pwRow);
// Sicherheitsschlüssel
for (const k of L.fido) {
const row = h(`${ICONS.key}${esc(k.label)} ${T('Security key (FIDO2)')}
`);
const rm = h(`${ICONS.trash} `);
rm.onclick = async () => { if (await disableWarn() && await confirmBox(T('Remove security key?'), k.label, T('Remove'))) refresh(await call('lock:removeFido', k.id)); };
row.append(rm);
box.append(row);
}
const add = h(`${ICONS.plus}${T('Add security key')} `);
add.onclick = async () => {
const label = await promptBox(T('Add security key'), T('Name'), 'YubiKey');
if (label == null) return;
add.disabled = true;
try { refresh(await call('lock:addFido', label)); toast(T('Security key added'), 'ok'); } catch {}
add.disabled = false;
};
const actions = h('
');
actions.append(add);
if (L.enabled) {
const now = h(`${ICONS.lock}${T('Lock now')} `);
now.onclick = lockNow;
actions.append(now);
}
box.append(actions);
if (L.enabled) {
const al = field(T('Lock automatically after inactivity'), 'autoLock', String(settings().autoLock || 0), { type: 'select',
options: [['0', T('Never')], ...[1, 5, 10, 15, 30, 60].map((n) => [String(n), T('{n} minutes', { n })])] });
al.style.marginTop = '14px';
al.onchange = async () => { const v = Number($('select', al).value); await call('vault:settings', { autoLock: v }); S.vault.settings.autoLock = v; };
box.append(al);
box.append(h(`${T('If you forget the password and lose all security keys, the vault cannot be recovered.')}
`));
}
}
// ============================================================ 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 = `${T('Synchronization')} ${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.')}
`;
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); };
card.append(en);
if (!st.enabled) return;
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('
');
if (!st.peers.length) list.append(h(`${T('No paired devices yet.')}
`));
for (const p of st.peers) {
const row = h(`${esc(p.name)} ${p.online ? T('online') : T('offline')}${p.lastSync ? ` · ${T('last sync {time}', { time: relTime(p.lastSync) })}` : ''}${p.error ? ` · ${esc(p.error)} ` : ''}
`);
const rm = h(`${ICONS.trash} `);
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(`${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) })}
`));
const acts = h('
');
const pair = h(`${ICONS.plus}${T('Pair new device')} `); pair.onclick = openPairing;
const now = h(`${ICONS.refresh}${T('Sync now')} `);
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(`${ICONS.key}${T('Choose shared secrets')} `); sec.onclick = chooseSharedSecrets;
acts.append(pair, now, sec);
card.append(acts);
card.append(h(`${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).')}
`));
}
// 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 = `${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.')}
`;
let timer;
const draw = () => {
const box = $('.pair-list');
if (!box) return clearInterval(timer);
const near = S.syncStatus?.nearby || [];
box.innerHTML = near.length ? '' : ` ${T('Searching for devices …')}
`;
for (const d of near) {
const row = h(``);
const b = h(`${T('Pair')} `);
b.onclick = async () => {
b.disabled = true; b.innerHTML = ` ${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 ? `${title} ${items.map((x) => ` ${esc(label(x))} `).join('')}` : '';
const hostsPw = S.vault.hosts.filter((x) => x.password);
const body = `${T('Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.')}
${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 ? `
${T('There are no secrets on this device yet.')}
` : ''}
`;
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: `${esc(T('Pairing with “{name}”. Does the other device show the same code?', { name: req.name }))}
${req.code.slice(0, 3)} ${req.code.slice(3)}
`,
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) => ``).join('');
const ok = await modal({
title: T('Import from Remote Desktop Manager'),
text: T('{file}: found {n} hosts ({rdp} RDP, {ssh} SSH) in {folders} folders.', { file: f.name, n: hosts.length, rdp: rdpN, ssh: hosts.length - rdpN, folders: folders.size }),
body: `${preview}
${hosts.length > 8 ? `${T('… and {n} more', { n: hosts.length - 8 })}
` : ''}`,
buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: T('Import'), value: true, cls: 'primary' }],
});
if (!ok) return;
const r = await call('vault:bulkImport', entries);
toast(T('{n} hosts imported', { n: r.hosts }) + (r.skipped ? T(', {n} already existed', { n: r.skipped }) : ''), 'ok');
await reload();
$('[data-view=hosts]').click();
}
// ============================================================ Updates
let updateInfo = null;
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
const r = await modal({
title: T('MrTerm {v} is available', { v: info.version }),
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
body: `${info.notes ? `${esc(info.notes)}
` : ''}
${info.kind === 'dev' ? `${T('Development mode: please update via git pull.')}
` : !info.asset ? `${T('The release contains no package for this system.')}
` : ''}
`,
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
});
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast(T('Downloading update …'));
try { await call('update:download'); toast(T('Installing update, MrTerm will restart …'), 'ok'); await call('update:install'); } catch {}
}
api.on('update:available', (info) => showUpdate(info));
api.on('update:progress', (done, total) => {
const b = $('#updateBadge span');
if (b) b.textContent = total ? `Update ${Math.round((done / total) * 100)}%` : 'Update …';
});
// ============================================================ Tabs
function addTab(session) {
S.tabs.push(session);
const tab = h(`${esc(session.title)} ${ICONS.close}
`);
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, 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(`
${avatar(host, 22).replace('SSH ', '')}${esc(hostSub(host))}
${ICONS.folder}SFTP
${host.execCommand ? '' : `
${ICONS.docker}Docker ${ICONS.wall}${T('Firewall')} ${ICONS.network}${T('Network')} `}
${ICONS.code}Snippets
${ICONS.search}
`);
this.overlay = null;
this.alive = false;
this.initTerm();
this.bindUi();
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this);
this.connect();
}
initTerm() {
const st = settings();
this.term = new Terminal({
fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink,
scrollback: st.scrollback, theme: termTheme(), allowProposedApi: true, macOptionIsMeta: true,
});
this.fit = new FitAddon.FitAddon();
this.search = new SearchAddon.SearchAddon();
this.term.loadAddon(this.fit);
this.term.loadAddon(this.search);
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_e, url) => api.call('shell:open', url)));
this.term.open($('.term', this.el));
$('.term', this.el).style.background = termTheme().background; // Innenabstand in Terminalfarbe
this.term.onData((d) => { if (this.alive) api.ssh.write(this.id, d); else if (d === '\r' && this.ended) this.reconnect(); });
this.term.onResize(({ cols, rows }) => { if (this.alive) api.ssh.resize(this.id, cols, rows); });
this.term.onSelectionChange(() => { if (settings().copyOnSelect && this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); });
this.term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
if (e.ctrlKey && e.shiftKey && e.code === 'KeyC') { if (this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); return false; }
if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') { this.paste(); return false; }
if (e.ctrlKey && e.shiftKey && e.code === 'KeyF') { this.toggleFind(true); return false; }
// App-Kürzel im Terminal nur mit Strg+Shift, damit Strg+W/K/T (nano, bash, …) im Terminal ankommen
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW', 'KeyL'].includes(e.code)) return false;
if (e.ctrlKey && e.code === 'Tab') return false;
if (e.ctrlKey && /^Digit[1-9]$/.test(e.code)) return false;
if (e.ctrlKey && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) { this.zoom(e.key); return false; }
return true;
});
$('.term', this.el).addEventListener('contextmenu', (e) => {
e.preventDefault();
if (this.term.hasSelection()) { api.call('clipboard:write', this.term.getSelection()); this.term.clearSelection(); toast(T('Copied')); }
else this.paste();
});
this.ro = new ResizeObserver(() => { if (this.el.classList.contains('active')) this.doFit(); });
this.ro.observe($('.term', this.el));
}
bindUi() {
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'sftp') openSftp(this.host);
if (a === 'docker') openDocker(this.host);
if (a === '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(`${T('No snippets yet.')}
`));
S.vault.snippets.forEach((s) => {
const it = h(`${esc(s.label)}
${esc(s.command)}
`);
it.onclick = () => runSnippet(s, this);
box.append(it);
});
}
toggleFind(open) {
const fb = $('.findbar', this.el);
const o = open ?? !fb.classList.contains('open');
fb.classList.toggle('open', o);
if (o) { $('input', fb).focus(); $('input', fb).select(); } else { this.search.clearDecorations?.(); this.term.focus(); }
}
async paste() { const t = await api.call('clipboard:read'); if (t) this.term.paste(t); }
sendText(t) { if (this.alive) api.ssh.write(this.id, t); }
zoom(k) {
const s = this.term.options.fontSize;
this.term.options.fontSize = k === '0' ? settings().fontSize : Math.max(8, Math.min(32, s + (k === '-' ? -1 : 1)));
this.doFit();
}
doFit() { try { this.fit.fit(); } catch {} }
onShow() { this.doFit(); this.term.focus(); }
applySettings() {
const st = settings();
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
$('.term', this.el).style.background = termTheme().background;
this.doFit();
}
showOverlay(msg, err) {
if (!this.overlay) { this.overlay = h('
'); $('.term-wrap', this.el).append(this.overlay); }
this.overlay.innerHTML = err
? `${esc(msg)}
${T('Edit host')} ${ICONS.refresh}${T('Reconnect')}
`
: `
${esc(msg)}
${T('Cancel')} `;
this.overlay.onclick = (e) => {
const o = e.target.closest('[data-o]')?.dataset.o;
if (o === 'retry') this.reconnect();
if (o === 'cancel') closeTab(this.id);
if (o === 'edit' && this.host.id) { activateTab('home'); editHost(S.vault.hosts.find((x) => x.id === this.host.id) || this.host); }
};
}
hideOverlay() { this.overlay?.remove(); this.overlay = null; }
async connect() {
this.ended = false;
setTabState(this, 'wait');
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
this.doFit();
try {
await api.call('ssh:open', this.id, hostRef(this.host), { cols: this.term.cols, rows: this.term.rows });
this.alive = true;
this.hideOverlay();
setTabState(this, 'on');
this.doFit();
api.ssh.resize(this.id, this.term.cols, this.term.rows);
if (S.active === this.id) this.term.focus();
} catch (e) {
setTabState(this, 'err');
this.showOverlay(e.message, true);
}
}
reconnect() {
api.ssh.close(this.id);
this.alive = false;
this.term.write(`\r\n\x1b[90m— ${T('Reconnecting')} —\x1b[0m\r\n`);
this.connect();
}
onEvent(type, payload) {
if (type === 'data') this.term.write(payload);
else if (type === 'status' && this.overlay) $('.msg', this.overlay).textContent = payload;
else if (type === 'error') this.term.write(`\r\n\x1b[31m${payload}\x1b[0m\r\n`);
else if (type === 'closed') {
if (!this.alive) return;
this.alive = false; this.ended = true;
setTabState(this, '');
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
}
}
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
}
function openTerminal(host) { return new TerminalSession(host); }
// ============================================================ RDP-Session (eingebettet im Tab)
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
S.covered = false;
function updateCover() {
const covered = !!document.querySelector('.modal-bg, .ctx, .lock-screen');
if (covered === S.covered) return;
S.covered = covered;
const t = S.tabs.find((x) => x.id === S.active && x.kind === 'rdp');
if (t) covered ? t.onHide() : t.onShow();
}
new MutationObserver(updateCover).observe(document.body, { childList: true, subtree: true });
class RdpSession {
constructor(host) {
this.kind = 'rdp';
this.id = uid();
this.host = host;
this.title = host.label || host.address;
this.attached = false;
this.el = h(`
${avatar(host, 22)}${esc(hostSub(host))}
${ICONS.refresh}${T('Reconnect')}
${api.platform === 'win32' ? `
${ICONS.screen}${T('Detach as window')} ` : ''}
${T('RDP session hidden while a dialog is open.')}
`);
this.area = $('.rdp-area', this.el);
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'reconnect') this.reconnect();
if (a === 'detach') this.detach();
});
let raf = 0;
this.ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => this.pushBounds()); });
this.ro.observe(this.area);
this.unsub = api.on('rdp:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this);
this.connect();
}
bounds() {
const r = this.area.getBoundingClientRect();
const d = window.devicePixelRatio || 1;
return { x: Math.round(r.left * d), y: Math.round(r.top * d), width: Math.round(r.width * d), height: Math.round(r.height * d) };
}
pushBounds() { if (this.attached && this.area.offsetWidth) api.rdp.bounds(this.id, this.bounds()); }
isVisible() { return S.active === this.id && !S.covered; }
showOverlay(msg, err) {
this.overlay?.remove();
this.overlay = h(`${err ? '' : '
'}
${esc(msg)}
${err ? `${ICONS.screen}${T('In separate window')} ${ICONS.refresh}${T('Reconnect')} ` : `${T('Cancel')} `}
`);
this.overlay.onclick = (e) => {
const o = e.target.closest('[data-o]')?.dataset.o;
if (o === 'retry') this.reconnect();
if (o === 'cancel') closeTab(this.id);
if (o === 'window') { launchRdp(this.host); closeTab(this.id); }
};
this.area.append(this.overlay);
}
async connect() {
setTabState(this, 'wait');
this.showOverlay(T('Connecting to {host} … Login or certificate prompts may appear in a separate window.', { host: this.host.address }));
await new Promise((r) => requestAnimationFrame(r));
try {
const ok = await api.call('rdp:open', this.id, this.host.id || this.host, this.bounds());
if (!ok && !this.attached && !this.disposed && !this.exited) this.showOverlay(T('The RDP window could not be embedded.'), true);
} catch (e) {
setTabState(this, 'err');
this.showOverlay(e.message, true);
}
}
onEvent(type, payload) {
if (type === 'attached') {
this.attached = true; this.exited = false;
this.overlay?.remove(); this.overlay = null;
setTabState(this, 'on');
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
} else if (type === 'exit') {
this.attached = false; this.exited = true;
setTabState(this, payload?.code ? 'err' : '');
this.showOverlay((payload?.code ? T('RDP session ended (code {code}).', { code: payload.code }) : T('RDP session ended.')) + (payload?.message ? ' ' + payload.message : ''), true);
}
}
reconnect() { api.rdp.close(this.id); this.attached = false; this.exited = false; this.connect(); }
async detach() {
if (!this.attached) return;
if (await api.call('rdp:detach', this.id)) { this.attached = false; closeTab(this.id); toast(T('Session continues in a separate window'), 'ok'); }
}
onShow() { if (this.attached && this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } }
onHide() { if (this.attached) api.rdp.hide(this.id); }
dispose() { this.disposed = true; this.unsub(); this.ro.disconnect(); api.rdp.close(this.id); }
}
// ============================================================ SFTP
function viewSftpPicker(page) {
page.append(toolbar(T('Choose a host for SFTP …')));
const c = h('
'); page.append(c);
const list = S.vault.hosts.filter((x) => x.protocol !== 'rdp' && matches(x.label, x.address, x.username));
if (!list.length) return c.append(emptyState('folder', T('No SSH hosts'), T('SFTP requires an SSH host.'), T('New host'), () => editHost({})));
c.append(h(`${T('Open host in SFTP browser')}
`));
const grid = h('
');
list.forEach((hst) => {
const card = h(``);
card.onclick = () => openSftp(hst);
grid.append(card);
});
c.append(grid);
}
class FilePane {
constructor(sftp, side) {
this.sftp = sftp; this.side = side; this.cwd = ''; this.files = []; this.sel = new Set(); this.sort = { k: 'name', dir: 1 };
this.el = h(``);
this.el.addEventListener('click', (e) => {
const p = e.target.closest('[data-p]')?.dataset.p;
if (p === 'up') this.up();
if (p === 'home') this.go(this.home);
if (p === 'refresh') this.go(this.cwd);
if (p === 'mkdir') this.mkdir();
if (p === 'xfer') this.transferSelected();
});
$('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); });
const files = $('.files', this.el);
files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } });
// Drag & Drop zwischen den Panes
this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } });
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; });
}
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
async init(home) {
this.home = home;
if (this.side === 'local' && api.platform === 'win32') {
const sel = $('.roots', this.el);
(await api.call('local:roots')).forEach((r) => sel.append(h(`${esc(r)} `)));
sel.onchange = () => this.go(sel.value);
}
return this.go(home);
}
async up() { this.go(await this.parent(this.cwd)); }
async go(dir) {
try {
const files = await this.list(dir);
this.cwd = dir; this.files = files; this.sel.clear();
$('.path', this.el).value = dir;
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 2).toUpperCase() + '\\';
this.draw();
} catch (e) { toast(e.message, 'error'); }
}
sorted() {
const { k, dir } = this.sort;
return [...this.files].filter((f) => f.name !== '.' && f.name !== '..').sort((a, b) => (b.isDir - a.isDir) || (k === 'name' ? a.name.localeCompare(b.name, undefined, { numeric: true }) : (a[k] - b[k])) * dir);
}
draw() {
const box = $('.files', this.el);
const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : '');
const rows = this.view = this.sorted();
box.innerHTML = `Name${arrow('name')} ${T('Size')}${arrow('size')} ${T('Modified')}${arrow('mtime')}
${ICONS.folder} ..
${rows.map((f, i) => `${f.isDir ? ICONS.folder : ICONS.file} ${esc(f.name)}
${f.isDir ? '' : fmtSize(f.size)} ${fmtDate(f.mtime)} `).join('')}
`;
$$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); }));
$('tr[data-up]', box).ondblclick = () => this.up();
$$('tr[data-i]', box).forEach((tr) => {
const f = rows[Number(tr.dataset.i)];
tr.onclick = (e) => {
if (e.ctrlKey) this.sel.has(f.name) ? this.sel.delete(f.name) : this.sel.add(f.name);
else if (e.shiftKey && this.lastIdx != null) { const [a, b] = [this.lastIdx, Number(tr.dataset.i)].sort((x, y) => x - y); rows.slice(a, b + 1).forEach((x) => this.sel.add(x.name)); }
else { this.sel.clear(); this.sel.add(f.name); }
this.lastIdx = Number(tr.dataset.i);
$$('tr[data-i]', box).forEach((r) => r.classList.toggle('sel', this.sel.has(rows[Number(r.dataset.i)].name)));
};
tr.ondblclick = async () => {
if (f.isDir) this.go(await this.join(this.cwd, f.name));
else if (this.side === 'local') api.call('local:op', 'open', await this.join(this.cwd, f.name));
else { this.sel = new Set([f.name]); this.transferSelected(); }
};
tr.ondragstart = (e) => { if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); } S.drag = { pane: this }; e.dataTransfer.effectAllowed = 'copy'; };
tr.oncontextmenu = (e) => {
e.preventDefault();
if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); this.draw(); }
ctxMenu(e.clientX, e.clientY, [
{ label: this.side === 'local' ? T('Upload') : T('Download'), icon: this.side === 'local' ? 'upload' : 'download', run: () => this.transferSelected() },
...(f.isDir ? [{ label: T('Open'), icon: 'folder', run: async () => this.go(await this.join(this.cwd, f.name)) }] : []),
{ label: T('Rename'), icon: 'edit', run: () => this.rename(f) },
{ label: T('Copy path'), icon: 'copy', run: async () => api.call('clipboard:write', await this.join(this.cwd, f.name)) },
...(this.side === 'remote' && !f.isDir ? [{ label: T('Permissions (chmod)…'), icon: 'lock', run: () => this.chmod(f) }] : []),
'-', { label: this.side === 'local' ? T('Move to trash') : T('Delete'), icon: 'trash', danger: true, run: () => this.remove() },
]);
};
});
}
async mkdir() {
const n = await promptBox(T('New folder'), T('Name')); if (!n) return;
const p = await this.join(this.cwd, n);
try { this.side === 'local' ? await api.call('local:op', 'mkdir', p) : await api.call('sftp:op', this.sftp.id, 'mkdir', p); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async rename(f) {
const n = await promptBox(T('Rename'), T('New name'), f.name); if (!n || n === f.name) return;
const a = await this.join(this.cwd, f.name), b = await this.join(this.cwd, n);
try { this.side === 'local' ? await api.call('local:op', 'rename', a, b) : await api.call('sftp:op', this.sftp.id, 'rename', a, b); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async chmod(f) {
const m = await promptBox(T('Change permissions'), T('Octal (e.g. 644)'), (f.mode & 0o777).toString(8)); if (!m) return;
try { await api.call('sftp:op', this.sftp.id, 'chmod', await this.join(this.cwd, f.name), parseInt(m, 8)); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async remove() {
const names = [...this.sel]; if (!names.length) return;
if (!(await confirmBox(T('Delete?'), T(this.side === 'local' ? 'Move {n} item(s) to the trash: {list}' : 'Permanently delete {n} item(s) from the server: {list}', { n: names.length, list: names.slice(0, 5).join(', ') + (names.length > 5 ? ' …' : '') })))) return;
for (const n of names) {
const f = this.files.find((x) => x.name === n);
const p = await this.join(this.cwd, n);
try {
if (this.side === 'local') await api.call('local:op', 'delete', p);
else await api.call('sftp:op', this.sftp.id, f.isDir ? 'rmdir' : 'unlink', p);
} catch (e) { toast(`${n}: ${e.message}`, 'error'); }
}
this.go(this.cwd);
}
async transferSelected() {
const names = [...this.sel];
if (!names.length) return toast(T('Nothing selected'));
const target = this.other;
for (const n of names) {
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
const dir = this.side === 'local' ? 'upload' : 'download';
await this.sftp.transfer(dir, dir === 'upload' ? src : dst, dir === 'upload' ? dst : src, n);
}
target.go(target.cwd);
}
}
class SftpSession {
constructor(host) {
this.kind = 'sftp'; this.id = uid(); this.host = host;
this.title = `SFTP · ${host.label || host.address}`;
this.el = h(``);
this.local = new FilePane(this, 'local');
this.remote = new FilePane(this, 'remote');
const wrap = $('.sftp', this.el);
wrap.append(this.local.el, h(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`));
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
this.xfers = new Map();
addTab(this);
this.open();
}
async open() {
this.local.init(await api.call('local:home'));
try {
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
await this.remote.init(home);
setTabState(this, 'on');
} catch (e) {
setTabState(this, 'err');
const pe = $('.pane-empty', this.el);
pe.innerHTML = `${esc(e.message)}
`;
const b = h(`${ICONS.refresh}${T('Try again')} `);
b.onclick = () => { closeTab(this.id); openSftp(this.host); };
pe.append(b);
}
}
async transfer(dir, localPath, remotePath, name) {
const tid = uid();
const row = h(`${dir === 'upload' ? ICONS.upload : ICONS.download} ${esc(name)} 0%
`);
$('.transfers', this.el).prepend(row);
this.xfers.set(tid, row);
try {
await api.call('sftp:transfer', this.id, dir, localPath, remotePath, tid);
row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done');
setTimeout(() => row.remove(), 5000);
} catch (e) {
row.classList.add('fail'); $('.pct', row).textContent = T('Error'); row.title = e.message; toast(`${name}: ${e.message}`, 'error');
setTimeout(() => row.remove(), 10000);
}
}
progress(tid, done, total) {
const row = this.xfers.get(tid); if (!row) return;
const p = total ? Math.round((done / total) * 100) : 0;
$('.bar i', row).style.width = p + '%';
$('.pct', row).textContent = `${p}% · ${fmtSize(done)} / ${fmtSize(total)}`;
}
dispose() { this.unsub(); api.call('sftp:close', this.id).catch(() => {}); }
}
function openSftp(host) { return new SftpSession(host); }
// Gespeicherte Hosts per ID übergeben; Container-Shells mit Zusatzbefehl
const hostRef = (host) => (host.id ? (host.execCommand ? { ref: host.id, execCommand: host.execCommand, label: host.label } : host.id) : host);
// ============================================================ Docker (Container über SSH)
class DockerSession {
constructor(host) {
this.kind = 'docker'; this.id = uid(); this.host = host;
this.title = `Docker · ${host.label || host.address}`;
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
this.el = h(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`);
this.body = $('.docker-body', this.el);
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); });
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this);
this.open();
}
async open() {
try {
this.apply(await api.call('docker:open', this.id, hostRef(this.host)));
setTabState(this, 'on');
this.loadStats();
this.timer = setInterval(() => { if (S.active === this.id && !$('.modal-bg')) this.refresh(); }, 10000);
} catch (e) {
setTabState(this, 'err');
this.error(e.message);
}
}
error(msg) {
this.body.innerHTML = ``;
const b = h(`${ICONS.refresh}${T('Try again')} `);
b.onclick = () => { closeTab(this.id); openDocker(this.host); };
$('.pane-empty', this.body).append(b);
}
apply(r) {
this.containers = r.containers;
$('.rt', this.el).textContent = ` · ${r.runtime === 'podman' ? 'Podman' : 'Docker'}${r.sudo ? ' (sudo)' : ''}`;
this.draw();
}
async refresh(withStats) {
if (this.busy) return;
this.busy = true;
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); }
this.busy = false;
}
async loadStats() {
try { this.stats = await api.call('docker:stats', this.id); this.draw(); } catch {}
}
draw() {
const running = (c) => c.state === 'running';
const list = this.containers
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
.sort((a, b) => (running(b) - running(a)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
if (!this.containers.length) { this.body.innerHTML = `${T('No containers on this host.')}
`; return; }
const scroll = this.body.scrollTop;
this.body.innerHTML = `${T('Name')} ${T('Status')} ${T('Ports')} CPU ${T('Memory')}
${list.map((c) => {
const st = this.stats[c.id];
const on = running(c);
return `
${esc(c.name)} ${esc(c.image)}
${esc(c.status)} ${esc(c.ports)}
${on && st ? esc(st.cpu) : ''} ${on && st ? esc(st.mem.split('/')[0].trim()) : ''}
${on ? `${ICONS.term} ` : ''}
${ICONS.logs}
${on ? `${ICONS.refresh} ${ICONS.stop} `
: `${ICONS.play} ${ICONS.trash} `}
`;
}).join('')}
`;
this.body.scrollTop = scroll;
$$('tr[data-id]', this.body).forEach((tr) => {
const c = this.containers.find((x) => x.id === tr.dataset.id);
tr.onclick = (e) => { const a = e.target.closest('[data-c]')?.dataset.c; if (a) this.act(a, c, e.target.closest('button')); };
tr.ondblclick = (e) => { if (!e.target.closest('[data-c]') && running(c)) this.act('shell', c); };
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(running(c) ? [{ label: T('Open shell'), icon: 'term', run: () => this.act('shell', c) }] : []),
{ label: T('Logs'), icon: 'logs', run: () => this.act('logs', c) },
'-',
...(running(c) ? [{ label: T('Restart'), icon: 'refresh', run: () => this.act('restart', c) }, { label: T('Stop'), icon: 'stop', run: () => this.act('stop', c) }]
: [{ label: T('Start'), icon: 'play', run: () => this.act('start', c) }]),
{ label: T('Copy ID'), icon: 'copy', run: () => api.call('clipboard:write', c.id) },
'-',
{ label: T('Delete'), icon: 'trash', danger: true, run: () => this.act('remove', c) },
]);
});
}
async act(a, c, btn) {
if (a === 'shell' || a === 'logs') {
const cmd = await call('docker:command', this.id, a, c.id);
openTerminal({ ...this.host, execCommand: cmd, label: `${a === 'logs' ? T('Logs') : '🐳'} ${c.name}` });
return;
}
if (a === 'remove' && !(await confirmBox(T('Delete container?'), T('“{name}” will be removed including its writable layer. Volumes are kept.', { name: c.name })))) return;
if (a === 'stop' && !(await confirmBox(T('Stop container?'), c.name, T('Stop')))) return;
if (btn) { btn.disabled = true; btn.innerHTML = ' '; }
try { await call('docker:action', this.id, a, c.id); toast(T('{action}: {name}', { action: { start: T('Started'), stop: T('Stopped'), restart: T('Restarted'), remove: T('Deleted') }[a], name: c.name }), 'ok'); } catch {}
await this.refresh(true);
}
onShow() { if (this.containers.length) this.refresh(); }
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
}
function openDocker(host) { return new DockerSession(host); }
// ============================================================ 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(`
${avatar(host, 22)}${esc(hostSub(host))}
${ICONS.download}${T('Save permanently')}
${ICONS.refresh}
${ICONS.plus}${T('Add rule')}
${esc(T('Connecting to {host} …', { host: host.address }))}
`);
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(`${l} `); 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 = ``;
const b = h(`${ICONS.refresh}${T('Try again')} `);
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(`${on ? T('active') : T('inactive')}
`);
const tgl = h(`${on ? T('Disable') : T('Enable')} `);
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(`${dir === 'incoming' ? T('Incoming') : T('Outgoing')}${['allow', 'deny', 'reject'].map((p) => `${{ allow: T('Allow'), deny: T('Deny'), reject: T('Reject') }[p]} `).join('')} `);
$('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(`${ICONS.wall}${T('UFW is disabled')} ${T('Enable UFW to view or add rules.')}
`));
return;
}
if (!d.rules.length) { this.body.append(h(``)); return; }
const t = h(`# ${T('To')} ${T('Action')} ${T('From')} ${T('Comment')}
`);
for (const r of d.rules) {
const tr = h(`${r.num} ${esc(r.to)} ${esc(r.action)} ${esc(r.dir)} ${esc(r.from)} ${esc(r.comment)}
${ICONS.trash} `);
$('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(`${T('Changes apply immediately but are lost after a reboot unless you click “Save permanently”.')}
`));
for (const c of chains) {
const sec = h(`${esc(c.name)} ${c.rules.length} ${T('rules')}
`);
if (c.policy && builtin.includes(c.name)) {
const sel = h(`${T('Policy')}${['ACCEPT', 'DROP'].map((p) => `${p} `).join('')} `);
$('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(``);
for (const r of c.rules) {
const tgt = (r.spec.match(/-j (\S+)/) || [])[1] || '';
const tr = h(`${r.num} ${esc(r.spec)} ${esc(tgt)}
${ICONS.trash} `);
$('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: `
${T('Action')} ${T('Allow')} ${T('Deny')} ${T('Reject')} ${T('Limit (rate-limit)')}
${T('Direction')} ${T('Incoming')} ${T('Outgoing')}
${T('From (IP or network)')}
${T('Comment')}
${T('Insert at the top (highest priority)')} `,
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: `
${T('Chain')} ${chains.map((c) => `${esc(c)} `).join('')}
${T('Target')} ACCEPT DROP REJECT LOG RETURN
${T('Connection state')} ${T('Any')} NEW ESTABLISHED,RELATED
${T('Comment')}
${T('Insert at the top (highest priority)')} `,
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(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`);
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 = ``;
const b = h(`${ICONS.refresh}${T('Try again')} `);
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() {
try { this.apply(await api.call('network:read', this.id)); } catch (e) { toast(e.message, 'error'); }
}
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('
');
if (d.rolledBack) wrap.append(h(`${ICONS.refresh}${esc(T('The last network change was rolled back automatically ({time}) because MrTerm could not reconnect.', { time: d.rolledBack }))}
`));
if (!d.editable) wrap.append(h(`${ICONS.network}${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 }))}
`));
// System: Hostname, Gateway, DNS
const gw = d.routes.map((r) => `${esc(r.via || '—')} (${esc(r.dev)}) `).join(' ') || `${T('none')} `;
const sys = h(`
${T('Hostname')} ${esc(d.hostname)} ${ICONS.edit}
${T('Default gateway')} ${gw}
${T('DNS servers')} ${esc(d.dns.servers.join(', ') || '—')}${d.dns.search.length ? `${T('Search')}: ${esc(d.dns.search.join(' '))} ` : ''}${d.dns.editable && !d.dns.servers.includes('127.0.0.53') ? `${ICONS.edit} ` : ''}
${d.dns.servers.includes('127.0.0.53') ? `
${T('systemd-resolved is used: set DNS servers per interface.')}
` : ''}
`);
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('
');
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 ? `${T('Not configured')} `
: c.method4 === 'dhcp' ? 'DHCP' : c.method4 === 'static' ? `${T('Static')} ${esc(c.addresses.join(', '))}${c.gateway ? ` → ${esc(c.gateway)}` : ''}` : `${T('No IPv4')} `;
const b = i.bonding;
const bondInfo = b ? `${T('Mode')}: ${esc(b.mode || '')}${b.lacpRate ? ` · LACP ${esc(b.lacpRate)}` : ''}${b.hashPolicy ? ` · ${esc(b.hashPolicy)}` : ''}
${b.slaves.map((s) => `
${esc(s.name)} ${esc(s.speed || '')}${s.aggregator ? ` · Agg ${esc(s.aggregator)}` : ''}
`).join('')}
` : '';
const el = h(`
${esc(i.name)} ${NET_KINDS[kind] || esc(i.kind || '')}
${i.master ? `→ ${esc(i.master)} ` : ''}
${this.data.editable ? `${ICONS.edit} ` : ''}
${this.data.editable && c && kind !== 'ethernet' ? `${ICONS.trash} ` : ''}
${i.mac ? esc(i.mac) : ''}${i.mtu ? ` · MTU ${i.mtu}` : ''}${i.state === 'ABSENT' ? T('Configured, but not present') : ''}
${i.addrs.map((a) => `
${esc(a.addr)}${a.dynamic ? ' DHCP ' : ''}
`).join('') || `
${T('No address')}
`}
${bondInfo}
${T('Configured')}: ${summary}
`);
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(`
${esc(label)}
${T('MrTerm reconnects to confirm the change. Without confirmation the server restores the previous configuration after 90 seconds.')}
`);
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(); }
else {
setTabState(this, 'err');
this.body.innerHTML = `${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}
`;
const b = h(`${ICONS.refresh}${T('Reconnect')} `);
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: `${T('The change is applied immediately. If MrTerm cannot reconnect within 90 seconds, the server restores the previous configuration automatically.')}
`,
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('
');
phys.filter(filter).forEach((x) => box.append(h(` ${esc(x)}${esc(d.interfaces.find((i) => i.name === x)?.mac || '')} `)));
if (!box.children.length) box.append(h(`${T('No suitable interfaces found.')}
`));
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 ., 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(`${T('Members')} `), 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(`${T('Ports')} `), 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('
');
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('
');
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: `${T('DNS servers')}
${T('Search domains')}
${T('Written to /etc/resolv.conf.')}
`,
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(``);
const inp = $('input', bg), res = $('.res', bg);
let items = [], idx = 0;
const close = () => bg.remove();
const draw = () => {
const q = inp.value.trim().toLowerCase();
items = [];
const quick = parseQuick(inp.value);
if (q && quick) items.push({ grp: 'Quick Connect', icon: avatar({ ...quick, label: quick.address }), label: `${quick.protocol.toUpperCase()} → ${inp.value.trim()}`, run: () => connectHost(quick) });
const hosts = S.vault.hosts.filter((x) => !q || [x.label, x.address, x.username, ...(x.tags || [])].some((f) => String(f || '').toLowerCase().includes(q)));
const recent = S.vault.history.map((x) => x.hostId);
hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999)));
hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `${ICONS.folder}
`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `${ICONS.docker}
`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `${ICONS.wall}
`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `${ICONS.network}
`, 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: `${ICONS.code}
`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: T('Actions'), icon: `${ICONS.plus}
`, label: l, run }));
idx = Math.min(idx, Math.max(0, items.length - 1));
let last = '';
res.innerHTML = '';
items.forEach((it, i) => {
if (it.grp !== last) { res.append(h(`${esc(it.grp)}
`)); last = it.grp; }
const el = h(`${it.icon}
${i === idx ? '
↵ ' : ''}
`);
el.onclick = () => { close(); it.run(); };
el.onmousemove = () => { if (idx !== i) { idx = i; draw(); } };
res.append(el);
});
$('.item.active', res)?.scrollIntoView({ block: 'nearest' });
};
inp.oninput = () => { idx = 0; draw(); };
inp.onkeydown = (e) => {
if (e.key === 'ArrowDown') { idx = Math.min(items.length - 1, idx + 1); draw(); e.preventDefault(); }
if (e.key === 'ArrowUp') { idx = Math.max(0, idx - 1); draw(); e.preventDefault(); }
if (e.key === 'Enter' && items[idx]) { close(); items[idx].run(); }
if (e.key === 'Escape') close();
};
bg.onmousedown = (e) => { if (e.target === bg) close(); };
$('#modalRoot').append(bg);
draw();
inp.focus();
}
// ============================================================ Globale Events
$('#newTabBtn').onclick = openPalette;
$('#winMin').onclick = api.win.min;
$('#winMax').onclick = api.win.max;
$('#winClose').onclick = api.win.close;
$('#titlebar').addEventListener('dblclick', (e) => { if (e.target.classList.contains('drag-fill') || e.target.id === 'titlebar') api.win.max(); });
if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none';
document.addEventListener('keydown', (e) => {
if (!e.ctrlKey) return;
const inTerm = !!e.target.closest?.('.xterm');
if ($('.lock-screen')) return;
if (e.shiftKey && e.code === 'KeyL') { e.preventDefault(); lockNow(); }
else if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
else if (e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); }
else if (e.code === 'Tab') {
e.preventDefault();
const ids = ['home', ...S.tabs.map((t) => t.id)];
const i = ids.indexOf(S.active);
activateTab(ids[(i + (e.shiftKey ? -1 : 1) + ids.length) % ids.length]);
} else if (/^Digit[1-9]$/.test(e.code)) {
e.preventDefault();
const ids = ['home', ...S.tabs.map((t) => t.id)];
const n = Number(e.code.slice(5));
activateTab(n === 9 ? ids[ids.length - 1] : ids[n - 1] || ids[ids.length - 1]);
}
});
api.on('secret:request', async (req) => {
const r = await modal({
title: req.title || T('Authentication'),
text: req.host,
body: `${esc(req.prompt || T('Password'))}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Log in'), value: 'form', cls: 'primary' }],
});
api.replySecret(req.reqId, r ? r.v : null);
});
api.on('fw:event', () => reload());
api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch(() => ({})); if (S.view === 'vpns' && S.active === 'home') render(); });
api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? ' ' : ' '; });
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);