Files

1079 lines
60 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 });
Promise.resolve(NodeJS.send({ eventName: 'call', args: [id, ch, args] })).catch((e) => { waits.delete(id); reject(e); });
});
},
send(ch, ...args) { NodeJS.send({ eventName: 'send', args: [ch, args] }).catch?.((e) => console.error(e)); },
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) => NodeJS.addListener('ready', () => r(true)));
const { key } = await Native.vaultKey();
// start() kehrt erst zurück, wenn Node beendet wird – daher nicht darauf warten, sondern auf whenReady()
NodeJS.start({ env: { MRTERM_VAULT_KEY: key, MRTERM_DEVICE: info.device, MRTERM_LOCALE: info.locale, MRTERM_VERSION: info.version } })
.catch((e) => { if (!/already been started/.test(e.message)) bootError(`Node.js: ${e.message}`); });
const timeout = (ms, what) => new Promise((_, rej) => setTimeout(() => rej(new Error(what)), ms));
await Promise.race([whenReady(), timeout(30000, 'The Node.js engine did not start.')]);
// Backend meldet sich mit 'ready'; nach einem Neuladen der Oberfläche läuft es schon → Ping genügt
await Promise.race([ready, api.call('app:version'), timeout(20000, 'The MrTerm backend did not respond.')]);
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();
applyTheme(S.lock.meta);
if (S.lock.locked) return showLock();
S.vault = await api.call('vault:get');
I18N.setLanguage(settings().language || 'auto', S.info.locale);
applyStatic();
applyTheme();
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() {
const id = settings().terminalTheme;
if (!id || id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
return TERM_THEMES[id] || TERM_THEMES.mrterm;
}
// App-Design (gemeinsam mit dem Desktop: lib/app-themes.css + APP_THEMES)
function applyTheme(st = settings()) {
const root = document.documentElement;
const t = APP_THEMES[st.appTheme] || APP_THEMES.midnight;
if (st.appTheme && st.appTheme !== 'midnight' && APP_THEMES[st.appTheme]) root.dataset.theme = st.appTheme; else delete root.dataset.theme;
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
root.classList.toggle('light', !!t.light);
S.sessions?.forEach((x) => { x.term.options.theme = termTheme(); });
document.documentElement.style.setProperty('--term-bg', termTheme().background);
syncBars();
}
// Statusleiste passend zur sichtbaren Fläche (Terminal oder App)
function syncBars() {
const t = APP_THEMES[settings().appTheme] || APP_THEMES.midnight;
const inTerm = !$('#termScreen').hidden;
const bg = inTerm ? getComputedStyle(document.querySelector('.term-head')).backgroundColor : getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g)?.map(Number) || [20, 22, 29];
const hex = '#' + rgb.slice(0, 3).map((n) => n.toString(16).padStart(2, '0')).join('');
const light = inTerm ? (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 > 150 : !!t.light;
Native.bars?.({ light, color: hex }).catch(() => {});
}
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);
syncBars();
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();
syncBars();
}
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, Design & 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)] })}
<div class="field"><label>Design</label><div class="theme-grid"></div></div>
<div class="field"><label>${esc(T('Accent color'))}</label><div class="colors"></div></div>
${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'auto', { type: 'select', options: [['auto', T('Match theme')], ...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>`);
const save = async (patch) => { await call('vault:settings', patch); Object.assign(S.vault.settings, patch); };
$('[name=language]', gen).onchange = async (e) => { await save({ language: e.target.value }); await reload(); };
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(`<button class="theme-prev ${(st.appTheme || 'midnight') === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div>${esc(T(t.name))}</button>`);
p.onclick = async () => { await save({ appTheme: id }); applyTheme(); render(); };
$('.theme-grid', gen).append(p);
}
['', ...COLORS.slice(0, 6), '#21c7a8', '#cba6f7', '#fcee0a'].forEach((col) => {
const b = h(`<button title="${esc(col || T('Theme default'))}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
b.onclick = async () => { await save({ accent: col }); applyTheme(); render(); };
$('.colors', gen).append(b);
});
$('[name=terminalTheme]', gen).onchange = async (e) => { await save({ terminalTheme: e.target.value }); applyTheme(); };
$('[name=mobileFontSize]', gen).onchange = async (e) => {
const n = Number(e.target.value); await save({ mobileFontSize: n });
S.sessions.forEach((x) => { x.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); };
// whenReady() wartet, bis die Node-Engine Nachrichten annimmt (vorher lehnt send() ab)
async function whenReady() {
for (;;) {
try { await NodeJS.whenReady(); return; } catch (e) {
if (!/not been started/.test(e.message)) throw e;
await new Promise((r) => setTimeout(r, 200));
}
}
}
function bootError(msg) {
const b = $('#boot');
if (b.hidden) return toast(msg, 'error');
b.innerHTML = `<img src="logo.png" alt=""/><div style="color:var(--red);padding:0 24px;text-align:center;max-width:420px">${esc(msg)}</div><div style="color:var(--muted);padding:0 24px;text-align:center;font-size:13px">Android ${esc(navigator.userAgent.match(/Android ([\d.]+)/)?.[1] || '')} · ${esc(S.info?.device || '')} · ${esc(S.info?.version || '')}</div>`;
}
boot().catch((e) => bootError(e.message || String(e)));