1038 lines
57 KiB
JavaScript
1038 lines
57 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||
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); };
|
||
|
||
// 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)));
|