Files
MrTerm/mobile/web/app.js
T

1021 lines
56 KiB
JavaScript

// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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: '<path d="M12 5v14M5 12h14"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>',
more: '<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>',
folder: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
term: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M13 15h4"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 00-1-1H5a1 1 0 00-1 1v10a1 1 0 001 1h3"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
code: '<path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/>',
lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 018 0v4"/>',
sync: '<path d="M20 11a8 8 0 00-14.6-4.6L4 8M4 4v4h4M4 13a8 8 0 0014.6 4.6L20 16M20 20v-4h-4"/>',
eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
play: '<path d="M7 5l12 7-12 7z"/>',
x: '<path d="M6 6l12 12M18 6L6 18"/>',
paste: '<rect x="6" y="4" width="12" height="17" rx="2"/><path d="M9 4V3h6v1"/>',
zoomIn: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5M8 11h6M11 8v6"/>',
zoomOut: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5M8 11h6"/>',
refresh: '<path d="M20 11a8 8 0 10-2.3 5.7M20 4v7h-7"/>',
finger: '<path d="M12 11v4M8.5 8.5A5 5 0 0117 12v2a9 9 0 01-1 4M7 12a5 5 0 01.2-1.5M7 15.5a13 13 0 00-.5 3M12 19v2M4 13a8 8 0 0116-2M12 7a5 5 0 00-1 .1"/>',
shield: '<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/>',
devices: '<rect x="2" y="4" width="14" height="10" rx="1.5"/><path d="M6 18h6M9 14v4"/><rect x="17" y="8" width="5" height="12" rx="1"/>',
down: '<path d="M6 9l6 6 6-6"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v5h1"/>',
up: '<path d="M18 15l-6-6-6 6"/>', left: '<path d="M15 18l-6-6 6-6"/>', right: '<path d="M9 18l6-6-6-6"/>', downA: '<path d="M6 9l6 6 6-6"/>',
};
const icon = (n) => `<svg viewBox="0 0 24 24">${I[n] || ''}</svg>`;
// ============================================================ 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(`<div class="toast ${type}">${esc(msg)}</div>`);
$('#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(`<div class="scrim center"><div class="dialog"><h3>${esc(title)}</h3>${text ? `<p>${esc(text)}</p>` : ''}<form>${body}</form><div class="btns"></div></div></div>`);
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(`<button type="button" class="btn ${b.cls || ''}">${esc(b.label)}</button>`);
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(`<div class="scrim"><div class="sheet"><div class="grab"></div>${title ? `<div class="sheet-title">${esc(title)}</div>` : ''}</div></div>`);
const close = pushLayer(el);
for (const it of items.filter(Boolean)) {
const b = h(`<button class="opt ${it.danger ? 'danger' : ''}">${icon(it.icon)}<span>${esc(it.label)}</span></button>`);
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(`<div class="page"><div class="page-head"><button class="icon" data-back>${icon('left')}</button><h2>${esc(title)}</h2></div><div class="page-body"></div></div>`);
const close = pushLayer(el);
$('[data-back]', el).onclick = close;
if (save) {
const b = h(`<button class="btn primary">${esc(saveLabel)}</button>`);
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 = `<select name="${name}">${o.options.map(([v, l]) => `<option value="${esc(v)}" ${String(v) === String(value ?? '') ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
else if (o.type === 'textarea') input = `<textarea name="${name}" placeholder="${esc(o.placeholder || '')}" spellcheck="false">${esc(value)}</textarea>`;
else if (o.type === 'password') input = `<div class="pw"><input name="${name}" type="password" value="${esc(value)}" placeholder="${esc(o.placeholder || '')}" autocomplete="off"/><button type="button" data-eye>${icon('eye')}</button></div>`;
else input = `<input name="${name}" type="${o.type || 'text'}" value="${esc(value)}" placeholder="${esc(o.placeholder || '')}" ${o.inputmode ? `inputmode="${o.inputmode}"` : ''} autocapitalize="off" autocorrect="off" spellcheck="false"/>`;
return `<div class="field"><label>${esc(label)}</label>${input}${o.hint ? `<div class="hint">${esc(o.hint)}</div>` : ''}</div>`;
}
function wirePw(root) {
$$('[data-eye]', root).forEach((b) => { b.onclick = () => { const i = b.previousElementSibling; i.type = i.type === 'password' ? 'text' : 'password'; }; });
}
const switchEl = (checked) => `<label class="switch"><input type="checkbox" ${checked ? 'checked' : ''}/><span></span></label>`;
// ============================================================ 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(`<div class="top"><h1>Mr<span class="brand">Term</span></h1></div>`);
if (S.sync?.enabled) {
const on = S.sync.peers.some((p) => p.online);
const b = h(`<button class="icon" title="${esc(T('Synchronization'))}" style="color:${on ? 'var(--green)' : ''}">${icon('sync')}</button>`);
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(`<button class="icon" title="${esc(T('Lock'))}">${icon('lock')}</button>`);
b.onclick = lockNow;
top.append(b);
}
v.append(top);
const search = h(`<label class="search">${icon('search')}<input placeholder="${esc(T('Search hosts or enter user@host …'))}" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go"/></label>`);
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('<div class="sessions"></div>');
for (const s of S.sessions) {
const c = h(`<button class="chip"><span class="dot ${s.alive ? 'on' : s.connecting ? '' : 'err'}"></span>${esc(s.title)}</button>`);
c.onclick = () => showSession(s);
bar.append(c);
}
v.append(bar);
}
const box = h('<div></div>');
v.append(box);
const fab = h(`<button class="fab" aria-label="${esc(T('New host'))}">${icon('plus')}</button>`);
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(`<div class="empty"><div class="avatar soft">${icon('term')}</div><h3>${esc(T('No hosts yet'))}</h3><div>${esc(T('Add your first server or pair this phone with MrTerm on your computer to take over your hosts.'))}</div>
<div class="btns" style="justify-content:center;margin-top:18px"><button class="btn primary" data-a="new">${icon('plus')}${esc(T('New host'))}</button><button class="btn" data-a="sync">${icon('sync')}${esc(T('Synchronization'))}</button></div></div>`));
$('[data-a=new]', box).onclick = () => editHost({});
$('[data-a=sync]', box).onclick = () => { S.view = 'settings'; render(); };
return;
}
if (!hosts.length && q) {
box.append(h(`<div class="empty"><div>${esc(T('No matching hosts. Press Enter to connect to “{q}”.', { q: S.filter.trim() }))}</div></div>`));
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(`<div class="section">${k ? `<span style="color:${g.color || 'var(--accent)'}">${icon('folder')}</span>` : ''}<span style="flex:1">${esc(k ? groupPath(k) : T('Hosts'))}</span><span class="count">${list.length}</span>${k ? `<span style="color:var(--faint);transform:rotate(${collapsed ? -90 : 0}deg)">${icon('down')}</span>` : ''}</div>`);
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('<div class="list"></div>');
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(`<div class="item ${rdp ? 'disabled' : ''}"><div class="avatar" style="background:${colorFor(x)}">${esc((hostName(x)[0] || '?').toUpperCase())}</div>
<div class="meta"><div class="title">${esc(hostName(x))}</div><div class="sub">${rdp ? esc(T('RDP is not available on Android')) : esc(hostSub(x))}</div></div>
${live ? '<span class="dot on"></span>' : ''}<button class="more">${icon('more')}</button></div>`);
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(`<div>${field(T('Name'), 'label', g.label)}${field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: groupOptions(g.id) })}</div>`), {
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(`<div>
<div class="two">${field(T('Address'), 'address', x.address, { placeholder: '192.168.0.10', inputmode: 'url' })}${field(T('Port'), 'port', x.port || '', { placeholder: '22', inputmode: 'numeric' })}</div>
${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' })}
</div>`);
const del = isNew ? null : h(`<button class="icon" style="color:var(--red)">${icon('trash')}</button>`);
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('<div class="term-wrap" hidden></div>');
$('#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(`<div class="term-overlay"><div class="spinner"></div><div class="msg">${esc(T('Connecting …'))}</div></div>`);
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 = `<div style="color:var(--red);margin-bottom:6px">${esc(e.message)}</div>`;
const b = h(`<button class="btn primary">${icon('refresh')}${esc(T('Try again'))}</button>`);
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(`<span class="count">${S.sessions.length}</span>`));
}
function drawKeybar() {
const bar = $('#keybar');
bar.innerHTML = '';
for (const k of KEYS) {
const on = k.mod && mods[k.id];
const b = h(`<button class="${on ? 'on' : ''} ${on === 2 ? 'lock' : ''}">${k.icon ? icon(k.icon) : esc(k.label)}</button>`);
// 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(`<div class="top"><h1>${esc(T('Keys'))}</h1></div>`));
const fab = h(`<button class="fab">${icon('plus')}</button>`);
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(`<div class="empty"><div class="avatar soft">${icon('key')}</div><h3>${esc(T('No keys'))}</h3><div>${esc(T('Generate a new SSH key or import an existing one.'))}</div></div>`));
v.append(fab);
return;
}
const list = h('<div class="list"></div>');
for (const k of S.vault.keys) {
const type = (k.publicKey || '').split(' ')[0].replace('ssh-', '') || 'key';
const el = h(`<div class="item"><div class="avatar soft">${icon('key')}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub mono">${esc(type)} · ${esc((k.publicKey || '').split(' ')[1]?.slice(-16) || '')}</div></div><button class="more">${icon('more')}</button></div>`);
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(`<div>
${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' })}
</div>`), {
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: `<p>${esc(T('Add the public key to ~/.ssh/authorized_keys on your servers.'))}</p><div class="fp mono">${esc(k.publicKey)}</div>`, 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(`<div>
${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.') })}
</div>`), {
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(`<div class="top"><h1>${esc(T('Snippets'))}</h1></div>`));
const fab = h(`<button class="fab">${icon('plus')}</button>`);
fab.onclick = () => editSnippet({});
if (!S.vault.snippets.length) {
v.append(h(`<div class="empty"><div class="avatar soft">${icon('code')}</div><h3>${esc(T('No snippets'))}</h3><div>${esc(T('Save frequently used commands and send them to a terminal with one tap.'))}</div></div>`));
v.append(fab);
return;
}
const list = h('<div class="list"></div>');
for (const s of S.vault.snippets) {
const el = h(`<div class="item"><div class="avatar soft" style="color:var(--green);background:#3ecf8e22">${icon('code')}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}</div></div><button class="more">${icon('more')}</button></div>`);
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(`<div>${field(T('Name'), 'label', s.label)}${field(T('Command'), 'command', s.command, { type: 'textarea', placeholder: 'sudo apt update && sudo apt upgrade -y' })}<div class="hint">${esc(T('End the command with a line break to run it immediately.'))}</div></div>`), {
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(`<div class="top"><h1>${esc(T('Settings'))}</h1></div>`));
const st = settings();
// Sprache & Terminal
const gen = h(`<div class="card"><h3>${esc(T('General'))}</h3>
${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`]) })}
</div>`);
$('[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('<div class="card sync-card"></div>');
v.append(sc);
renderSyncCard(sc);
// Bekannte Hosts
const kh = h(`<div class="card"><div class="row" style="padding:0"><div class="grow"><h3>${esc(T('Known hosts'))}</h3><div class="sub">${esc(T('{n} saved host keys', { n: Object.keys(S.vault.knownHosts || {}).length }))}</div></div><button class="btn">${esc(T('Show'))}</button></div></div>`);
$('button', kh).onclick = showKnownHosts;
v.append(kh);
v.append(h(`<div class="card"><h3>MrTerm</h3><p>${esc(T('Version {v}', { v: S.info.version }))} · Android<br>${esc(T('Updates are installed with Obtainium.'))}</p></div>`));
}
function showKnownHosts() {
const body = h('<div></div>');
const draw = () => {
body.innerHTML = '';
const entries = Object.entries(S.vault.knownHosts || {});
if (!entries.length) { body.append(h(`<div class="empty">${esc(T('No known hosts yet.'))}</div>`)); return; }
const list = h('<div class="list"></div>');
for (const [id, kh] of entries) {
const el = h(`<div class="item"><div class="avatar soft">${icon('shield')}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(kh.fingerprint || '')}</div></div><button class="more" style="color:var(--red)">${icon('trash')}</button></div>`);
$('.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(`<div class="card"><h3>${esc(T('App lock'))}</h3><p>${esc(T('Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.'))}</p></div>`);
const pw = h(`<div class="row"><div class="grow"><b>${esc(T('Password'))}</b><div class="sub">${esc(L.hasPassword ? T('Set') : T('Not set'))}</div></div></div>`);
const setPw = h(`<button class="btn">${esc(L.hasPassword ? T('Change') : T('Set password'))}</button>`);
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(`<div class="row"><div class="grow"><b>${esc(T('Remove app lock'))}</b></div><button class="btn danger">${esc(T('Remove'))}</button></div>`);
$('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(`<div class="row"><div class="grow"><b>${esc(T('Unlock with fingerprint'))}</b><div class="sub">${esc(T('Uses the fingerprint or face unlock of this phone.'))}</div></div>${switchEl(L.bio)}</div>`);
$('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(`<div class="row"><div class="grow"><b>${esc(T('Lock when leaving the app'))}</b></div><select style="background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:8px">${AUTOLOCK.map(([s, l]) => `<option value="${s}" ${Number(settings().mobileAutoLock ?? 60) === s ? 'selected' : ''}>${esc(T(l))}</option>`).join('')}</select></div>`);
$('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 = `<img src="logo.png" alt=""/><h2>${esc(T('MrTerm is locked'))}</h2><p>${esc(T('Enter your password to unlock.'))}</p>
<form>${field(T('Password'), 'pw', '', { type: 'password' })}<button class="btn primary block">${esc(T('Unlock'))}</button></form>
${S.lock.bio && S.bio.enrolled !== false ? `<button class="bio-btn" aria-label="${esc(T('Unlock with fingerprint'))}">${icon('finger')}</button>` : ''}`;
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 = `<h3>${esc(T('Synchronization'))}</h3><p>${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.'))}</p>`;
const en = h(`<div class="row"><div class="grow"><b>${esc(T('Enable LAN synchronization'))}</b></div>${switchEl(st.enabled)}</div>`);
$('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(`<div>${field(T('Device name'), 'devName', st.deviceName)}</div>`);
$('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(`<div class="hint">${esc(T('No paired devices yet.'))}</div>`));
for (const p of st.peers) {
const row = h(`<div class="row"><span class="dot ${p.online ? 'on' : ''}"></span><div class="grow"><b>${esc(p.name)}</b><div class="sub">${esc(p.online ? T('online') : T('offline'))}${p.lastSync ? ` · ${esc(T('last sync {time}', { time: new Date(p.lastSync).toLocaleString(I18N.lang) }))}` : ''}${p.error ? ` · <span style="color:var(--red)">${esc(p.error)}</span>` : ''}</div></div><button class="icon" style="color:var(--red)">${icon('trash')}</button></div>`);
$('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('<div class="btns"></div>');
const pair = h(`<button class="btn primary">${icon('plus')}${esc(T('Pair new device'))}</button>`); pair.onclick = openPairing;
const now = h(`<button class="btn">${icon('sync')}${esc(T('Sync now'))}</button>`);
now.onclick = async () => { now.disabled = true; try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} now.disabled = false; };
const sec = h(`<button class="btn">${icon('key')}${esc(T('Choose shared secrets'))}</button>`); sec.onclick = chooseSharedSecrets;
acts.append(pair, now, sec);
card.append(acts);
card.append(h(`<div class="hint" style="font-size:12px;margin-top:12px">${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.'))}</div>`));
}
async function openPairing() {
S.sync = await call('sync:pairable', true);
const body = h(`<div><p style="margin-top:0;color:var(--muted)">${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.'))}</p><div class="pair-list"></div>
<div class="field" style="margin-top:16px"><label>${esc(T('Or enter an IP address'))}</label><div style="display:flex;gap:8px"><input name="ip" placeholder="192.168.0.25" inputmode="decimal" style="flex:1"/><button class="btn" data-probe>${esc(T('Search'))}</button></div></div></div>`);
const pg = page(T('Pair new device'), body);
const draw = () => {
const box = $('.pair-list', body);
const near = S.sync?.nearby || [];
box.innerHTML = near.length ? '' : `<div class="row" style="color:var(--muted)"><span class="spinner sm"></span>${esc(T('Searching for devices …'))}</div>`;
if (!near.length) return;
const list = h('<div class="list"></div>');
for (const d of near) {
const row = h(`<div class="item"><div class="avatar soft">${icon('devices')}</div><div class="meta"><div class="title">${esc(d.name)}</div><div class="sub">${esc(d.address)}${d.pairable ? '' : ` · ${esc(T('not ready for pairing'))}`}</div></div><button class="btn primary" ${d.pairable ? '' : 'disabled'}>${esc(T('Pair'))}</button></div>`);
const b = $('button', row);
b.onclick = async () => {
b.disabled = true; b.innerHTML = '<span class="spinner sm"></span>';
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 ? `<h4>${esc(title)}</h4>${items.map((x) => `<label><input type="checkbox" name="${c}|${esc(x.id)}" ${(sh[c] || []).includes(x.id) ? 'checked' : ''}/>${esc(label(x))}</label>`).join('')}` : '';
const hostsPw = S.vault.hosts.filter((x) => x.password);
const none = !S.vault.keys.length && !hostsPw.length && !S.vault.vpns.length;
const body = h(`<div><p style="margin-top:0;color:var(--muted)">${esc(T('Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.'))}</p>
<div class="checks">${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)}</div>
${none ? `<div class="hint">${esc(T('There are no secrets on this device yet.'))}</div>` : ''}</div>`);
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: `<p style="margin-top:0">${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(', ') }))}</p>`,
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: `<p>${esc(T('Pairing with “{name}”. Does the other device show the same code?', { name: req.name }))}</p><div class="pair-code">${esc(req.code.slice(0, 3))} ${esc(req.code.slice(3))}</div>`,
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: `<p>${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}</p>
<div class="fp mono">${esc(req.fingerprint)}</div>${changed ? `<p style="margin:12px 0 6px">${esc(T('Previously stored:'))}</p><div class="fp mono">${esc(req.previous)}</div>` : ''}`,
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 = `<div style="color:var(--red);padding:24px;text-align:center">${esc(e.message || e)}</div>`;
});