/* global Terminal, FitAddon, WebLinksAddon, SearchAddon, TERM_THEMES */ 'use strict'; // ============================================================ Helpers const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // crypto.randomUUID gibt es nur in sicheren Kontexten (HTTPS/localhost) – die Web-Version läuft oft per http:// 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: '', plus: '', edit: '', trash: '', more: '', close: '', folder: '', file: '', term: '', screen: '', key: '', copy: '', play: '', stop: '', up: '', refresh: '', home: '', newdir: '', arrowR: '', arrowL: '', code: '', shield: '', lock: '', unlock: '', eye: '', fwd: '', dup: '', clock: '', download: '', upload: '', docker: '', wall: '', network: '', ai: '', logs: '', }; const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100']; function colorFor(h) { if (h.color) return h.color; let n = 0; for (const c of h.label || h.address || '') n = (n * 31 + c.charCodeAt(0)) >>> 0; return COLORS[n % COLORS.length]; } function avatar(hst, size) { const letter = esc((hst.label || hst.address || '?').trim()[0]?.toUpperCase() || '?'); const proto = hst.protocol === 'rdp' ? 'RDP' : 'SSH'; return `
${hst.protocol === 'rdp' ? ICONS.screen : letter}${proto}
`; } function fmtSize(n) { if (n == null) return ''; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; } const fmtDate = (t) => (t ? new Date(t).toLocaleString(I18N.lang, { dateStyle: 'short', timeStyle: 'short' }) : ''); function relTime(t) { const s = (Date.now() - t) / 1000; if (s < 60) return T('just now'); if (s < 3600) return T('{n} min ago', { n: Math.floor(s / 60) }); if (s < 86400) return T('{n} h ago', { n: Math.floor(s / 3600) }); return T('{n} days ago', { n: Math.floor(s / 86400) }); } function toast(msg, type = '') { const el = h(`
${esc(msg)}
`); $('#toasts').append(el); setTimeout(() => el.remove(), type === 'error' ? 7000 : 3500); } async function call(ch, ...a) { try { return await api.call(ch, ...a); } catch (e) { toast(e.message, 'error'); throw e; } } // ---------- Modals function modal({ title, text = '', body = '', buttons = [], noEnter = false }) { return new Promise((resolve) => { const bg = h(``); const foot = $('.mfoot', bg); const done = (v) => { bg.remove(); resolve(v); }; buttons.forEach((b) => { const btn = h(``); 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: `
`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('OK'), value: 'form', cls: 'primary' }] }); return r ? r.v : null; } // ---------- Kontextmenü function ctxMenu(x, y, items) { $$('.ctx').forEach((c) => c.remove()); const m = h('
'); for (const it of items) { if (it === '-') { m.append(h('
')); continue; } const b = h(``); 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(); initAi(); const b = $('#vaultBadge'); b.innerHTML = S.vault.encrypted ? `${ICONS.lock}${T('Vault encrypted')}` : `${ICONS.unlock}${T('Vault not encrypted')}`; b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).'); applyAppTheme(); S.activeForwards = new Set(await call('fw:active')); if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({})); render(); } // Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title) function applyStatic() { document.documentElement.lang = I18N.lang; $$('[data-i18n]').forEach((el) => { el.textContent = T(el.dataset.i18n); }); $$('[data-i18n-title]').forEach((el) => { el.title = T(el.dataset.i18nTitle); }); // Tooltips für die eingeklappte Symbolansicht $$('#navMenu .nav').forEach((n) => { n.title = n.querySelector('span')?.textContent || ''; }); $('#navToggle').title = $('#sidebar').classList.contains('nav-collapsed') ? T('Expand menu') : T('Collapse menu'); } // ============================================================ Navigation $$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => { S.view = n.dataset.view; $$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x === n)); closeDrawer(); activateTab('home'); render(); })); // ============================================================ Einklappbares Menü (Sidebar) (() => { const KEY = 'mrterm.nav.collapsed'; const sb = $('#sidebar'); const apply = (collapsed) => { sb.classList.toggle('nav-collapsed', collapsed); $('#navToggle').title = collapsed ? T('Expand menu') : T('Collapse menu'); }; let collapsed = false; try { collapsed = localStorage.getItem(KEY) === '1'; } catch {} apply(collapsed); $('#navToggle').onclick = () => { collapsed = !collapsed; apply(collapsed); try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch {} }; })(); // ============================================================ Verzeichnisbaum (Sidebar) const TREE_KEY = 'mrterm.tree.open'; const treeOpen = new Set((() => { try { return JSON.parse(localStorage.getItem(TREE_KEY) || '[]'); } catch { return []; } })()); const saveTreeOpen = () => { try { localStorage.setItem(TREE_KEY, JSON.stringify([...treeOpen])); } catch {} }; const CHEV = ''; function goGroup(id) { S.view = 'hosts'; S.group = id; S.search = ''; S.tag = null; $$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x.dataset.view === 'hosts')); closeDrawer(); activateTab('home'); render(); } async function moveHostToGroup(hostId, groupId) { const hst = S.vault.hosts.find((x) => x.id === hostId); if (!hst || (hst.groupId || null) === groupId) return; await call('vault:upsert', 'hosts', { ...hst, groupId }); if (groupId) { treeOpen.add(groupId); saveTreeOpen(); } reload(); } function dropTarget(el, groupId) { el.addEventListener('dragover', (e) => { if (e.dataTransfer.types.includes('mrterm/host')) { e.preventDefault(); el.classList.add('drop'); } }); el.addEventListener('dragleave', () => el.classList.remove('drop')); el.addEventListener('drop', (e) => { e.preventDefault(); el.classList.remove('drop'); const id = e.dataTransfer.getData('mrterm/host'); if (id) moveHostToGroup(id, groupId); }); } function renderTree() { const box = $('#tree'); if (!box || !S.vault) return; const scroll = box.scrollTop; box.innerHTML = ''; const { groups, hosts } = S.vault; const byLabel = (x, y) => (x.label || x.address || '').localeCompare(y.label || y.address || '', undefined, { numeric: true }); const countIn = (gid) => hosts.filter((x) => x.groupId === gid).length + groups.filter((g) => g.parentId === gid).reduce((n, g) => n + countIn(g.id), 0); const head = h(`
${T('Connections')}
`); $('button', head).onclick = () => { treeOpen.clear(); saveTreeOpen(); renderTree(); }; dropTarget(head, null); // auf die Überschrift ziehen = Stammebene box.append(head); if (!groups.length && !hosts.length) { box.append(h(`
${T('No hosts yet')}
`)); return; } const pad = (depth) => `padding-left:${4 + depth * 14}px`; const walk = (parentId, depth) => { for (const g of groups.filter((x) => (x.parentId || null) === parentId).sort(byLabel)) { const open = treeOpen.has(g.id); const active = S.view === 'hosts' && S.group === g.id && S.active === 'home'; const row = h(`
${CHEV}${ICONS.folder}${esc(g.label)}${countIn(g.id)}
`); const toggle = () => { if (open) treeOpen.delete(g.id); else treeOpen.add(g.id); saveTreeOpen(); renderTree(); }; $('.chev', row).onclick = (e) => { e.stopPropagation(); toggle(); }; row.onclick = () => { if (!open) { treeOpen.add(g.id); saveTreeOpen(); } goGroup(g.id); }; row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ { label: T('Open'), icon: 'folder', run: () => goGroup(g.id) }, { label: T('Add host'), icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } }, { label: T('Create subfolder'), icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } }, { label: T('Edit'), icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } }, '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } }, ]); dropTarget(row, g.id); box.append(row); if (open) walk(g.id, depth + 1); } for (const x of hosts.filter((y) => (y.groupId || null) === parentId).sort(byLabel)) { const live = S.tabs.some((t) => t.host?.id === x.id); const row = h(`
${x.protocol === 'rdp' ? ICONS.screen : ICONS.term}${esc(x.label || x.address)}${live ? '●' : ''}
`); row.ondblclick = () => connectHost(x); row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(x)); row.ondragstart = (e) => { e.dataTransfer.setData('mrterm/host', x.id); e.dataTransfer.effectAllowed = 'move'; }; box.append(row); } }; walk(null, 0); box.scrollTop = scroll; } function render() { const page = $('#homeView'); const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, vpns: viewVpns, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings }; const scroll = page.scrollTop; page.innerHTML = ''; views[S.view](page); page.scrollTop = scroll; renderTree(); } function toolbar(placeholder, buttons = []) { const t = h(`
`); 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(``); el.onclick = b.run; t.append(el); }); return t; } const matches = (...fields) => { const q = S.search.trim().toLowerCase(); return !q || fields.some((f) => String(f || '').toLowerCase().includes(q)); }; function emptyState(icon, title, text, btnLabel, run) { const e = h(`
${ICONS[icon]}

${esc(title)}

${esc(text)}

`); if (btnLabel) { const b = h(``); b.onclick = run; e.append(b); } return e; } // ============================================================ Hosts function hostSub(hst) { const u = hst.username ? `${hst.username}@` : ''; const port = hst.port && Number(hst.port) !== (hst.protocol === 'rdp' ? 3389 : 22) ? `:${hst.port}` : ''; return `${hst.protocol === 'rdp' ? 'rdp' : 'ssh'}, ${u}${hst.address}${port}`; } function viewHosts(page) { page.append(toolbar(T('Search hosts or enter user@host …'), [ { label: T('Group'), icon: 'newdir', run: () => editGroup({ parentId: S.group }) }, { label: T('New host'), icon: 'plus', cls: 'primary', run: () => editHost({ groupId: S.group }) }, ])); const c = h('
'); page.append(c); const { groups, hosts } = S.vault; const searching = !!S.search.trim(); if (S.group) { const trail = []; let g = groups.find((x) => x.id === S.group); while (g) { trail.unshift(g); g = groups.find((x) => x.id === g.parentId); } const bc = h(''); const root = h(`${esc(T('All hosts'))}`); root.onclick = () => { S.group = null; render(); }; bc.append(root); trail.forEach((t) => { bc.append(' / '); const a = h(`${esc(t.label)}`); a.onclick = () => { S.group = t.id; render(); }; bc.append(a); }); c.append(bc); } const allTags = [...new Set(hosts.flatMap((x) => x.tags || []))].sort(); if (allTags.length) { const ch = h('
'); allTags.forEach((t) => { const b = h(``); b.onclick = () => { S.tag = S.tag === t ? null : t; render(); }; ch.append(b); }); c.append(ch); } const subGroups = searching ? groups.filter((g) => matches(g.label)) : groups.filter((g) => (g.parentId || null) === S.group); if (subGroups.length && !S.tag) { c.append(h(`
${T('Groups')} ${subGroups.length}
`)); const grid = h('
'); for (const g of subGroups) { const count = hosts.filter((x) => x.groupId === g.id).length; const card = h(`
${ICONS.folder}
${esc(g.label)}
${count} Host${count === 1 ? '' : 's'}
`); card.onclick = (e) => { if (e.target.closest('[data-a=edit]')) return editGroup(g); S.group = g.id; S.search = ''; render(); }; card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ { label: T('Edit'), icon: 'edit', run: () => editGroup(g) }, { label: T('Add host'), icon: 'plus', run: () => editHost({ groupId: g.id }) }, '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } }, ]); grid.append(card); } c.append(grid); } let list = hosts.filter((x) => (searching || S.tag ? true : (x.groupId || null) === S.group)); list = list.filter((x) => matches(x.label, x.address, x.username, ...(x.tags || []))); if (S.tag) list = list.filter((x) => (x.tags || []).includes(S.tag)); list.sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address)); if (list.length) { c.append(h(`
Hosts ${list.length}
`)); const grid = h('
'); list.forEach((hst) => grid.append(hostCard(hst))); c.append(grid); } else if (!subGroups.length) { const q = S.search.trim(); if (q && /^[^\s@]+@[^\s]+$|^[\w.-]+(:\d+)?$/.test(q)) { c.append(emptyState('term', T('Connect to {q}?', { q }), T('Quick Connect without saving the host.'), T('Connect'), () => quickConnect(q))); } else if (!hosts.length) { const e = emptyState('screen', T('No hosts yet'), T('Add your first SSH or RDP host or import ~/.ssh/config.'), T('New host'), () => editHost({})); const imp = h(``); imp.onclick = importSshConfig; e.append(imp); c.append(e); } else c.append(emptyState('search', T('No results'), T('Adjust the search or filters.'))); } } function hostCard(hst) { const tags = (hst.tags || []).length ? `
${hst.tags.map((t) => `${esc(t)}`).join('')}
` : ''; const card = h(`
${avatar(hst)}
${esc(hst.label || hst.address)}
${esc(hostSub(hst))}
${tags}
${hst.protocol !== 'rdp' ? `` : ''}
`); 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('
'); const hd = h(`

${esc(title)}

`); $('button', hd).onclick = closeDrawer; const ft = h(''); if (extraFooter) ft.prepend(extraFooter); const cancel = h(``); cancel.onclick = closeDrawer; const save = h(``); 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 = ``; else if (type === 'textarea') input = ``; else if (type === 'password') input = `
`; else input = ``; const el = h(`
${input}${hint ? `
${esc(hint)}
` : ''}
`); const eye = $('.pw-wrap button', el); if (eye) eye.onclick = () => { const i = $('input', el); i.type = i.type === 'password' ? 'text' : 'password'; }; return el; } const row = (...els) => { const r = h('
'); r.append(...els); return r; }; const check = (label, name, checked) => h(``); const heading = (t) => h(`

${esc(t)}

`); function formValues(root) { const o = {}; $$('[name]', root).forEach((i) => { o[i.name] = i.type === 'checkbox' ? i.checked : i.value; }); return o; } function colorPicker(current) { const el = h(`
`); const wrap = $('.colors', el); ['', ...COLORS].forEach((c) => { const b = h(``); b.onclick = () => { $('input', wrap).value = c; $$('button', wrap).forEach((x) => x.classList.toggle('active', x === b)); }; wrap.append(b); }); return el; } function groupOptions() { const out = [['', T('— No group —')]]; const walk = (parent, depth) => S.vault.groups.filter((g) => (g.parentId || null) === parent).forEach((g) => { out.push([g.id, `${' '.repeat(depth)}${g.label}`]); walk(g.id, depth + 1); }); walk(null, 0); return out; } function editHost(hst = {}) { let proto = hst.protocol || 'ssh'; const isNew = !hst.id; const seg = h(`
`); const sshBox = h('
'); const rdpBox = h('
'); const portField = field('Port', 'port', hst.port || '', { type: 'number', placeholder: proto === 'rdp' ? '3389' : '22', cls: 'small' }); const setProto = (p) => { proto = p; $$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.p === p)); sshBox.style.display = p === 'ssh' ? '' : 'none'; rdpBox.style.display = p === 'rdp' ? '' : 'none'; $('input', portField).placeholder = p === 'rdp' ? '3389' : '22'; }; $$('button', seg).forEach((b) => (b.onclick = () => setProto(b.dataset.p))); const keys = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])]; const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((x) => x.id !== hst.id && x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address])]; sshBox.append( field(T('SSH key'), 'keyId', hst.keyId, { type: 'select', options: keys }), check(T('Use SSH agent'), 'useAgent', hst.useAgent !== false), heading(T('Advanced')), field(T('Jump host (ProxyJump)'), 'jumpHostId', hst.jumpHostId, { type: 'select', options: jumps }), field(T('Startup command'), 'startupCommand', hst.startupCommand, { placeholder: T('e.g. tmux attach || tmux') }), field(T('Environment variables'), 'env', hst.env, { type: 'textarea', placeholder: 'LANG=de_DE.UTF-8', hint: T('One per line, KEY=VALUE (server must allow AcceptEnv).') }), ); rdpBox.append( field(T('Domain'), 'domain', hst.domain, { placeholder: 'optional' }), row(field(T('Width'), 'rdpWidth', hst.rdpWidth, { type: 'number', placeholder: 'auto' }), field(T('Height'), 'rdpHeight', hst.rdpHeight, { type: 'number', placeholder: 'auto' })), field(T('On resize'), 'rdpResize', hst.rdpResize || 'dynamic', { type: 'select', options: [['dynamic', T('Adjust resolution dynamically')], ['scale', T('Scale image (fixed resolution)')]] }), check(T('Fullscreen'), 'rdpFullscreen', hst.rdpFullscreen), check(T('Share clipboard'), 'rdpClipboard', hst.rdpClipboard !== false), check(T('Play audio'), 'rdpAudio', hst.rdpAudio !== false), check(T('Share local drives/home'), 'rdpDrives', hst.rdpDrives), ); const form = openDrawer(isNew ? T('New host') : T('Edit host'), [ seg, row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField), field(T('Label'), 'label', hst.label, { placeholder: T('My server') }), field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }), ...(S.vault.vpns.length ? [field(T('VPN'), 'vpnId', hst.vpnId, { type: 'select', options: [['', T('— No VPN —')], ...S.vault.vpns.map((v) => [v.id, v.label])], hint: T('Connected automatically before connecting to this host.') })] : []), field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }), heading(T('Authentication')), field(T('Username'), 'username', hst.username, { placeholder: 'root' }), field(T('Password'), 'password', hst.password, { type: 'password', placeholder: T('stored encrypted') }), sshBox, rdpBox, heading(T('Appearance')), colorPicker(hst.color), ], async () => { const v = formValues(form); if (!v.address.trim()) throw new Error(T('Please enter an address.')); const item = { ...hst, ...v, protocol: proto, address: v.address.trim(), port: v.port ? Number(v.port) : undefined, tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean), groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null, vpnId: v.vpnId || null, }; const savedHost = await call('vault:upsert', 'hosts', item); if (item.password) askShareSecret('hosts', savedHost, item.label || item.address); reload(); }, isNew ? null : (() => { const b = h(``); b.title = T('Delete'); b.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); closeDrawer(); reload(); } }; return b; })()); setProto(proto); return form; } function editGroup(g = {}) { const parents = groupOptions().filter(([id]) => id !== g.id); const form = openDrawer(g.id ? T('Edit group') : T('New group'), [ field('Name', 'label', g.label, { placeholder: T('Production') }), field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: parents }), colorPicker(g.color), ], async () => { const v = formValues(form); if (!v.label.trim()) throw new Error(T('Name is missing.')); await call('vault:upsert', 'groups', { ...g, ...v, parentId: v.parentId || null }); reload(); }); } // ============================================================ Keychain function viewKeys(page) { page.append(toolbar(T('Search keys …'), [ { label: T('Import'), icon: 'download', run: () => editKey({}, true) }, { label: T('Generate'), icon: 'plus', cls: 'primary', run: generateKey }, ])); const c = h('
'); page.append(c); const list = S.vault.keys.filter((k) => matches(k.label, k.publicKey)); if (!list.length) return c.append(emptyState('key', T('No keys'), T('Generate a new Ed25519 key or import an existing one.'), T('Generate key'), generateKey)); c.append(h(`
${T('Keys')} ${list.length}
`)); const grid = h('
'); for (const k of list) { const type = (k.publicKey || '').split(' ')[0] || 'key'; const used = S.vault.hosts.filter((x) => x.keyId === k.id).length; const card = h(`
${ICONS.key}
${esc(k.label)}
${esc(type)} · ${used} Host${used === 1 ? '' : 's'}
`); 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: `
`, 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(``); b.onclick = loadFile; return b; })()); async function loadFile() { const f = await call('key:pickFile'); if (!f) return; $('[name=privateKey]', form).value = f.content; if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name; if (f.publicKey) $('[name=publicKey]', form).value = f.publicKey; } if (pick) loadFile(); } // ============================================================ Snippets function viewSnippets(page) { page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }])); const c = h('
'); page.append(c); const list = S.vault.snippets.filter((s) => matches(s.label, s.command)); if (!list.length) { 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())); c.append(templateList()); return; } const l = h('
'); for (const s of list) { const card = h(`
${ICONS.code}
${esc(s.label)}
${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}
`); 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, templateList()); } 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(); }); } // Eingebaute Vorlagen (nicht im Vault). Parameter werden vor dem Ausführen abgefragt; build() liefert eine Zeile. // $S = sudo, außer man ist bereits root. const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`; const SUDO = 'S=$([ "$(id -u)" = 0 ] || echo sudo);'; const SNIPPET_TEMPLATES = [ { label: () => T('Authorize SSH key for a user'), desc: () => T('Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.'), params: () => [ S.vault.keys.some((k) => k.publicKey) ? field(T('Key'), 'key', '', { type: 'select', options: S.vault.keys.filter((k) => k.publicKey).map((k) => [k.id, k.label]) }) : field('Public Key', 'pub', '', { type: 'textarea', placeholder: 'ssh-ed25519 AAAA…' }), field(T('User'), 'user', 'root'), ], build: (v) => { const pub = (v.key ? S.vault.keys.find((k) => k.id === v.key)?.publicKey : v.pub || '').trim(); if (!pub) throw new Error(T('Public key is missing.')); if (!v.user.trim()) throw new Error(T('User is missing.')); return `${SUDO} u=${shq(v.user.trim())}; k=${shq(pub)}; d="$(getent passwd "$u" | cut -d: -f6)/.ssh"; ` + `$S install -d -m 700 -o "$u" -g "$(id -gn "$u")" "$d" && { $S grep -qxF "$k" "$d/authorized_keys" 2>/dev/null || echo "$k" | $S tee -a "$d/authorized_keys" >/dev/null; } ` + `&& $S chown "$u": "$d/authorized_keys" && $S chmod 600 "$d/authorized_keys" && echo "OK: $d/authorized_keys"`; }, }, { label: () => T('Install QEMU guest agent'), desc: () => T('Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).'), build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get install -y qemu-guest-agent; ` + 'elif command -v dnf >/dev/null; then $S dnf install -y qemu-guest-agent; elif command -v yum >/dev/null; then $S yum install -y qemu-guest-agent; ' + 'elif command -v pacman >/dev/null; then $S pacman -S --needed --noconfirm qemu-guest-agent; elif command -v zypper >/dev/null; then $S zypper -n install qemu-guest-agent; ' + 'elif command -v apk >/dev/null; then $S apk add qemu-guest-agent && $S rc-update add qemu-guest-agent && $S rc-service qemu-guest-agent start; fi; ' + 'command -v systemctl >/dev/null && { $S systemctl enable --now qemu-guest-agent 2>/dev/null || $S systemctl start qemu-guest-agent; }; ' + 'command -v systemctl >/dev/null && systemctl is-active qemu-guest-agent', }, { label: () => T('Use APT cache server'), desc: () => T('Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).'), params: () => [field(T('Proxy URL'), 'url', 'http://', { placeholder: 'http://192.168.1.10:3142' })], build: (v) => { const url = v.url.trim(); if (!/^https?:\/\/[^\s/]+/.test(url)) throw new Error(T('Invalid URL.')); return `${SUDO} echo ${shq(`Acquire::http::Proxy "${url}";`)} | $S tee /etc/apt/apt.conf.d/00proxy && $S apt-get update`; }, }, { label: () => T('Remove APT cache server'), desc: () => T('Removes the APT proxy setting again.'), build: () => `${SUDO} $S rm -f /etc/apt/apt.conf.d/00proxy && $S apt-get update`, }, { label: () => T('System update'), desc: () => T('Updates all packages with the system package manager.'), build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get -y full-upgrade; elif command -v dnf >/dev/null; then $S dnf -y upgrade; ` + 'elif command -v pacman >/dev/null; then $S pacman -Syu --noconfirm; elif command -v zypper >/dev/null; then $S zypper -n update; elif command -v apk >/dev/null; then $S apk upgrade --update; fi', }, ]; // Fragt die Parameter einer Vorlage ab und schickt den Befehl ins Terminal async function runTemplate(t, session) { const params = t.params ? t.params() : []; let v = {}; if (params.length) { const body = `

${esc(t.desc())}

${params.map((el) => el.outerHTML).join('')}`; v = await modal({ title: t.label(), body, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Run'), value: 'form', cls: 'primary' }] }); if (!v) return; } let command; try { command = t.build(v); } catch (e) { return toast(e.message, 'error'); } runSnippet({ command, autoRun: true }, session); } function templateList(session) { const d = h(`
${T('Templates')}
`); for (const t of SNIPPET_TEMPLATES) { const it = h(`
${esc(t.label())}
${esc(t.desc())}
`); it.onclick = () => runTemplate(t, session); $('.tpl-items', d).append(it); } return d; } function runSnippet(s, session) { const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh'); if (!t) return toast(T('No open terminal'), 'error'); t.sendText(s.command.replace(/\r?\n/g, '\r') + (s.autoRun !== false ? '\r' : '')); activateTab(t.id); t.term.focus(); } // ============================================================ Port Forwarding function viewForwards(page) { page.append(toolbar(T('Search rules …'), [{ label: T('New rule'), icon: 'plus', cls: 'primary', run: () => editForward() }])); const c = h('
'); page.append(c); const list = S.vault.forwards.filter((f) => matches(f.label, f.destHost)); if (!list.length) return c.append(emptyState('fwd', T('No port forwards'), T('Local (-L), remote (-R) or dynamic as SOCKS5 proxy (-D).'), T('New rule'), () => editForward())); const l = h('
'); for (const f of list) { const hst = S.vault.hosts.find((x) => x.id === f.hostId); const on = S.activeForwards.has(f.id); const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}` : f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}` : `${T('Local')} ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`; const card = h(`
${ICONS.fwd}
${esc(f.label || desc)} ${on ? T('active') : T('inactive')}
${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}
`); card.onclick = async (e) => { const a = e.target.closest('[data-a]')?.dataset.a; if (a === 'toggle') { const b = e.target.closest('button'); b.disabled = true; try { if (on) await call('fw:stop', f.id); else { await call('fw:start', f.id); toast(T('Forward active'), 'ok'); } } catch {} reload(); } else if (a === 'del') { if (await confirmBox(T('Delete rule?'), f.label || desc)) { await call('fw:stop', f.id); await call('vault:remove', 'forwards', f.id); reload(); } } else editForward(f); }; l.append(card); } c.append(l); } function editForward(f = {}) { const hosts = S.vault.hosts.filter((x) => x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address]); if (!hosts.length) return toast(T('Add an SSH host first.'), 'error'); const form = openDrawer(f.id ? T('Edit rule') : T('New forward'), [ field(T('Label'), 'label', f.label, { placeholder: 'Postgres Prod' }), field(T('Type'), 'type', f.type || 'local', { type: 'select', options: [['local', T('Local (-L)')], ['remote', 'Remote (-R)'], ['dynamic', T('Dynamic / SOCKS5 (-D)')]] }), field(T('Via host'), 'hostId', f.hostId, { type: 'select', options: hosts }), row(field(T('Bind address'), 'bindAddress', f.bindAddress, { placeholder: '127.0.0.1' }), field(T('Bind port'), 'bindPort', f.bindPort, { type: 'number', placeholder: '8080', cls: 'small' })), row(field(T('Target host'), 'destHost', f.destHost, { placeholder: 'localhost' }), field(T('Target port'), 'destPort', f.destPort, { type: 'number', placeholder: '5432', cls: 'small' })), ], async () => { const v = formValues(form); if (!v.bindPort) throw new Error(T('Bind port is missing.')); if (v.type !== 'dynamic' && !v.destPort) throw new Error(T('Target port is missing.')); await call('vault:upsert', 'forwards', { ...f, ...v }); reload(); }); } // ============================================================ VPN (WireGuard / OpenVPN) S.vpnStatus = {}; const VPN_TYPES = { wireguard: 'WireGuard', openvpn: 'OpenVPN' }; function viewVpns(page) { page.append(toolbar(T('Search VPNs …'), [ { label: T('Import'), icon: 'download', run: () => editVpn({}, true) }, { label: T('New VPN'), icon: 'plus', cls: 'primary', run: () => editVpn() }, ])); const c = h('
'); page.append(c); const list = S.vault.vpns.filter((v) => matches(v.label, VPN_TYPES[v.type])); if (!list.length) return c.append(emptyState('shield', T('No VPNs'), T('Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.'), T('New VPN'), () => editVpn())); const l = h('
'); for (const v of list) { const on = !!S.vpnStatus[v.id]; const n = S.vault.hosts.filter((x) => x.vpnId === v.id).length; const card = h(`
${ICONS.shield}
${esc(v.label)} ${on ? T('connected') : T('disconnected')}
${VPN_TYPES[v.type] || v.type} · ${n} Host${n === 1 ? '' : 's'}
`); card.onclick = async (e) => { const a = e.target.closest('[data-a]')?.dataset.a; if (a === 'toggle') { const b = e.target.closest('button'); b.disabled = true; b.innerHTML = `${on ? T('Disconnect') : T('Connecting …')}`; try { await call(on ? 'vpn:down' : 'vpn:up', v.id); if (!on) toast(T('VPN “{name}” connected', { name: v.label }), 'ok'); } catch {} S.vpnStatus = await call('vpn:status').catch(() => ({})); render(); } else if (a === 'del') { if (await confirmBox(T('Delete VPN?'), T('“{name}” will be removed. Assigned hosts will connect without VPN.', { name: v.label }))) { await call('vault:remove', 'vpns', v.id); reload(); } } else editVpn(v); }; l.append(card); } c.append(l); } function editVpn(v = {}, pick = false) { let type = v.type || 'wireguard'; const seg = h('
'); const ovpnBox = h('
'); ovpnBox.append( heading(T('Authentication')), field(T('Username'), 'username', v.username, { placeholder: T('optional') }), field(T('Password'), 'password', v.password, { type: 'password', placeholder: T('stored encrypted') }), ); const cfg = field(T('Configuration'), 'config', v.config, { type: 'textarea', placeholder: '[Interface]\nPrivateKey = …', hint: '' }); $('textarea', cfg).style.minHeight = '180px'; const setType = (t) => { type = t; $$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.t === t)); ovpnBox.style.display = t === 'openvpn' ? '' : 'none'; $('textarea', cfg).placeholder = t === 'openvpn' ? 'client\ndev tun\nremote vpn.example.com 1194\n…' : '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\n\n[Peer]\n…'; const hint = $('.hint', cfg) || cfg.appendChild(h('
')); hint.textContent = t === 'openvpn' ? T('Certificates and keys must be embedded in the .ovpn file (, , …).') : T('Contents of a WireGuard .conf file.'); }; $$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t))); // Hosts zuordnen const hostBox = h(`
`); const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address)); if (!sorted.length) hostBox.append(h(`
${T('No hosts yet')}
`)); sorted.forEach((x) => hostBox.append(h(``))); 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(``); 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}
${esc(id)}
${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}
`); $('.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)}
${esc(it.host.label || it.host.address)}
${esc(hostSub(it.host))} · ${relTime(it.at)}
`); 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(`

${T('Account')}

`); const draw = async () => { const box = $('.acc', card); let me; try { me = await webApi('/api/me'); } catch (e) { box.textContent = e.message; return; } box.innerHTML = `

${esc(T('Signed in as {name}', { name: me.name }))}${me.admin ? ` · ${esc(T('Administrator'))}` : ''}

`; const pw = h(``); pw.onclick = async () => { const r = await modal({ title: T('Change password'), body: `
`, 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(``); 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(`

${esc(T('Users'))}

`); for (const u of users.users) { const row = h(`
${esc(u.name)}
${u.admin ? esc(T('Administrator')) : esc(T('User'))}
`); if (u.id !== me.id) { const del = h(``); 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(``); add.onclick = async () => { const r = await modal({ title: T('Add user'), body: `
`, 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(`

${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(`

Design

`); for (const [id, t] of Object.entries(APP_THEMES)) { const p = h(`
${esc(T(t.name))}
`); 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 }); }); // ---- Support-Assistent (Ollama) const aiCard = h(`

${T('Support assistant')}

${T('Helps with errors in the Linux console, Docker, firewall and network via a local Ollama instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.')}

`); const aiUrl = field(T('Ollama server'), 'aiUrl', st.aiUrl || 'http://localhost:11434', { placeholder: 'http://localhost:11434' }); const aiModel = field(T('Model'), 'aiModel', st.aiModel, { type: 'select', options: [[st.aiModel || '', st.aiModel || T('– select –')]] }); const loadModels = async () => { const sel = $('select', aiModel); try { const list = await api.call('ai:models', $('input', aiUrl).value.trim()); sel.innerHTML = ''; if (!list.length) sel.append(new Option(T('No models installed (ollama pull …)'), '')); list.forEach((m) => sel.append(new Option(m, m))); sel.value = list.includes(st.aiModel) ? st.aiModel : list[0] || ''; if (sel.value !== (settings().aiModel || '')) save({ aiModel: sel.value }); } catch (e) { toast(e.message, 'error'); } }; const aiReload = h(``); aiReload.onclick = loadModels; $('input', aiUrl).onchange = () => save({ aiUrl: $('input', aiUrl).value.trim() }).then(loadModels); $('select', aiModel).onchange = () => save({ aiModel: $('select', aiModel).value }); aiCard.append(aiUrl, aiModel, aiReload); if (st.aiModel) loadModels(); const rdpCard = h(`

RDP

${T('Checking …')}
`); 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 dockerCard = h(`

Docker Compose

`); const dockerFields = h('
'); dockerFields.append( field(T('Stacks folder'), 'dockerStacksDir', st.dockerStacksDir || '/opt/stacks', { hint: T('Folder on the host in which each stack gets its own subfolder') }), field(T('Template source'), 'dockerTemplatesUrl', st.dockerTemplatesUrl || '', { placeholder: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', hint: T('URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.') }), ); dockerFields.addEventListener('change', () => save(formValues(dockerFields))); dockerCard.append(dockerFields); const dataCard = h(`

${T('Data')}

${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 gnome-keyring or kwallet.')}

`); const btns = [ ...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]), [T('Export backup'), exportBackup], [T('Import backup'), importBackup], ]; btns.forEach(([l, fn]) => { const b = h(``); 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(``); 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(``); 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')}
`); if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard); else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, 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 ? `` : ''}
`); 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(``); pwBtn.onclick = async () => { const r = await modal({ title: L.hasPassword ? T('Change password') : T('Set password'), body: `
`, 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(``); 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(``); 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(``); 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(``); 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); 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('
'); 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(``); 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(``); pair.onclick = openPairing; const now = h(``); 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(``); sec.onclick = chooseSharedSecrets; acts.append(pair, now, sec); card.append(acts); if (!st.firewall || syncFwDismissed()) 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).')}
`)); } // 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: `

${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(', ') })}

`, 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(`
${ICONS.wall}
${f.os === 'cachyos' ? T('CachyOS: allow the sync ports in the firewall') : T('Firewall active: allow the sync ports')}

${T('Otherwise other devices cannot find or reach this device. Run these commands once in a terminal:')}

${esc(fwCmds(f))}
`); 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: `

${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:')}

${esc(fwCmds(f))}
`, 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 = `

${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 …')}
${S.syncStatus?.firewall ? `
${T('No devices found? The firewall on this device may block them:')}
${esc(fwCmds(S.syncStatus.firewall))}
` : ''}`; for (const d of near) { const row = h(`
${ICONS.screen}
${esc(d.name)}
${esc(d.address)}${d.pairable ? '' : ` · ${T('not ready for pairing')}`}
`); const b = h(``); 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) => ``).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.')}

`; 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) => `
${avatar(e.host)}
${esc(e.host.label)}
${esc(e.folder.join(' / ') || T('Root level'))} · ${esc(hostSub(e.host))}
`).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(); } // ============================================================ Backup const BACKUP_ERRORS = () => ({ INVALID: T('This file is not a MrTerm backup.'), WRONG_PASSWORD: T('Wrong password.'), PASSWORD_REQUIRED: T('This backup is encrypted. Please enter the password.') }); const backupError = (e) => BACKUP_ERRORS()[e.message] || e.message; async function exportBackup() { let err = ''; for (;;) { const r = await modal({ title: T('Export backup'), body: `

${T('Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.')}

${err ? `

${esc(err)}

` : ''} ${field(T('Password'), 'pw', '', { type: 'password', hint: T('Recommended. Without a password, keys and passwords are stored in plain text in the file.') }).outerHTML} ${field(T('Repeat password'), 'pw2', '', { type: 'password' }).outerHTML} ${check(T('Include settings'), 'settings', true).outerHTML}`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Export'), value: 'form', cls: 'primary' }], }); if (!r) return; if (r.pw !== r.pw2) { err = T('The passwords do not match.'); continue; } if (r.pw && r.pw.length < 8) { err = T('The password must be at least 8 characters long.'); continue; } if (!r.pw && !(await confirmBox(T('Export without password?'), T('Private keys and passwords will be readable by anyone who gets the file.'), T('Export')))) continue; try { const content = await api.call('backup:export', { password: r.pw, settings: r.settings }); const name = `mrterm-backup-${new Date().toISOString().slice(0, 10)}${r.pw ? '-encrypted' : ''}.json`; const p = await call('backup:saveFile', content, name); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); } catch (e) { toast(backupError(e), 'error'); } return; } } async function importBackup() { const f = await call('import:pickFile', 'MrTerm Backup', ['json']); if (!f) return; let info; try { info = await api.call('backup:inspect', f.content); } catch (e) { return toast(backupError(e), 'error'); } const c = info.counts; const summary = c ? T('{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs', c) : T('Contents are encrypted.'); let err = ''; for (;;) { const r = await modal({ title: T('Import backup'), body: `

${esc(f.name)}${info.createdAt ? ' · ' + esc(new Date(info.createdAt).toLocaleString()) : ''}
${esc(summary)}

${err ? `

${esc(err)}

` : ''} ${info.encrypted ? field(T('Password'), 'pw', '', { type: 'password' }).outerHTML : ''} ${check(T('Import settings'), 'settings', false).outerHTML} ${check(T('Replace existing data (entries not contained in the backup are deleted)'), 'replace', false).outerHTML}`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Import'), value: 'form', cls: 'primary' }], }); if (!r) return; if (r.replace && !(await confirmBox(T('Replace existing data?'), T('Hosts, keys, snippets and other entries that are not in the backup will be deleted.'), T('Replace')))) continue; try { const n = await api.call('backup:import', f.content, r.pw || '', { replace: r.replace, settings: r.settings }); toast(T('Import complete: {hosts} hosts, {keys} keys, {snippets} snippets', n), 'ok'); reload(); return; } catch (e) { err = backupError(e); } } } // ============================================================ Updates let updateInfo = null; let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln async function showUpdate(info) { updateInfo = info; const b = $('#updateBadge'); b.style.display = ''; b.querySelector('span').textContent = `Update ${info.version}`; if (updateDialogOpen) return; updateDialogOpen = true; 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' }] : [])], }).finally(() => { updateDialogOpen = false; }); 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)}
`); 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(); if ($('#aiPanel').classList.contains('open')) aiUpdateCtx(); } 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))}
${host.execCommand ? '' : ``}
Snippets
`); 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); }); box.append(templateList(this)); } // Letzte Zeilen des Terminals als Kontext für den Assistenten aiContext(lines = 120) { const b = this.term.buffer.active, out = []; for (let i = Math.max(0, b.length - lines); i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? ''); return out.join('\n').replace(/\n{3,}/g, '\n\n').trim(); } 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)}
` : `
${esc(msg)}
`; 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(); } } // Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett function aiMarkdown(md) { return md.split(/```/).map((part, i) => { if (i % 2) { const code = part.replace(/^[\w-]*\n/, ''); return `
${esc(code)}
`; } return esc(part).replace(/`([^`\n]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\n/g, '
'); }).join(''); } function openTerminal(host) { return new TerminalSession(host); } // ============================================================ Support-Assistent (tabübergreifend, Chats im Tresor) const AI = { chat: null, unsub: null, busy: false }; const aiChats = () => (S.vault.aiChats ||= []); const activeSession = () => S.tabs.find((t) => t.id === S.active); function aiToggle(open) { const p = $('#aiPanel'), o = open ?? !p.classList.contains('open'); p.classList.toggle('open', o); if (o) { aiRender(); $('textarea', p).focus(); } } // Kontext aus dem aktiven Tab: Terminalausgabe, Docker-/Firewall-/Netzwerkansicht … function aiContext() { const t = activeSession(); if (!t) return ''; const host = t.host ? `, host ${t.host.username ? t.host.username + '@' : ''}${t.host.address}` : ''; const body = String((t.aiContext ? t.aiContext() : t.el.innerText) || '').trim(); return body ? `Active MrTerm tab: ${t.title} (${t.kind}${host})\n\n${body.slice(-12000)}` : ''; } function aiUpdateCtx() { const t = activeSession(); $('#aiPanel .ctx-name').textContent = t ? t.title : T('Vault (no context)'); } function aiRender() { const p = $('#aiPanel'), sel = $('.chats', p), box = $('.msgs', p); sel.innerHTML = ''; sel.append(new Option(T('New chat'), '')); [...aiChats()].sort((a, b) => b.updatedAt - a.updatedAt).forEach((c) => sel.append(new Option(c.title, c.id))); sel.value = AI.chat?.id && aiChats().includes(AI.chat) ? AI.chat.id : ''; box.innerHTML = ''; if (!AI.chat?.messages.length) box.append(h(`
${T('Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.')}
`)); for (const m of AI.chat?.messages || []) { if (m.role === 'user') box.append(h(`
${m.tab ? `${esc(m.tab)}` : ''}${esc(m.content)}
`)); else { const d = h('
'); d.innerHTML = aiMarkdown(m.content); box.append(d); } } $('[data-ai=del]', p).disabled = !AI.chat?.id || !aiChats().includes(AI.chat); aiUpdateCtx(); box.scrollTop = box.scrollHeight; } async function aiSave() { const c = AI.chat; if (!c) return; if (!aiChats().includes(c)) aiChats().push(c); c.updatedAt = Date.now(); try { await call('vault:upsert', 'aiChats', c); } catch {} } function aiSetBusy(b) { AI.busy = b; const btn = $('#aiPanel [type=submit]'); btn.textContent = b ? T('Stop') : T('Send'); btn.classList.toggle('danger', b); } async function aiAsk(text) { const p = $('#aiPanel'); if (!p.classList.contains('open')) aiToggle(true); if (AI.busy) return; if (!settings().aiModel) { toast(T('Please select an Ollama model in the settings first.'), 'error'); return; } if (!AI.chat) AI.chat = { id: uid(), title: text.replace(/\s+/g, ' ').slice(0, 60), messages: [] }; const chat = AI.chat, t = activeSession(); chat.messages.push({ role: 'user', content: text, tab: t?.title || '' }); aiRender(); const box = $('.msgs', p); $('.hint', box)?.remove(); const out = h('
'); box.append(out); box.scrollTop = box.scrollHeight; const reply = { role: 'assistant', content: '' }; aiSetBusy(true); const finish = async (err) => { AI.unsub?.(); AI.unsub = null; aiSetBusy(false); if (err) { out.classList.add('err'); out.textContent = err; chat.messages.pop(); } else if (reply.content) { chat.messages.push(reply); await aiSave(); if (AI.chat === chat) aiRender(); } else out.remove(); }; AI.unsub = api.on('ai:event', (cid, type, payload) => { if (cid !== chat.id) return; if (type === 'data') { reply.content += payload; if (AI.chat !== chat) return; const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40; out.innerHTML = aiMarkdown(reply.content); if (stick) box.scrollTop = box.scrollHeight; } else finish(type === 'error' ? payload : null); }); const ctx = $('[name=ctx]', p).checked ? aiContext() : ''; try { await api.call('ai:chat', chat.id, chat.messages.slice(-20).map(({ role, content }) => ({ role, content })), ctx); } catch (e) { finish(e.message); } } function initAi() { const p = $('#aiPanel'); p.innerHTML = `
${ICONS.ai}${T('Support assistant')}
`; const ta = $('textarea', p); $('form', p).onsubmit = (e) => { e.preventDefault(); if (AI.busy) { api.call('ai:stop', AI.chat?.id); return; } if (ta.value.trim()) { aiAsk(ta.value.trim()); ta.value = ''; } }; ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!AI.busy) $('form', p).requestSubmit(); } }); $('.chats', p).onchange = (e) => { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = aiChats().find((c) => c.id === e.target.value) || null; aiRender(); }; p.addEventListener('click', async (e) => { const a = e.target.closest('[data-ai]')?.dataset.ai; if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); } if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) { if (AI.busy) api.call('ai:stop', AI.chat.id); S.vault.aiChats = aiChats().filter((c) => c !== AI.chat); await call('vault:remove', 'aiChats', AI.chat.id).catch(() => {}); AI.chat = null; aiRender(); } if (a === 'explain') { const t = activeSession(); aiAsk(t?.kind === 'ssh' ? T('Explain the last error in the terminal output and how to fix it.') : T('Look at the active tab, explain what you see and point out problems.')); } const b = e.target.closest('[data-cmd]'); if (!b) return; const code = b.closest('.code').querySelector('code').textContent.replace(/\n+$/, ''); const t = activeSession(); // Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); } else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); } }); if ($('#aiPanel').classList.contains('open')) aiRender(); if (AI.init) return; AI.init = true; document.addEventListener('click', (e) => { if (e.target.closest('[data-ai-toggle]')) aiToggle(); }); } // ============================================================ 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))}
${api.platform === 'win32' ? `` : ''}
${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 ? `` : ``}
`); 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 === 'resizing') { this.attached = false; setTabState(this, 'wait'); this.showOverlay(T('Adjusting resolution …')); } 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(`
${avatar(hst)}
${esc(hst.label || hst.address)}
${esc(hostSub(hst))}
`); 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(`
${side === 'local' ? ICONS.screen + T('Local') : ICONS.folder + esc(sftp.host.label || sftp.host.address)} ${side === 'local' && api.platform === 'win32' ? '' : ''} ${WEB ? `` : ``}
`); 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(''); 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(``))); 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 = ` ${rows.map((f, i) => ``).join('')}
Name${arrow('name')}${T('Size')}${arrow('size')}${T('Modified')}${arrow('mtime')}
${ICONS.folder}..
${f.isDir ? ICONS.folder : ICONS.file}${esc(f.name)}
${f.isDir ? '' : fmtSize(f.size)}${fmtDate(f.mtime)}
`; $$('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(`
`); 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(`
${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() { 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 = `
${esc(e.message)}
`; const b = h(``); 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); } } // 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(``); document.body.append(a); a.click(); a.remove(); return Promise.resolve(); } const tid = uid(); const row = h(`
${ICONS.upload}${esc(name)}0%
`); $('.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(`
${avatar(host, 22)}${esc(hostSub(host))}
${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.view = 'containers'; this.stackInfo = null; this.editing = null; this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); const v = e.target.closest('.seg [data-v]')?.dataset.v; if (v && v !== this.view) { this.view = v; this.editing = null; $$('.seg [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v)); $('.stopped-opt', this.el).style.display = v === 'containers' ? '' : 'none'; if (v === 'stacks') this.loadStacks(true); else this.draw(); } }); $('[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 = `
${esc(msg)}
`; const b = h(``); 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(); if (this.view === 'stacks' && !this.editing) await this.loadStacks(); } 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() { if (this.view === 'stacks') return this.editing ? null : this.drawStacks(); 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 = ` ${list.map((c) => { const st = this.stats[c.id]; const on = running(c); return ``; }).join('')}
${T('Name')}${T('Status')}${T('Ports')}CPU${T('Memory')}
${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 ? `` : ''} ${on ? `` : ``}
`; 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); } // ---------- Compose-Stacks get stacksDir() { return settings().dockerStacksDir || '/opt/stacks'; } async loadStacks(showErrors) { try { this.stackInfo = await api.call('docker:stacks', this.id, this.stacksDir); this.stackError = ''; } catch (e) { this.stackError = e.message; if (showErrors) this.stackInfo = null; } if (this.view === 'stacks' && !this.editing) this.drawStacks(); } drawStacks() { const info = this.stackInfo; if (!info) { this.body.innerHTML = `
${this.stackError ? `
${esc(this.stackError)}
` : ''}
`; return; } const list = info.stacks.filter((x) => !this.filter || [x.name, x.file, typeof x.git === 'string' ? x.git : ''].some((f) => String(f).toLowerCase().includes(this.filter))); const scroll = this.body.scrollTop; const needSetup = !info.exists || !info.writable; this.body.innerHTML = `
${T('Folder')}: ${esc(info.dir)} ${needSetup ? `` : ''}
${list.length ? ` ${list.map((x, i) => { const on = /running/.test(x.status); return ``; }).join('')}
${T('Name')}${T('Status')}${T('Compose file')}
${esc(x.name)}${x.git ? ` git` : ''}${x.managed ? '' : ` ${T('external')}`}
${esc(x.status || T('not deployed'))}${esc(x.file || T('no compose file found'))} ${x.file ? ` ${on ? `` : ''} ` : ''} ${x.managed ? `` : ''}
` : `
${this.filter ? T('No matches.') : T('No stacks yet. Create one, clone it from Git or start from a template.')}
`}`; this.body.scrollTop = scroll; $$('[data-s]', this.body).forEach((b) => { b.onclick = () => this.stackBar(b.dataset.s); }); $$('tr[data-i]', this.body).forEach((tr) => { const x = list[+tr.dataset.i]; tr.onclick = (e) => { const k = e.target.closest('[data-k]')?.dataset.k; if (k) this.stackAct(k, x); }; tr.ondblclick = (e) => { if (!e.target.closest('[data-k]') && x.file) this.stackAct('edit', x); }; tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ ...(x.file ? [ { label: T('Edit'), icon: 'edit', run: () => this.stackAct('edit', x) }, { label: T('Deploy (up -d)'), icon: 'play', run: () => this.stackAct('up', x) }, { label: T('Update images (pull + up)'), icon: 'download', run: () => this.stackAct('pull', x) }, ...(x.git ? [{ label: T('Git pull + redeploy'), icon: 'code', run: () => this.stackAct('gitpull', x) }] : []), { label: T('Restart'), icon: 'refresh', run: () => this.stackAct('restart', x) }, { label: T('Stop'), icon: 'stop', run: () => this.stackAct('stop', x) }, { label: T('Down (remove containers)'), icon: 'close', run: () => this.stackAct('down', x) }, { label: T('Logs'), icon: 'logs', run: () => this.stackAct('logs', x) }, '-', { label: T('Copy path'), icon: 'copy', run: () => api.call('clipboard:write', x.file) }, ] : []), ...(x.managed ? ['-', { label: T('Delete'), icon: 'trash', danger: true, run: () => this.stackAct('delete', x) }] : []), ]); }); } // Befehl in einem Terminal-Tab ausführen (Live-Ausgabe, sudo/git-Abfragen möglich) async stackTerminal(action, st, opts, label) { const cmd = await call('docker:stackCommand', this.id, action, st, opts); openTerminal({ ...this.host, execCommand: cmd, label: `${label} · ${st.name || st.dir}` }); } async stackBar(a) { if (a === 'new') return this.editStack({ name: '', compose: 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n', env: '' }); if (a === 'dir') { const d = await promptBox(T('Stacks folder'), T('Folder on the host in which each stack gets its own subfolder'), this.stacksDir); if (!d || !d.startsWith('/')) return; await call('vault:settings', { dockerStacksDir: d.replace(/\/+$/, '') || '/' }); S.vault.settings.dockerStacksDir = d.replace(/\/+$/, '') || '/'; return this.loadStacks(true); } if (a === 'setup') return this.stackTerminal('setup', { dir: this.stacksDir }, {}, T('Set up folder')); if (a === 'git') { const r = await modal({ title: T('Stack from Git'), body: `${field(T('Repository URL'), 'url', '', { placeholder: 'https://github.com/user/repo.git' }).outerHTML} ${row(field(T('Stack name'), 'name', '', { placeholder: 'my-app' }), field(T('Branch (optional)'), 'branch', '')).outerHTML} ${field(T('Compose file in the repository (optional)'), 'path', '', { placeholder: 'docker-compose.yml', hint: T('If empty, the first compose file found is used.') }).outerHTML} ${check(T('Deploy right after cloning'), 'deploy', true).outerHTML}
${T('The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.')}
`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Clone'), value: 'form', cls: 'primary' }], }); if (!r) return; const name = (r.name || r.url.split('/').pop().replace(/\.git$/, '')).toLowerCase().replace(/[^a-z0-9_-]+/g, '-'); return this.stackTerminal('clone', { name, stacksDir: this.stacksDir }, { url: r.url.trim(), branch: r.branch.trim(), path: r.path.trim(), deploy: r.deploy }, 'git clone').catch(() => {}); } if (a === 'templates') return this.pickTemplate(); } async pickTemplate() { const tplUrl = settings().dockerTemplatesUrl || ''; const done = modal({ title: T('App templates'), body: `
${T('Source: {url} (Portainer template format, changeable in the settings).', { url: esc(tplUrl || 'Lissy93/portainer-templates') })}
`, buttons: [{ label: T('Close'), value: null, cls: 'ghost' }], noEnter: true, }); const root = $$('#modalRoot .modal-bg').pop(); $('.modal', root).classList.add('wide'); let list = []; try { list = await api.call('docker:templates', tplUrl); } catch (e) { $('.tpl-list', root).innerHTML = `
${esc(e.message)}
`; return done; } const cats = [...new Set(list.flatMap((t) => t.categories))].sort(); $('.tpl-cat', root).innerHTML = `${cats.map((c) => ``).join('')}`; const render = () => { const q = $('.tpl-q', root).value.toLowerCase(), cat = $('.tpl-cat', root).value; const f = list.filter((t) => (!cat || t.categories.includes(cat)) && (!q || `${t.title} ${t.description}`.toLowerCase().includes(q))).slice(0, 200); $('.tpl-list', root).innerHTML = f.map((t) => `
${esc(t.title)} ${t.kind === 'stack' ? 'Stack' : 'Container'}
${esc(t.description)}
`).join('') || `
${T('No matches.')}
`; }; $('.tpl-q', root).oninput = render; $('.tpl-cat', root).onchange = render; $('.tpl-list', root).onclick = async (e) => { const it = e.target.closest('.tpl-it'); if (!it) return; it.style.opacity = '.5'; try { const t = await api.call('docker:template', +it.dataset.i, tplUrl); $('.mfoot .btn', root).click(); this.editStack({ name: t.name, compose: t.compose, env: t.env, note: t.note }); } catch (err) { it.style.opacity = ''; toast(err.message, 'error'); } }; render(); $('.tpl-q', root).focus(); return done; } async stackAct(k, x) { try { if (k === 'edit') { const r = await call('docker:stackRead', this.id, x.file); return this.editStack({ ...x, ...r }); } if (k === 'delete') { const r = await modal({ title: T('Delete stack?'), text: T('The stack “{name}” is stopped (down) and its folder {dir} is deleted.', { name: x.name, dir: x.dir }), body: check(T('Also delete named volumes (data!)'), 'volumes', false).outerHTML, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Delete'), value: 'form', cls: 'danger' }], }); if (!r) return; return await this.stackTerminal('delete', x, { volumes: r.volumes }, T('Delete')); } if (k === 'down' && !(await confirmBox(T('Remove containers?'), T('docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.', { name: x.name }), T('Down')))) return; const labels = { up: 'up', pull: 'pull', gitpull: 'git pull', restart: 'restart', stop: 'stop', down: 'down', logs: T('Logs') }; await this.stackTerminal(k, x, {}, labels[k] || k); } catch {} } aiContext() { if (!this.editing || !$('.se-compose', this.body)) return this.body.innerText; const env = $('.se-env', this.body).value.replace(/^(\s*[^#=\s]+\s*=).*$/gm, '$1***'); return `Editing compose stack "${this.editing.name || ''}"\n\ncompose.yaml:\n${$('.se-compose', this.body).value}\n\n.env (values hidden):\n${env}`; } // Editor für compose.yaml und .env; st.file fehlt bei neuen Stacks editStack(st) { this.editing = st; const isNew = !st.file; this.body.innerHTML = `
${isNew ? `` : `${esc(st.name)}${esc(st.file)}`}
${st.note ? `
${esc(st.note)}
` : ''}
`; const ta = $('.se-compose', this.body), env = $('.se-env', this.body); ta.value = st.compose || ''; env.value = st.env || ''; for (const t of [ta, env]) t.addEventListener('keydown', (e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); t.setRangeText(' ', t.selectionStart, t.selectionEnd, 'end'); } if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(false); } }); const save = async (deploy) => { const name = isNew ? $('.se-name', this.body).value.trim() : st.name; const btns = $$('[data-e]', this.body); btns.forEach((b) => { b.disabled = true; }); try { const r = await call('docker:stackSave', this.id, { stacksDir: this.stacksDir, name, file: st.file, compose: ta.value, env: env.value }); if (r.warning) toast(T('Saved, but the compose file has an error: {msg}', { msg: r.warning }), 'error'); else toast(T('Saved'), 'ok'); Object.assign(st, { file: r.file, name, dir: r.file.replace(/\/[^/]*$/, ''), managed: true, compose: ta.value, env: env.value }); if (deploy && !r.warning) await this.stackTerminal('up', st, {}, 'up'); if (isNew || deploy) { this.editing = null; await this.loadStacks(true); return; } } catch {} btns.forEach((b) => { b.disabled = false; }); }; $('[data-e=back]', this.body).onclick = () => { this.editing = null; this.loadStacks(true); }; $('[data-e=save]', this.body).onclick = () => save(false); $('[data-e=deploy]', this.body).onclick = () => save(true); (isNew && !st.name ? $('.se-name', this.body) : ta).focus(); } 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))}
${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(``); 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 = `
${esc(msg)}
`; const b = h(``); 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(``); 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(``); $('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(`
${T('No rules yet.')}
`)); 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)} `); $('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(``); $('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)} `); $('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: `
`, 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: `
`, 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(`
${avatar(host, 22)}${esc(hostSub(host))}
${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 = `
${esc(msg)}
`; const b = h(``); 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('
'); 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(`
${esc(d.hostname)}
${gw}
${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') ? `` : ''}
${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 ? `` : ''} ${this.data.editable && c && kind !== 'ethernet' ? `` : ''}
${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(4); } 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(``); 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.')}

${T('Change this if the change affects the address MrTerm uses to connect.')}
`, 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(``))); 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(``), 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(``), 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('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}
${esc(it.label)}
${it.sub ? `
${esc(it.sub.split('\n')[0])}
` : ''}
${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]); } }); // 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: `

${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 }))}

${esc(req.fingerprint)}
${changed ? `

${esc(T('Previously stored:'))}

${esc(req.previous)}
` : ''}`, 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: `
`, 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);