// MrTerm für Android: Hosts, Terminal, Schlüssel, Snippets, LAN-Sync und App-Sperre. // Die Logik (SSH, Tresor, Sync) läuft im Node-Backend (nodejs/server.js), diese Datei ist nur die Oberfläche. 'use strict'; const T = (s, v) => I18N.t(s, v); const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }; const uid = () => crypto.randomUUID(); // ============================================================ Plattform // In der App: Capacitor-Plugins. Zum Entwickeln am Desktop stellt dev/electron-dev.js window.DevBridge bereit. const Cap = window.Capacitor; const NodeJS = window.DevBridge?.NodeJS || Cap.registerPlugin('CapacitorNodeJS'); const Native = window.DevBridge?.Native || Cap.registerPlugin('MrTermNative'); const waits = new Map(); const events = new Map(); let seq = 0; const api = { call(ch, ...args) { return new Promise((resolve, reject) => { const id = ++seq; waits.set(id, { resolve, reject }); NodeJS.send({ eventName: 'call', args: [id, ch, args] }); }); }, send(ch, ...args) { NodeJS.send({ eventName: 'send', args: [ch, args] }); }, on(ch, fn) { if (!events.has(ch)) events.set(ch, new Set()); events.get(ch).add(fn); return () => events.get(ch).delete(fn); }, }; NodeJS.addListener('reply', ({ args: [id, ok, value] }) => { const w = waits.get(id); if (!w) return; waits.delete(id); ok ? w.resolve(value) : w.reject(new Error(value)); }); NodeJS.addListener('evt', ({ args: [ch, a] }) => events.get(ch)?.forEach((fn) => { try { fn(...a); } catch (e) { console.error(e); } })); // Aufruf mit Fehlermeldung als Toast async function call(ch, ...args) { try { return await api.call(ch, ...args); } catch (e) { toast(e.message, 'error'); throw e; } } // ============================================================ Icons const I = { plus: '', search: '', more: '', folder: '', term: '', edit: '', trash: '', copy: '', key: '', code: '', lock: '', sync: '', eye: '', play: '', x: '', paste: '', zoomIn: '', zoomOut: '', refresh: '', finger: '', shield: '', devices: '', down: '', info: '', up: '', left: '', right: '', downA: '', }; const icon = (n) => `${I[n] || ''}`; // ============================================================ Zustand const S = { vault: null, lock: null, sync: null, view: 'hosts', sessions: [], active: null, filter: '', collapsed: new Set(), bio: { available: false, enrolled: false } }; const settings = () => S.vault?.settings || {}; const COLORS = ['#6e7bff', '#3ecf8e', '#ffb347', '#ff5f6d', '#c792ea', '#56d6d6', '#f78c6c', '#82aaff']; function colorFor(x) { if (x.color) return x.color; let n = 0; for (const c of String(x.label || x.address || '')) n = (n * 31 + c.charCodeAt(0)) >>> 0; return COLORS[n % COLORS.length]; } const hostName = (x) => x.label || x.address; const hostSub = (x) => `${x.username ? `${x.username}@` : ''}${x.address}${x.port && Number(x.port) !== 22 ? `:${x.port}` : ''}`; // ============================================================ Bausteine: Toast, Dialog, Sheet, Seite function toast(msg, type = '') { const t = h(`
${esc(msg)}
`); $('#toasts').append(t); setTimeout(() => t.remove(), type === 'error' ? 5000 : 2600); } // Hardware-Zurück schließt die oberste Ebene const backStack = []; function pushLayer(el, onClose) { $('#layer').append(el); const entry = { el, close: () => { el.remove(); const i = backStack.indexOf(entry); if (i >= 0) backStack.splice(i, 1); onClose?.(); } }; backStack.push(entry); return entry.close; } // Dialog: buttons [{label, value, cls}], value 'form' liefert die Eingabefelder als Objekt function modal({ title, text, body = '', buttons = [{ label: T('OK'), value: true, cls: 'primary' }], dismiss = true }) { return new Promise((resolve) => { const el = h(`

${esc(title)}

${text ? `

${esc(text)}

` : ''}
${body}
`); let done = false; const finish = (v) => { if (done) return; done = true; close(); resolve(v); }; const close = pushLayer(el, () => finish(null)); const form = $('form', el); const collect = () => Object.fromEntries($$('input,select,textarea', form).filter((i) => i.name).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value])); for (const b of buttons) { const btn = h(``); btn.onclick = () => finish(b.value === 'form' ? collect() : b.value); $('.btns', el).append(btn); } form.onsubmit = (e) => { e.preventDefault(); const p = buttons.find((b) => b.cls?.includes('primary')); if (p) finish(p.value === 'form' ? collect() : p.value); }; if (dismiss) el.addEventListener('click', (e) => { if (e.target === el) finish(null); }); wirePw(el); setTimeout(() => $('input:not([type=checkbox])', form)?.focus(), 60); }); } const confirmBox = async (title, text, ok = T('Delete'), cls = 'danger') => (await modal({ title, text, buttons: [{ label: T('Cancel'), value: false }, { label: ok, value: true, cls: `primary ${cls === 'danger' ? '' : ''}` }] })) === true; // Auswahlliste von unten: [{label, icon, danger, run}] function sheet(title, items) { const el = h(`
${title ? `
${esc(title)}
` : ''}
`); const close = pushLayer(el); for (const it of items.filter(Boolean)) { const b = h(``); b.onclick = () => { close(); it.run(); }; $('.sheet', el).append(b); } el.addEventListener('click', (e) => { if (e.target === el) close(); }); } // Vollbild-Formular mit Speichern-Knopf function page(title, body, { save, saveLabel = T('Save'), extra } = {}) { const el = h(`

${esc(title)}

`); const close = pushLayer(el); $('[data-back]', el).onclick = close; if (save) { const b = h(``); b.onclick = async () => { b.disabled = true; try { if ((await save(collectForm($('.page-body', el)))) !== false) close(); } catch (e) { toast(e.message, 'error'); } b.disabled = false; }; $('.page-head', el).append(b); } if (extra) $('.page-head', el).insertBefore(extra, $('.page-head .btn', el)); $('.page-body', el).append(...[].concat(body)); wirePw(el); return { el, close }; } const collectForm = (root) => Object.fromEntries($$('input,select,textarea', root).filter((i) => i.name).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value])); function field(label, name, value = '', o = {}) { let input; if (o.type === 'select') input = ``; else if (o.type === 'textarea') input = ``; else if (o.type === 'password') input = `
`; else input = ``; return `
${input}${o.hint ? `
${esc(o.hint)}
` : ''}
`; } function wirePw(root) { $$('[data-eye]', root).forEach((b) => { b.onclick = () => { const i = b.previousElementSibling; i.type = i.type === 'password' ? 'text' : 'password'; }; }); } const switchEl = (checked) => ``; // ============================================================ Start async function boot() { const info = await Native.info().catch(() => ({ device: 'Android', locale: navigator.language, version: '0.0.0' })); S.info = info; I18N.setLanguage('auto', info.locale); const ready = new Promise((r) => { const off = NodeJS.addListener('ready', () => r()); void off; }); const { key } = await Native.vaultKey(); await NodeJS.start({ env: { MRTERM_VAULT_KEY: key, MRTERM_DEVICE: info.device, MRTERM_LOCALE: info.locale, MRTERM_VERSION: info.version } }); await Promise.race([ready, new Promise((r) => setTimeout(r, 8000))]); try { Object.assign(S.bio, await Native.bioAvailable()); } catch {} Native.multicast({ on: true }).catch(() => {}); $('#boot').hidden = true; await reload(); } async function reload() { S.lock = await api.call('lock:status'); I18N.setLanguage(S.lock.meta.language || 'auto', S.info.locale); applyStatic(); if (S.lock.locked) return showLock(); S.vault = await api.call('vault:get'); I18N.setLanguage(settings().language || 'auto', S.info.locale); applyStatic(); S.sync = await api.call('sync:status').catch(() => null); $('#main').hidden = false; render(); } // Statische Texte in index.html übersetzen function applyStatic() { $$('[data-t]').forEach((e) => { e.dataset.t ||= e.textContent; e.textContent = T(e.dataset.t); }); document.documentElement.lang = I18N.lang; } $$('#tabs button').forEach((b) => (b.onclick = () => { S.view = b.dataset.view; render(); $('#view').scrollTop = 0; })); function render() { $$('#tabs button').forEach((b) => b.classList.toggle('active', b.dataset.view === S.view)); const v = $('#view'); v.innerHTML = ''; ({ hosts: viewHosts, keys: viewKeys, snippets: viewSnippets, settings: viewSettings })[S.view](v); } // ============================================================ Hosts function groupPath(id) { const parts = []; const seen = new Set(); let g = S.vault.groups.find((x) => x.id === id); while (g && !seen.has(g.id)) { seen.add(g.id); parts.unshift(g.label); g = S.vault.groups.find((x) => x.id === g.parentId); } return parts.join(' / '); } function viewHosts(v) { const top = h(`

MrTerm

`); if (S.sync?.enabled) { const on = S.sync.peers.some((p) => p.online); const b = h(``); b.onclick = async () => { try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} }; top.append(b); } if (S.lock.enabled) { const b = h(``); b.onclick = lockNow; top.append(b); } v.append(top); const search = h(``); const input = $('input', search); input.value = S.filter; input.oninput = () => { S.filter = input.value; drawList(); }; input.onkeydown = (e) => { if (e.key === 'Enter') quickConnect(input.value); }; v.append(search); if (S.sessions.length) { const bar = h('
'); for (const s of S.sessions) { const c = h(``); c.onclick = () => showSession(s); bar.append(c); } v.append(bar); } const box = h('
'); v.append(box); const fab = h(``); fab.onclick = () => sheet(null, [ { label: T('New host'), icon: 'term', run: () => editHost({}) }, { label: T('New group'), icon: 'folder', run: () => editGroup({}) }, ]); v.append(fab); function drawList() { box.innerHTML = ''; const q = S.filter.trim().toLowerCase(); const hosts = S.vault.hosts.filter((x) => !q || [x.label, x.address, x.username, ...(x.tags || [])].some((f) => String(f || '').toLowerCase().includes(q))); if (!S.vault.hosts.length) { box.append(h(`
${icon('term')}

${esc(T('No hosts yet'))}

${esc(T('Add your first server or pair this phone with MrTerm on your computer to take over your hosts.'))}
`)); $('[data-a=new]', box).onclick = () => editHost({}); $('[data-a=sync]', box).onclick = () => { S.view = 'settings'; render(); }; return; } if (!hosts.length && q) { box.append(h(`
${esc(T('No matching hosts. Press Enter to connect to “{q}”.', { q: S.filter.trim() }))}
`)); return; } // Gruppen in Pfadreihenfolge, Hosts ohne Gruppe zuerst const byGroup = new Map(); for (const x of hosts) { const k = x.groupId && S.vault.groups.some((g) => g.id === x.groupId) ? x.groupId : ''; if (!byGroup.has(k)) byGroup.set(k, []); byGroup.get(k).push(x); } const keys = [...byGroup.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : groupPath(a).localeCompare(groupPath(b)))); for (const k of keys) { const list = byGroup.get(k).sort((a, b) => hostName(a).localeCompare(hostName(b))); const g = S.vault.groups.find((x) => x.id === k); const collapsed = !q && S.collapsed.has(k); const head = h(`
${k ? `${icon('folder')}` : ''}${esc(k ? groupPath(k) : T('Hosts'))}${list.length}${k ? `${icon('down')}` : ''}
`); if (k) { head.onclick = () => { S.collapsed.has(k) ? S.collapsed.delete(k) : S.collapsed.add(k); drawList(); }; head.oncontextmenu = (e) => { e.preventDefault(); groupMenu(g); }; let t; head.addEventListener('touchstart', () => { t = setTimeout(() => groupMenu(g), 550); }, { passive: true }); head.addEventListener('touchend', () => clearTimeout(t)); head.addEventListener('touchmove', () => clearTimeout(t), { passive: true }); } box.append(head); if (collapsed) continue; const ul = h('
'); for (const x of list) ul.append(hostItem(x)); box.append(ul); } } drawList(); } function hostItem(x) { const rdp = x.protocol === 'rdp'; const live = S.sessions.some((s) => s.host.id === x.id && s.alive); const el = h(`
${esc((hostName(x)[0] || '?').toUpperCase())}
${esc(hostName(x))}
${rdp ? esc(T('RDP is not available on Android')) : esc(hostSub(x))}
${live ? '' : ''}
`); el.onclick = (e) => { if (e.target.closest('.more')) return hostMenu(x); if (rdp) return toast(T('RDP is not available on Android')); openTerminal(x); }; return el; } function hostMenu(x) { const ssh = x.protocol !== 'rdp'; sheet(hostName(x), [ ssh && { label: T('Connect'), icon: 'term', run: () => openTerminal(x) }, { label: T('Edit'), icon: 'edit', run: () => editHost(x) }, { label: T('Duplicate'), icon: 'copy', run: async () => { const { id, createdAt, updatedAt, ...rest } = x; await call('vault:upsert', 'hosts', { ...rest, label: hostName(x) + T(' (copy)') }); reload(); } }, { label: T('Copy address'), icon: 'copy', run: () => copyText(hostSub(x)) }, { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hostName(x) }))) { await call('vault:remove', 'hosts', x.id); reload(); } } }, ]); } function groupMenu(g) { sheet(g.label, [ { label: T('Edit'), icon: 'edit', run: () => 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(); } } }, ]); } function groupOptions(exclude) { return [['', T('— No group —')], ...S.vault.groups.filter((g) => g.id !== exclude).map((g) => [g.id, groupPath(g.id)]).sort((a, b) => a[1].localeCompare(b[1]))]; } function editGroup(g) { page(g.id ? T('Edit group') : T('New group'), h(`
${field(T('Name'), 'label', g.label)}${field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: groupOptions(g.id) })}
`), { save: async (v) => { if (!v.label.trim()) throw new Error(T('Please enter a name.')); await call('vault:upsert', 'groups', { ...g, label: v.label.trim(), parentId: v.parentId || null }); reload(); }, }); } function editHost(x) { const isNew = !x.id; const keys = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])]; const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((o) => o.id !== x.id && o.protocol !== 'rdp').map((o) => [o.id, hostName(o)])]; const body = h(`
${field(T('Address'), 'address', x.address, { placeholder: '192.168.0.10', inputmode: 'url' })}${field(T('Port'), 'port', x.port || '', { placeholder: '22', inputmode: 'numeric' })}
${field(T('Label'), 'label', x.label)} ${field(T('Username'), 'username', x.username, { placeholder: 'root' })} ${field(T('Password'), 'password', x.password, { type: 'password', placeholder: T('optional') })} ${field(T('SSH key'), 'keyId', x.keyId, { type: 'select', options: keys })} ${field(T('Group'), 'groupId', x.groupId, { type: 'select', options: groupOptions() })} ${field(T('Jump host (ProxyJump)'), 'jumpHostId', x.jumpHostId, { type: 'select', options: jumps })} ${field(T('Startup command'), 'startupCommand', x.startupCommand, { placeholder: T('e.g. tmux attach || tmux') })} ${field(T('Tags'), 'tags', (x.tags || []).join(', '), { placeholder: 'prod, web, db' })}
`); const del = isNew ? null : h(``); const pg = page(isNew ? T('New host') : T('Edit host'), body, { extra: del, save: async (v) => { if (!v.address.trim()) throw new Error(T('Please enter an address.')); const item = { ...x, ...v, protocol: x.protocol || 'ssh', 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, }; const saved = await call('vault:upsert', 'hosts', item); if (item.password && item.password !== x.password) askShareSecret('hosts', saved, hostName(item)); reload(); }, }); if (del) del.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hostName(x) }))) { await call('vault:remove', 'hosts', x.id); pg.close(); reload(); } }; } // user@host:port als temporäre Verbindung function quickConnect(q) { const m = String(q).trim().match(/^(?:ssh:\/\/)?(?:([^@\s]+)@)?([^\s:@]+)(?::(\d+))?$/); if (!m) return; openTerminal({ label: m[2], address: m[2], username: m[1] || '', port: m[3] ? Number(m[3]) : 22, protocol: 'ssh', quick: true }); } // ============================================================ Terminal const KEYS = [ { id: 'esc', label: 'Esc', seq: '\x1b' }, { id: 'tab', label: 'Tab', seq: '\t' }, { id: 'ctrl', label: 'Ctrl', mod: true }, { id: 'alt', label: 'Alt', mod: true }, { id: 'left', icon: 'left', seq: '\x1b[D', app: '\x1bOD' }, { id: 'up', icon: 'up', seq: '\x1b[A', app: '\x1bOA' }, { id: 'down', icon: 'downA', seq: '\x1b[B', app: '\x1bOB' }, { id: 'right', icon: 'right', seq: '\x1b[C', app: '\x1bOC' }, { label: '|', seq: '|' }, { label: '-', seq: '-' }, { label: '/', seq: '/' }, { label: '~', seq: '~' }, { label: 'Home', seq: '\x1b[H' }, { label: 'End', seq: '\x1b[F' }, { label: 'PgUp', seq: '\x1b[5~' }, { label: 'PgDn', seq: '\x1b[6~' }, { label: '^C', seq: '\x03' }, { label: '^D', seq: '\x04' }, { label: '^Z', seq: '\x1a' }, { label: '^R', seq: '\x12' }, ]; const mods = { ctrl: 0, alt: 0 }; // 0 aus, 1 einmal, 2 festgestellt function termTheme() { return TERM_THEMES[settings().terminalTheme] || TERM_THEMES.mrterm; } const fontSize = () => Number(settings().mobileFontSize) || 12; class Session { constructor(host) { this.id = uid(); this.host = host; this.title = hostName(host); this.alive = false; this.connecting = false; this.el = h(''); $('#terms').append(this.el); this.term = new Terminal({ fontFamily: 'ui-monospace, "JetBrains Mono", "DejaVu Sans Mono", "Droid Sans Mono", monospace', fontSize: fontSize(), theme: termTheme(), scrollback: 5000, cursorBlink: true, allowProposedApi: true, }); this.fit = new FitAddon.FitAddon(); this.term.loadAddon(this.fit); this.term.open(this.el); const ta = this.term.textarea; ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocorrect', 'off'); ta.setAttribute('spellcheck', 'false'); ta.setAttribute('autocomplete', 'off'); this.term.onData((d) => this.input(d)); this.term.onResize(({ cols, rows }) => { if (this.alive) api.send('ssh:resize', this.id, cols, rows); }); this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); }); this.pinch(this.el); S.sessions.push(this); showSession(this); this.connect(); } async connect() { this.connecting = true; this.overlay?.remove(); this.overlay = h(`
${esc(T('Connecting …'))}
`); this.el.append(this.overlay); updateTermHead(); this.refit(); try { const ref = this.host.quick ? { ...this.host } : this.host.id; await api.call('ssh:open', this.id, ref, { cols: this.term.cols, rows: this.term.rows }); this.alive = true; this.overlay.remove(); this.overlay = null; api.send('ssh:resize', this.id, this.term.cols, this.term.rows); if (S.active === this) this.term.focus(); } catch (e) { this.alive = false; $('.spinner', this.overlay)?.remove(); $('.msg', this.overlay).innerHTML = `
${esc(e.message)}
`; const b = h(``); b.onclick = () => this.connect(); this.overlay.append(b); } this.connecting = false; updateTermHead(); } input(d) { if (mods.ctrl && d.length === 1) { const c = d.toUpperCase().charCodeAt(0); if (c >= 64 && c <= 95) d = String.fromCharCode(c - 64); else if (d === ' ') d = '\x00'; if (mods.ctrl === 1) mods.ctrl = 0; } if (mods.alt) { d = '\x1b' + d; if (mods.alt === 1) mods.alt = 0; } drawKeybar(); if (!this.alive) { if (d === '\r' && !this.connecting) this.connect(); return; } api.send('ssh:write', this.id, d); } key(k) { if (k.seq === undefined) return; const app = k.app && this.term.modes.applicationCursorKeysMode; this.input(app ? k.app : k.seq); } 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.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); updateTermHead(); } } refit() { if (this.el.hidden) return; try { this.fit.fit(); } catch {} } setFont(size) { size = Math.max(7, Math.min(28, Math.round(size))); if (size === this.term.options.fontSize) return; S.sessions.forEach((s) => { s.term.options.fontSize = size; }); this.refit(); clearTimeout(this.fontSave); this.fontSave = setTimeout(() => { api.call('vault:settings', { mobileFontSize: size }).catch(() => {}); S.vault.settings.mobileFontSize = size; }, 600); } // Zwei-Finger-Zoom ändert die Schriftgröße pinch(el) { let d0 = 0, f0 = 0; const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); el.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { d0 = dist(e.touches); f0 = this.term.options.fontSize; } }, { passive: true }); el.addEventListener('touchmove', (e) => { if (e.touches.length === 2 && d0) { e.preventDefault(); this.setFont(f0 * (dist(e.touches) / d0)); } }, { passive: false }); el.addEventListener('touchend', () => { d0 = 0; }, { passive: true }); } close() { this.unsub(); api.send('ssh:close', this.id); this.term.dispose(); this.el.remove(); S.sessions = S.sessions.filter((s) => s !== this); } } function openTerminal(host) { return new Session(host); } function showSession(s) { S.active = s; S.sessions.forEach((x) => { x.el.hidden = x !== s; }); $('#termScreen').hidden = false; $('#main').hidden = true; document.documentElement.style.setProperty('--term-bg', termTheme().background); updateTermHead(); drawKeybar(); requestAnimationFrame(() => { s.refit(); if (s.alive) s.term.focus(); }); } function hideTerminal() { $('#termScreen').hidden = true; $('#main').hidden = false; S.active?.term.blur(); S.active = null; render(); } function updateTermHead() { const s = S.active; if (!s) return; $('#termTitle .name').textContent = s.title; $('#termTitle .dot').className = `dot ${s.alive ? 'on' : s.connecting ? '' : 'err'}`; $('#termTitle .count')?.remove(); if (S.sessions.length > 1) $('#termTitle .name').after(h(`${S.sessions.length}`)); } function drawKeybar() { const bar = $('#keybar'); bar.innerHTML = ''; for (const k of KEYS) { const on = k.mod && mods[k.id]; const b = h(``); // pointerdown + preventDefault: Tastatur bleibt offen, Fokus bleibt im Terminal b.addEventListener('pointerdown', (e) => { e.preventDefault(); if (!S.active) return; if (k.mod) { mods[k.id] = (mods[k.id] + 1) % 3; drawKeybar(); return; } S.active.key(k); }); bar.append(b); } } $('#termBack').onclick = hideTerminal; $('#termKbd').onclick = () => { const s = S.active; if (!s) return; if (document.activeElement === s.term.textarea) s.term.blur(); else s.term.focus(); }; $('#termTitle').onclick = () => { sheet(T('Sessions'), [ ...S.sessions.map((s) => ({ label: `${s === S.active ? '● ' : ''}${s.title}${s.alive ? '' : ` (${T('disconnected')})`}`, icon: 'term', run: () => showSession(s) })), { label: T('New connection'), icon: 'plus', run: () => { hideTerminal(); } }, ]); }; $('#termMenu').onclick = () => { const s = S.active; if (!s) return; sheet(s.title, [ { label: T('Snippets'), icon: 'code', run: () => pickSnippet(s) }, { label: T('Paste'), icon: 'paste', run: async () => { const { text } = await Native.paste(); if (text) s.alive ? api.send('ssh:write', s.id, text) : null; } }, { label: T('Copy selection'), icon: 'copy', run: () => { const t = s.term.getSelection(); if (t) copyText(t); else toast(T('Nothing selected. Long-press the terminal to select text.')); } }, { label: T('Larger font'), icon: 'zoomIn', run: () => s.setFont(s.term.options.fontSize + 1) }, { label: T('Smaller font'), icon: 'zoomOut', run: () => s.setFont(s.term.options.fontSize - 1) }, s.alive ? { label: T('Disconnect'), icon: 'x', run: () => api.send('ssh:close', s.id) } : { label: T('Reconnect'), icon: 'refresh', run: () => s.connect() }, { label: T('Close session'), icon: 'trash', danger: true, run: () => { s.close(); const next = S.sessions[S.sessions.length - 1]; next ? showSession(next) : hideTerminal(); } }, ]); }; // Bildschirmtastatur verkleinert den sichtbaren Bereich: Terminal an visualViewport anpassen function fitViewport() { const vv = window.visualViewport; const scr = $('#termScreen'); if (vv) scr.style.height = `${vv.height}px`; S.active?.refit(); } window.visualViewport?.addEventListener('resize', fitViewport); window.addEventListener('resize', fitViewport); function pickSnippet(s) { if (!S.vault.snippets.length) return toast(T('No snippets')); sheet(T('Snippets'), S.vault.snippets.map((sn) => ({ label: sn.label, icon: 'code', run: () => { if (s.alive) api.send('ssh:write', s.id, sn.command.endsWith('\n') ? sn.command.replace(/\n$/, '\r') : sn.command); s.term.focus(); } }))); } // ============================================================ Schlüssel function viewKeys(v) { v.append(h(`

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

`)); const fab = h(``); fab.onclick = () => sheet(null, [ { label: T('Generate key'), icon: 'key', run: generateKey }, { label: T('Import key'), icon: 'paste', run: () => editKey({}) }, ]); if (!S.vault.keys.length) { v.append(h(`
${icon('key')}

${esc(T('No keys'))}

${esc(T('Generate a new SSH key or import an existing one.'))}
`)); v.append(fab); return; } const list = h('
'); for (const k of S.vault.keys) { const type = (k.publicKey || '').split(' ')[0].replace('ssh-', '') || 'key'; const el = h(`
${icon('key')}
${esc(k.label)}
${esc(type)} · ${esc((k.publicKey || '').split(' ')[1]?.slice(-16) || '')}
`); el.onclick = () => sheet(k.label, [ { label: T('Copy public key'), icon: 'copy', run: () => copyText(k.publicKey) }, { label: T('Edit'), icon: 'edit', run: () => editKey(k) }, { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete key?'), k.label)) { await call('vault:remove', 'keys', k.id); reload(); } } }, ]); list.append(el); } v.append(list, fab); } function generateKey() { page(T('Generate key'), h(`
${field(T('Name'), 'label', `${S.info.device.replace(/\s+/g, '-').toLowerCase()}`)} ${field(T('Type'), 'type', 'ed25519', { type: 'select', options: [['ed25519', `Ed25519 (${T('recommended')})`], ['ecdsa', 'ECDSA'], ['rsa', 'RSA 4096']] })} ${field('Passphrase (optional)', 'passphrase', '', { type: 'password' })}
`), { saveLabel: T('Generate'), save: async (v) => { if (!v.label.trim()) throw new Error(T('Please enter a name.')); const k = await api.call('key:generate', { type: v.type, passphrase: v.passphrase, comment: v.label.trim() }); const saved = await call('vault:upsert', 'keys', { label: v.label.trim(), privateKey: k.privateKey, publicKey: k.publicKey, passphrase: v.passphrase || undefined }); await reload(); askShareSecret('keys', saved, saved.label); const r = await modal({ title: T('Key created'), body: `

${esc(T('Add the public key to ~/.ssh/authorized_keys on your servers.'))}

${esc(k.publicKey)}
`, buttons: [{ label: T('Close'), value: false }, { label: T('Copy public key'), value: true, cls: 'primary' }] }); if (r) copyText(k.publicKey); }, }); } function editKey(k) { page(k.id ? T('Edit key') : T('Import key'), h(`
${field(T('Name'), 'label', k.label)} ${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.') })}
`), { save: async (v) => { if (!v.privateKey.trim()) throw new Error(T('Private key is missing.')); const parsed = await api.call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase }); const saved = await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey, passphrase: v.passphrase || undefined }); await reload(); if (!k.id) askShareSecret('keys', saved, saved.label); }, }); } // ============================================================ Snippets function viewSnippets(v) { v.append(h(`

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

`)); const fab = h(``); fab.onclick = () => editSnippet({}); if (!S.vault.snippets.length) { v.append(h(`
${icon('code')}

${esc(T('No snippets'))}

${esc(T('Save frequently used commands and send them to a terminal with one tap.'))}
`)); v.append(fab); return; } const list = h('
'); for (const s of S.vault.snippets) { const el = h(`
${icon('code')}
${esc(s.label)}
${esc(s.command.split('\n')[0])}
`); el.onclick = () => sheet(s.label, [ S.sessions.some((x) => x.alive) && { label: T('Run in terminal'), icon: 'play', run: () => { const t = S.sessions.find((x) => x.alive); showSession(t); api.send('ssh:write', t.id, s.command.replace(/\n$/, '\r')); } }, { label: T('Copy'), icon: 'copy', run: () => copyText(s.command) }, { label: T('Edit'), icon: 'edit', run: () => editSnippet(s) }, { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete snippet?'), s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } } }, ]); list.append(el); } v.append(list, fab); } function editSnippet(s) { page(s.id ? T('Edit snippet') : T('New snippet'), h(`
${field(T('Name'), 'label', s.label)}${field(T('Command'), 'command', s.command, { type: 'textarea', placeholder: 'sudo apt update && sudo apt upgrade -y' })}
${esc(T('End the command with a line break to run it immediately.'))}
`), { save: async (v) => { if (!v.command.trim()) throw new Error(T('Please enter a command.')); await call('vault:upsert', 'snippets', { ...s, ...v, label: v.label || v.command.split('\n')[0].slice(0, 40) }); reload(); }, }); } // ============================================================ Einstellungen function viewSettings(v) { v.append(h(`

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

`)); const st = settings(); // Sprache & Terminal const gen = h(`

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

${field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System language')], ...Object.entries(I18N.LANGUAGES)] })} ${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'mrterm', { type: 'select', options: Object.entries(TERM_THEMES).map(([id, t]) => [id, t.name]) })} ${field(T('Font size'), 'mobileFontSize', fontSize(), { type: 'select', options: [8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20].map((n) => [n, `${n} px`]) })}
`); $('[name=language]', gen).onchange = async (e) => { await call('vault:settings', { language: e.target.value }); await reload(); }; $('[name=terminalTheme]', gen).onchange = async (e) => { await call('vault:settings', { terminalTheme: e.target.value }); S.vault.settings.terminalTheme = e.target.value; S.sessions.forEach((s) => { s.term.options.theme = termTheme(); }); }; $('[name=mobileFontSize]', gen).onchange = async (e) => { const n = Number(e.target.value); await call('vault:settings', { mobileFontSize: n }); S.vault.settings.mobileFontSize = n; S.sessions.forEach((s) => { s.term.options.fontSize = n; }); }; v.append(gen); v.append(lockCard()); const sc = h('
'); v.append(sc); renderSyncCard(sc); // Bekannte Hosts const kh = h(`

${esc(T('Known hosts'))}

${esc(T('{n} saved host keys', { n: Object.keys(S.vault.knownHosts || {}).length }))}
`); $('button', kh).onclick = showKnownHosts; v.append(kh); v.append(h(`

MrTerm

${esc(T('Version {v}', { v: S.info.version }))} · Android
${esc(T('Updates are installed with Obtainium.'))}

`)); } function showKnownHosts() { const body = h('
'); const draw = () => { body.innerHTML = ''; const entries = Object.entries(S.vault.knownHosts || {}); if (!entries.length) { body.append(h(`
${esc(T('No known hosts yet.'))}
`)); return; } const list = h('
'); for (const [id, kh] of entries) { const el = h(`
${icon('shield')}
${esc(id)}
${esc(kh.fingerprint || '')}
`); $('.more', el).onclick = async () => { if (await confirmBox(T('Remove host key?'), id, T('Remove'))) { await call('vault:forgetHost', id); S.vault = await api.call('vault:get'); draw(); } }; list.append(el); } body.append(list); }; draw(); page(T('Known hosts'), body); } // ---------- App-Sperre ---------- const AUTOLOCK = [[0, 'Immediately'], [60, 'After 1 minute'], [300, 'After 5 minutes'], [-1, 'Never']]; function lockCard() { const L = S.lock; const c = h(`

${esc(T('App lock'))}

${esc(T('Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.'))}

`); const pw = h(`
${esc(T('Password'))}
${esc(L.hasPassword ? T('Set') : T('Not set'))}
`); const setPw = h(``); setPw.onclick = async () => { const r = await modal({ title: T('Set password'), body: `${field(T('New password'), 'a', '', { type: 'password' })}${field(T('Repeat password'), 'b', '', { type: 'password' })}`, buttons: [{ label: T('Cancel'), value: null }, { 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 { S.lock = await call('lock:setPassword', r.a); toast(T('Saved'), 'ok'); render(); } catch {} }; pw.append(setPw); c.append(pw); if (L.hasPassword) { const rm = h(`
${esc(T('Remove app lock'))}
`); $('button', rm).onclick = async () => { if (!(await confirmBox(T('Remove app lock?'), T('MrTerm will open without a password.'), T('Remove')))) return; S.lock = await call('lock:removePassword'); Native.bioDisable().catch(() => {}); render(); }; c.append(rm); if (S.bio.available) { const bio = h(`
${esc(T('Unlock with fingerprint'))}
${esc(T('Uses the fingerprint or face unlock of this phone.'))}
${switchEl(L.bio)}
`); $('input', bio).onchange = async (e) => { try { if (e.target.checked) { const { secret } = await Native.bioEnroll({ title: T('Confirm fingerprint'), cancel: T('Cancel') }); S.lock = await call('lock:setBio', secret); } else { S.lock = await call('lock:setBio', null); await Native.bioDisable(); } } catch (err) { if (err.message !== 'cancelled') toast(err.message, 'error'); } render(); }; c.append(bio); } const al = h(`
${esc(T('Lock when leaving the app'))}
`); $('select', al).onchange = async (e) => { const n = Number(e.target.value); await call('vault:settings', { mobileAutoLock: n }); S.vault.settings.mobileAutoLock = n; }; c.append(al); } return c; } async function lockNow() { S.lock = await api.call('lock:lock'); if (S.lock.locked) showLock(); } function showLock() { $('#main').hidden = true; $('#termScreen').hidden = true; $$('#layer > *').forEach((e) => e.remove()); backStack.length = 0; const el = $('#lockScreen'); el.hidden = false; el.innerHTML = `

${esc(T('MrTerm is locked'))}

${esc(T('Enter your password to unlock.'))}

${field(T('Password'), 'pw', '', { type: 'password' })}
${S.lock.bio && S.bio.enrolled !== false ? `` : ''}`; wirePw(el); const done = async () => { el.hidden = true; el.innerHTML = ''; await reload(); if (S.active) showSession(S.active); }; $('form', el).onsubmit = async (e) => { e.preventDefault(); const pw = $('[name=pw]', el).value; try { await api.call('lock:unlockPassword', pw); await done(); } catch (err) { toast(err.message, 'error'); $('[name=pw]', el).select(); } }; const bioUnlock = async () => { try { const { secret } = await Native.bioUnlock({ title: T('Unlock MrTerm'), cancel: T('Use password') }); await api.call('lock:unlockBio', secret); await done(); } catch (err) { if (err.message === 'invalidated') toast(T('Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.'), 'error'); else if (err.message !== 'cancelled') toast(err.message, 'error'); $('[name=pw]', el)?.focus(); } }; const bb = $('.bio-btn', el); if (bb) { bb.onclick = bioUnlock; setTimeout(bioUnlock, 250); } else setTimeout(() => $('[name=pw]', el).focus(), 100); } // Automatisch sperren, wenn die App länger im Hintergrund war; Sync-Sockets im Hintergrund schließen let hiddenAt = 0; document.addEventListener('visibilitychange', async () => { if (!S.lock || !S.vault) return; if (document.hidden) { hiddenAt = Date.now(); Native.multicast({ on: false }).catch(() => {}); return; } Native.multicast({ on: true }).catch(() => {}); const after = Number(settings().mobileAutoLock ?? 60); if (S.lock.enabled && !S.lock.locked && after >= 0 && Date.now() - hiddenAt >= after * 1000) await lockNow(); else api.call('app:resume').catch(() => {}); }); // ============================================================ Synchronisation async function renderSyncCard(card) { const st = S.sync || (S.sync = await api.call('sync:status').catch(() => null)); if (!st) return; card.innerHTML = `

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

${esc(T('Take over hosts, groups, snippets and known hosts from MrTerm on your computer. The connection is end-to-end encrypted and stays in your local network.'))}

`; const en = h(`
${esc(T('Enable LAN synchronization'))}
${switchEl(st.enabled)}
`); $('input', en).onchange = async (e) => { S.sync = await call('sync:enable', e.target.checked, st.deviceName || S.info.device); renderSyncCard(card); }; card.append(en); if (!st.enabled) return; const name = h(`
${field(T('Device name'), 'devName', st.deviceName)}
`); $('input', name).onchange = async (e) => { S.sync = await call('sync:enable', true, e.target.value.trim()); }; card.append(name); if (!st.peers.length) card.append(h(`
${esc(T('No paired devices yet.'))}
`)); for (const p of st.peers) { const row = h(`
${esc(p.name)}
${esc(p.online ? T('online') : T('offline'))}${p.lastSync ? ` · ${esc(T('last sync {time}', { time: new Date(p.lastSync).toLocaleString(I18N.lang) }))}` : ''}${p.error ? ` · ${esc(p.error)}` : ''}
`); $('button', row).onclick = async () => { if (await confirmBox(T('Unpair device?'), T('“{name}” will no longer synchronize with this device.', { name: p.name }), T('Unpair'))) { S.sync = await call('sync:unpair', p.id); renderSyncCard(card); } }; card.append(row); } 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); card.append(h(`
${esc(T('Both devices must be in the same Wi-Fi. If nothing is found, allow UDP port 47811 and TCP port 47812 in the firewall of your computer.'))}
`)); } async function openPairing() { S.sync = await call('sync:pairable', true); const body = h(`

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

`); const pg = page(T('Pair new device'), body); const draw = () => { const box = $('.pair-list', body); const near = S.sync?.nearby || []; box.innerHTML = near.length ? '' : `
${esc(T('Searching for devices …'))}
`; if (!near.length) return; const list = h('
'); for (const d of near) { const row = h(`
${icon('devices')}
${esc(d.name)}
${esc(d.address)}${d.pairable ? '' : ` · ${esc(T('not ready for pairing'))}`}
`); const b = $('button', row); b.onclick = async () => { b.disabled = true; b.innerHTML = ''; try { await call('sync:pair', d.id); pg.close(); } catch {} b.disabled = false; b.textContent = T('Pair'); }; list.append(row); } box.append(list); }; draw(); const timer = setInterval(() => { if (!pg.el.isConnected) { clearInterval(timer); api.call('sync:pairable', false).then((s) => { S.sync = s; }).catch(() => {}); return; } draw(); }, 1500); $('[data-probe]', body).onclick = async () => { const ip = $('[name=ip]', body).value.trim(); if (ip) { try { await call('sync:probe', ip); toast(T('Device found'), 'ok'); draw(); } catch {} } }; } async function chooseSharedSecrets() { const sh = S.sync?.share || { keys: [], hosts: [], vpns: [] }; const sect = (title, c, items, label) => items.length ? `

${esc(title)}

${items.map((x) => ``).join('')}` : ''; const hostsPw = S.vault.hosts.filter((x) => x.password); const none = !S.vault.keys.length && !hostsPw.length && !S.vault.vpns.length; const body = h(`

${esc(T('Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.'))}

${sect(T('SSH keys'), 'keys', S.vault.keys, (x) => x.label)}${sect(T('Host passwords'), 'hosts', hostsPw, (x) => `${hostName(x)} (${x.username || '?'}@${x.address})`)}${sect(T('VPN configurations'), 'vpns', S.vault.vpns, (x) => x.label)}
${none ? `
${esc(T('There are no secrets on this device yet.'))}
` : ''}
`); page(T('Choose shared secrets'), body, { save: async (v) => { const picked = { keys: [], hosts: [], vpns: [] }; for (const [k, on] of Object.entries(v)) { const [c, id] = k.split('|'); if (on && picked[c]) picked[c].push(id); } S.sync = await call('sync:share', picked); toast(T('Saved'), 'ok'); }, }); } // Neues Geheimnis bei aktiver Synchronisation: fragen, ob es geteilt werden soll (Antwort wird gemerkt) async function askShareSecret(c, item, label) { try { const st = S.sync || (S.sync = 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: `

${esc(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' }, { label: T('Share'), value: 'yes', cls: 'primary' }] }); if (!r) return; S.sync = await api.call('sync:shareItem', c, item.id, r === 'yes'); } catch {} } api.on('sync:state', (st) => { S.sync = st; const card = $('.sync-card'); if (card && !card.contains(document.activeElement)) renderSyncCard(card); }); api.on('sync:changed', async () => { if (S.lock?.locked || !$('#lockScreen').hidden) return; S.vault = await api.call('vault:get'); if (!$('#main').hidden && !$('#layer').children.length) render(); }); api.on('sync:paired', (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 }))}

${esc(req.code.slice(0, 3))} ${esc(req.code.slice(3))}
`, buttons: [{ label: T('Codes differ'), value: false, cls: 'danger' }, { label: T('Codes match'), value: true, cls: 'primary' }], dismiss: false }); api.send('ask:reply', req.reqId, r === true); }); // ============================================================ Rückfragen des Backends 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 }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }], dismiss: false, }); api.send('ask:reply', req.reqId, r === true); }); api.on('secret:request', async (req) => { const r = await modal({ title: req.title || T('Authentication'), text: req.host, body: field(req.prompt || T('Password'), 'v', '', { type: req.echo ? 'text' : 'password' }), buttons: [{ label: T('Cancel'), value: null }, { label: T('Log in'), value: 'form', cls: 'primary' }], }); api.send('ask:reply', req.reqId, r ? r.v : null); }); api.on('toast', (m, t) => toast(m, t)); // ============================================================ Hilfen function copyText(t) { Native.copy({ text: t }).then(() => toast(T('Copied'), 'ok')).catch(() => toast(T('Copy failed'), 'error')); } // Android-Zurück: oberste Ebene schließen, dann Terminal verlassen, dann App in den Hintergrund function onBack() { const top = backStack[backStack.length - 1]; if (top) return top.close(); if (!$('#termScreen').hidden) return hideTerminal(); if (S.view !== 'hosts') { S.view = 'hosts'; render(); return; } Native.background?.().catch(() => {}); } window.mrtermBack = onBack; // wird von MainActivity bei der Zurück-Taste aufgerufen // MainActivity meldet die Bildschirmtastatur (WebView wird dann um ihre Höhe verkleinert) window.mrtermIme = (open) => { document.body.classList.toggle('kbd-open', open); setTimeout(fitViewport, 50); }; boot().catch((e) => { $('#boot').innerHTML = `
${esc(e.message || e)}
`; });