// 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)}
` : ''}
`);
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(`${esc(b.label)} `);
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(`${icon(it.icon)}${esc(it.label)} `);
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(`${icon('left')}
${esc(title)}
`);
const close = pushLayer(el);
$('[data-back]', el).onclick = close;
if (save) {
const b = h(`${esc(saveLabel)} `);
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 = `${o.options.map(([v, l]) => `${esc(l)} `).join('')} `;
else if (o.type === 'textarea') input = ``;
else if (o.type === 'password') input = `${icon('eye')}
`;
else input = ` `;
return `${esc(label)} ${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(`${icon('sync')} `);
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(`${icon('lock')} `);
b.onclick = lockNow;
top.append(b);
}
v.append(top);
const search = h(`${icon('search')} `);
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(` ${esc(s.title)} `);
c.onclick = () => showSession(s);
bar.append(c);
}
v.append(bar);
}
const box = h('
');
v.append(box);
const fab = h(`${icon('plus')} `);
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.'))}
${icon('plus')}${esc(T('New host'))} ${icon('sync')}${esc(T('Synchronization'))}
`));
$('[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())}
${live ? '
' : ''}
${icon('more')} `);
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(`${icon('trash')} `);
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(`${icon('refresh')}${esc(T('Try again'))} `);
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(`${k.icon ? icon(k.icon) : esc(k.label)} `);
// 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(`${icon('plus')} `);
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')}
${icon('more')} `);
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(`${icon('plus')} `);
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')}
${icon('more')} `);
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 }))}
${esc(T('Show'))} `);
$('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')}
${icon('trash')} `);
$('.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(`${esc(L.hasPassword ? T('Change') : T('Set password'))} `);
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'))}
${esc(T('Remove'))} `);
$('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'))}
${AUTOLOCK.map(([s, l]) => `${esc(T(l))} `).join('')} `);
$('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.'))}
${S.lock.bio && S.bio.enrolled !== false ? `${icon('finger')} ` : ''}`;
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)} ` : ''}
${icon('trash')} `);
$('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(`${icon('plus')}${esc(T('Pair new device'))} `); pair.onclick = openPairing;
const now = h(`${icon('sync')}${esc(T('Sync now'))} `);
now.onclick = async () => { now.disabled = true; try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} now.disabled = false; };
const sec = h(`${icon('key')}${esc(T('Choose shared secrets'))} `); 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(T('Pair'))} `);
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) => ` ${esc(label(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)}
`;
});