.apk bauen
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const crypto = require('crypto');
+const { execFileSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const WWW = path.join(__dirname, 'www');
+const version = require(path.join(ROOT, 'package.json')).version;
+const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: 'inherit', cwd: __dirname, ...opts });
+
+const PLUGIN = {
+ url: 'https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.10/capacitor-nodejs.tgz',
+ file: path.join(__dirname, 'vendor', 'capacitor-nodejs-1.0.0-beta.10.tgz'),
+ sha256: 'cc47cba4190d5e0ea8e2b33f4e9bd47e159570c01571c51214467e4c21e87bd3',
+};
+
+async function setup() {
+ if (!fs.existsSync(PLUGIN.file)) {
+ console.log('Lade capacitor-nodejs …');
+ const res = await fetch(PLUGIN.url);
+ if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
+ fs.mkdirSync(path.dirname(PLUGIN.file), { recursive: true });
+ fs.writeFileSync(PLUGIN.file, Buffer.from(await res.arrayBuffer()));
+ }
+ const sum = crypto.createHash('sha256').update(fs.readFileSync(PLUGIN.file)).digest('hex');
+ if (sum !== PLUGIN.sha256) { fs.rmSync(PLUGIN.file); throw new Error(`Prüfsumme von capacitor-nodejs stimmt nicht (${sum})`); }
+ run('npm', ['install']);
+}
+
+function copy(from, to) {
+ fs.mkdirSync(path.dirname(to), { recursive: true });
+ fs.cpSync(from, to, { recursive: true });
+}
+
+function buildWeb() {
+ fs.rmSync(WWW, { recursive: true, force: true });
+ copy(path.join(__dirname, 'web'), WWW);
+ const nm = path.join(ROOT, 'node_modules', '@xterm');
+ copy(path.join(nm, 'xterm', 'lib', 'xterm.js'), path.join(WWW, 'lib', 'xterm.js'));
+ copy(path.join(nm, 'xterm', 'css', 'xterm.css'), path.join(WWW, 'lib', 'xterm.css'));
+ copy(path.join(nm, 'addon-fit', 'lib', 'addon-fit.js'), path.join(WWW, 'lib', 'addon-fit.js'));
+ copy(path.join(ROOT, 'src', 'i18n.js'), path.join(WWW, 'lib', 'i18n.js'));
+ copy(path.join(ROOT, 'src', 'renderer', 'themes.js'), path.join(WWW, 'lib', 'themes.js'));
+ copy(path.join(ROOT, 'src', 'renderer', 'logo.png'), path.join(WWW, 'logo.png'));
+
+ // Node-Backend inkl. ssh2 in eine Datei bündeln (nodejs-mobile = Node 18)
+ require('esbuild').buildSync({
+ entryPoints: [path.join(__dirname, 'nodejs', 'server.js')],
+ outfile: path.join(WWW, 'nodejs', 'index.js'),
+ bundle: true, platform: 'node', target: 'node18', minify: true, legalComments: 'none',
+ external: ['bridge', 'cpu-features'],
+ alias: { electron: path.join(__dirname, 'nodejs', 'electron-shim.js') },
+ logLevel: 'warning',
+ });
+ fs.writeFileSync(path.join(WWW, 'nodejs', 'package.json'), JSON.stringify({ name: 'mrterm-backend', main: 'index.js' }));
+ run('npx', ['cap', 'sync', 'android']);
+}
+
+function toolchain() {
+ const base = path.join(os.homedir(), '.local', 'share', 'android-toolchain');
+ const env = { ...process.env };
+ env.JAVA_HOME ||= path.join(base, 'jdk');
+ env.ANDROID_HOME ||= path.join(base, 'sdk');
+ if (!fs.existsSync(env.JAVA_HOME) || !fs.existsSync(env.ANDROID_HOME)) throw new Error('JDK/Android-SDK nicht gefunden: JAVA_HOME und ANDROID_HOME setzen');
+ fs.writeFileSync(path.join(__dirname, 'android', 'local.properties'), `sdk.dir=${env.ANDROID_HOME}\n`);
+ return env;
+}
+
+function buildApk() {
+ if (!fs.existsSync(path.join(__dirname, 'android', 'keystore.properties'))) {
+ throw new Error('android/keystore.properties fehlt (Signaturschlüssel). Ohne ihn können Updates nicht installiert werden.');
+ }
+ run('./gradlew', ['--no-daemon', '-q', 'assembleRelease', `-PmrtermVersion=${version}`], { cwd: path.join(__dirname, 'android'), env: toolchain() });
+ const dir = path.join(__dirname, 'android', 'app', 'build', 'outputs', 'apk', 'release');
+ for (const abi of ['arm64-v8a', 'armeabi-v7a']) {
+ const out = path.join(ROOT, 'dist', `MrTerm-${version}-${abi}.apk`);
+ copy(path.join(dir, `app-${abi}-release.apk`), out);
+ console.log(`APK: ${out}`);
+ }
+}
+
+(async () => {
+ const args = process.argv.slice(2);
+ if (args.includes('--setup')) await setup();
+ else {
+ buildWeb();
+ if (args.includes('--apk')) buildApk();
+ }
+})().catch((e) => { console.error(e.message || e); process.exit(1); });
diff --git a/mobile/capacitor.config.json b/mobile/capacitor.config.json
new file mode 100644
index 0000000..1b53e30
--- /dev/null
+++ b/mobile/capacitor.config.json
@@ -0,0 +1,9 @@
+{
+ "appId": "de.mrterm.app",
+ "appName": "MrTerm",
+ "webDir": "www",
+ "android": { "backgroundColor": "#14161d" },
+ "plugins": {
+ "CapacitorNodeJS": { "nodeDir": "nodejs", "startMode": "manual" }
+ }
+}
diff --git a/mobile/dev/electron-dev.js b/mobile/dev/electron-dev.js
new file mode 100644
index 0000000..670ef61
--- /dev/null
+++ b/mobile/dev/electron-dev.js
@@ -0,0 +1,61 @@
+// Entwicklung ohne Handy: startet die gebaute Oberfläche (www/) in einem Fenster in Handygröße
+// und das Node-Backend im Electron-Hauptprozess, verbunden über eine nachgebaute Capacitor-Bridge.
+// cd mobile && node build.js && ../node_modules/.bin/electron dev/electron-dev.js [--user-data-dir=…]
+const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
+const path = require('path');
+const Module = require('module');
+const EventEmitter = require('events');
+const crypto = require('crypto');
+
+const WWW = path.join(__dirname, '..', 'www');
+const toWeb = new EventEmitter();
+const fromWeb = new EventEmitter();
+let win;
+
+// require('bridge') im Backend auf diese Nachbildung umleiten
+const bridge = {
+ channel: { send: (name, ...args) => toWeb.emit('msg', name, args), addListener: (name, fn) => fromWeb.on(name, fn) },
+ getDataPath: () => path.join(app.getPath('userData'), 'mobile-data'),
+};
+const resolve = Module._resolveFilename;
+Module._resolveFilename = function (req, ...rest) { return req === 'bridge' ? 'bridge' : resolve.call(this, req, ...rest); };
+require.cache.bridge = { id: 'bridge', filename: 'bridge', loaded: true, exports: bridge };
+
+toWeb.on('msg', (name, args) => { if (win && !win.isDestroyed()) win.webContents.send('dev:node', name, args); });
+ipcMain.on('dev:send', (_e, name, args) => fromWeb.emit(name, ...args));
+ipcMain.handle('dev:start', (_e, env) => {
+ Object.assign(process.env, env, { DATADIR: bridge.getDataPath() });
+ require('fs').mkdirSync(process.env.DATADIR, { recursive: true });
+ require(path.join(WWW, 'nodejs', 'index.js'));
+});
+ipcMain.handle('dev:native', (_e, method, arg) => {
+ if (method === 'vaultKey') return { key: crypto.createHash('sha256').update(app.getPath('userData')).digest('base64') };
+ if (method === 'info') return { device: 'Dev Phone', locale: process.env.MRTERM_DEV_LOCALE || 'en-US', version: require('../../package.json').version };
+ if (method === 'bioAvailable') return { available: true, enrolled: false };
+ if (method === 'bioEnroll' || method === 'bioUnlock') return { secret: Buffer.alloc(32, 1).toString('base64') };
+ if (method === 'copy') return clipboard.writeText(arg.text);
+ if (method === 'paste') return { text: clipboard.readText() };
+ return {};
+});
+
+app.whenReady().then(() => {
+ win = new BrowserWindow({
+ width: 412, height: 870, backgroundColor: '#14161d',
+ webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
+ });
+ win.loadFile(path.join(WWW, 'index.html'));
+ if (process.env.MRTERM_SHOT) {
+ const steps = JSON.parse(require('fs').readFileSync(process.env.MRTERM_SHOT, 'utf8'));
+ win.webContents.on('console-message', (e) => console.log('[r]', e.message));
+ win.webContents.once('did-finish-load', async () => {
+ await new Promise((r) => setTimeout(r, 2500));
+ for (const s of steps) {
+ if (s.js) await win.webContents.executeJavaScript(`(async()=>{${s.js}})();0`).catch((e) => console.log('js', e.message));
+ await new Promise((r) => setTimeout(r, s.wait || 900));
+ if (s.shot) require('fs').writeFileSync(path.join(process.env.MRTERM_SHOT_OUT, s.shot), (await win.webContents.capturePage()).toPNG());
+ }
+ app.exit(0);
+ });
+ }
+});
+app.on('window-all-closed', () => app.exit(0));
diff --git a/mobile/dev/preload.js b/mobile/dev/preload.js
new file mode 100644
index 0000000..e5e13d4
--- /dev/null
+++ b/mobile/dev/preload.js
@@ -0,0 +1,14 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+const listeners = new Map();
+ipcRenderer.on('dev:node', (_e, name, args) => listeners.get(name)?.forEach((fn) => fn({ args })));
+const native = (m) => (arg) => ipcRenderer.invoke('dev:native', m, arg);
+
+contextBridge.exposeInMainWorld('DevBridge', {
+ NodeJS: {
+ start: (o) => ipcRenderer.invoke('dev:start', o.env),
+ send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
+ addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
+ },
+ Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background'].map((m) => [m, native(m)])),
+});
diff --git a/mobile/nodejs/electron-shim.js b/mobile/nodejs/electron-shim.js
new file mode 100644
index 0000000..3a262aa
--- /dev/null
+++ b/mobile/nodejs/electron-shim.js
@@ -0,0 +1,30 @@
+// Ersatz für die von store.js genutzten Electron-APIs unter Android.
+// safeStorage verschlüsselt mit einem 256-Bit-Schlüssel, den die App im Android Keystore verpackt aufbewahrt
+// und beim Start als MRTERM_VAULT_KEY (base64) an den Node-Prozess übergibt.
+const crypto = require('crypto');
+
+const dataDir = () => process.env.DATADIR || require('bridge').getDataPath();
+const key = () => {
+ const k = Buffer.from(process.env.MRTERM_VAULT_KEY || '', 'base64');
+ return k.length === 32 ? k : null;
+};
+
+const safeStorage = {
+ isEncryptionAvailable: () => !!key(),
+ encryptString(plain) {
+ const iv = crypto.randomBytes(12);
+ const c = crypto.createCipheriv('aes-256-gcm', key(), iv);
+ const ct = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
+ return Buffer.concat([iv, c.getAuthTag(), ct]);
+ },
+ decryptString(buf) {
+ const d = crypto.createDecipheriv('aes-256-gcm', key(), buf.subarray(0, 12));
+ d.setAuthTag(buf.subarray(12, 28));
+ return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
+ },
+};
+
+module.exports = {
+ app: { getPath: () => dataDir(), getVersion: () => process.env.MRTERM_VERSION || '0.0.0', getLocale: () => process.env.MRTERM_LOCALE || 'en' },
+ safeStorage,
+};
diff --git a/mobile/nodejs/server.js b/mobile/nodejs/server.js
new file mode 100644
index 0000000..098c3a2
--- /dev/null
+++ b/mobile/nodejs/server.js
@@ -0,0 +1,199 @@
+// Node-Backend der Android-App. Nutzt dieselben Module wie der Electron-Hauptprozess
+// (Tresor, SSH, LAN-Sync, Docker, Firewall, Netzwerk) und spricht über die Capacitor-Bridge mit der Oberfläche.
+// Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente];
+// Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente].
+const { channel } = require('bridge');
+const crypto = require('crypto');
+const { utils: sshUtils } = require('ssh2');
+const { Store } = require('../../src/main/store');
+const { SshManager } = require('../../src/main/ssh');
+const { DockerManager } = require('../../src/main/docker');
+const { FirewallManager } = require('../../src/main/firewall');
+const { NetworkConfigManager } = require('../../src/main/network');
+const { SyncService } = require('../../src/main/sync');
+const i18n = require('../../src/i18n');
+
+const send = (ch, ...args) => channel.send('evt', ch, args);
+process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.message || String(e), 'error'); });
+process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
+
+const store = new Store();
+const applyLanguage = () => i18n.setLanguage(store.get().settings.language, process.env.MRTERM_LOCALE || 'en');
+
+// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
+const pending = new Map();
+function ask(ch, req, timeout = 0) {
+ return new Promise((resolve) => {
+ const reqId = crypto.randomUUID();
+ pending.set(reqId, resolve);
+ send(ch, { reqId, ...req });
+ if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
+ });
+}
+const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
+
+const confirmHostKey = async (target, fingerprint, known) =>
+ !!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
+const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
+
+const ssh = new SshManager(store, confirmHostKey, askSecret);
+const docker = new DockerManager(ssh);
+const firewall = new FirewallManager(ssh);
+const network = new NetworkConfigManager(ssh);
+const sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
+store.onChange = () => sync.schedule();
+
+const handlers = new Map();
+const OPEN_WHILE_LOCKED = new Set(['app:version']);
+const handle = (ch, fn) => handlers.set(ch, fn);
+
+channel.addListener('call', async (reqId, ch, args = []) => {
+ try {
+ const fn = handlers.get(ch);
+ if (!fn) throw new Error(`Unknown channel ${ch}`);
+ if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
+ channel.send('reply', reqId, true, await fn(...args));
+ } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
+});
+const listeners = {
+ 'ssh:write': (id, d) => ssh.write(id, d),
+ 'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
+ 'ssh:close': (id) => ssh.close(id),
+ 'ask:reply': answer,
+};
+channel.addListener('send', (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } });
+
+// ---------- App-Sperre: Passwort, zusätzlich Fingerabdruck (Geheimnis aus dem Android Keystore) ----------
+const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
+ crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
+const bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32));
+function lockStatus() {
+ const { language, appTheme, accent } = store.get().settings;
+ return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, meta: { language, appTheme, accent } };
+}
+const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
+const afterUnlock = () => { applyLanguage(); sync.start().catch(() => {}); };
+
+handle('lock:status', lockStatus);
+handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
+handle('lock:unlockPassword', async (pw) => {
+ const p = store.lock?.password;
+ if (!p) throw new Error(i18n.t('No password set.'));
+ const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
+ try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
+ afterUnlock();
+ return true;
+});
+handle('lock:unlockBio', (secret) => {
+ if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.'));
+ try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your password.')); }
+ afterUnlock();
+ return true;
+});
+handle('lock:setPassword', async (pw) => {
+ requireUnlocked();
+ if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
+ const salt = crypto.randomBytes(16), N = 2 ** 15;
+ const kek = await kdf(pw, salt, N);
+ store.lock = store.lock || { password: null, fido: [] };
+ store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
+ store.save();
+ return lockStatus();
+});
+handle('lock:removePassword', () => {
+ requireUnlocked();
+ if (store.lock) { store.lock.password = null; store.lock.bio = null; } // Fingerabdruck nur zusätzlich zum Passwort
+ store.dropLockIfEmpty();
+ store.save();
+ return lockStatus();
+});
+handle('lock:setBio', (secret) => {
+ requireUnlocked();
+ if (!store.lock?.password) throw new Error(i18n.t('Set a password first.'));
+ store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null;
+ store.save();
+ return lockStatus();
+});
+
+// ---------- Tresor ----------
+const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
+handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: 'android' }));
+handle('vault:upsert', (col, item) => store.upsert(col, item));
+handle('vault:remove', (col, id) => {
+ if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
+ return store.remove(col, id);
+});
+handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) applyLanguage(); });
+handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
+handle('app:version', () => process.env.MRTERM_VERSION || '0.0.0');
+
+// ---------- Schlüssel ----------
+handle('key:generate', ({ type, bits, comment, passphrase }) => {
+ const opts = { comment: comment || 'mrterm@android' };
+ if (type === 'rsa') opts.bits = Number(bits) || 4096;
+ if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
+ const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
+ return { privateKey: k.private, publicKey: k.public };
+});
+handle('key:parse', ({ privateKey, passphrase }) => {
+ const k = sshUtils.parseKey(privateKey, passphrase || undefined);
+ if (k instanceof Error) throw k;
+ const key = Array.isArray(k) ? k[0] : k;
+ return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
+});
+
+// ---------- SSH-Terminal ----------
+function hostWithOverrides(ref) {
+ if (typeof ref === 'string') {
+ const h = store.resolveHost(ref);
+ if (!h) throw new Error(i18n.t('Host not found'));
+ return h;
+ }
+ if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
+ return ref;
+}
+handle('ssh:open', async (sessionId, ref, size) => {
+ const host = hostWithOverrides(ref);
+ if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
+ await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
+ return true;
+});
+
+// ---------- Docker / Firewall / Netzwerk (gleiche Module wie am Desktop) ----------
+handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id)));
+handle('docker:list', (id) => docker.list(id));
+handle('docker:stats', (id) => docker.stats(id));
+handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
+handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
+handle('docker:close', (id) => docker.close(id));
+handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
+handle('firewall:list', (id, backend) => firewall.list(id, backend));
+handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
+handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
+handle('firewall:close', (id) => firewall.close(id));
+handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id)));
+handle('network:read', (id) => network.read(id));
+handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
+handle('network:remove', (id, model) => network.removeInterface(id, model));
+handle('network:hostname', (id, name) => network.setHostname(id, name));
+handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
+handle('network:close', (id) => network.close(id));
+
+// ---------- Synchronisation ----------
+handle('sync:status', () => sync.status());
+handle('sync:enable', (on, name) => sync.setEnabled(on, name));
+handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
+handle('sync:pair', (id) => sync.pair(id));
+handle('sync:unpair', (id) => sync.unpair(id));
+handle('sync:now', () => sync.syncAll());
+handle('sync:share', (sel) => sync.setShare(sel));
+handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
+handle('sync:probe', (address) => sync.probe(address));
+// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
+handle('app:pause', () => sync.stop());
+handle('app:resume', () => sync.start().catch(() => {}));
+
+store.load();
+applyLanguage();
+sync.start().catch(() => {});
+channel.send('ready');
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
new file mode 100644
index 0000000..9a1a0cf
--- /dev/null
+++ b/mobile/package-lock.json
@@ -0,0 +1,1731 @@
+{
+ "name": "mrterm-mobile",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mrterm-mobile",
+ "version": "0.0.0",
+ "dependencies": {
+ "@capacitor/android": "^8.5.2",
+ "@capacitor/core": "^8.5.2",
+ "capacitor-nodejs": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz"
+ },
+ "devDependencies": {
+ "@capacitor/cli": "^8.5.2",
+ "esbuild": "^0.28.2"
+ }
+ },
+ "node_modules/@capacitor/android": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
+ "integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": "^8.5.0"
+ }
+ },
+ "node_modules/@capacitor/cli": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
+ "integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ionic/cli-framework-output": "^2.2.8",
+ "@ionic/utils-subprocess": "^3.0.1",
+ "@ionic/utils-terminal": "^2.3.5",
+ "commander": "^12.1.0",
+ "debug": "^4.4.0",
+ "env-paths": "^2.2.0",
+ "fs-extra": "^11.2.0",
+ "kleur": "^4.1.5",
+ "native-run": "^2.0.3",
+ "open": "^8.4.0",
+ "plist": "^3.1.0",
+ "prompts": "^2.4.2",
+ "rimraf": "^6.0.1",
+ "semver": "^7.6.3",
+ "tar": "^7.5.3",
+ "tslib": "^2.8.1",
+ "xcode": "^3.0.1",
+ "xml2js": "^0.6.2"
+ },
+ "bin": {
+ "cap": "bin/capacitor",
+ "capacitor": "bin/capacitor"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@capacitor/core": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz",
+ "integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@ionic/cli-framework-output": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
+ "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ionic/utils-terminal": "2.3.5",
+ "debug": "^4.0.0",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@ionic/utils-array": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz",
+ "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.0.0",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@ionic/utils-fs": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz",
+ "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/fs-extra": "^8.0.0",
+ "debug": "^4.0.0",
+ "fs-extra": "^9.0.0",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@ionic/utils-fs/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@ionic/utils-object": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz",
+ "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.0.0",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@ionic/utils-process": {
+ "version": "2.1.12",
+ "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz",
+ "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ionic/utils-object": "2.1.6",
+ "@ionic/utils-terminal": "2.3.5",
+ "debug": "^4.0.0",
+ "signal-exit": "^3.0.3",
+ "tree-kill": "^1.2.2",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@ionic/utils-stream": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz",
+ "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.0.0",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@ionic/utils-subprocess": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz",
+ "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ionic/utils-array": "2.1.6",
+ "@ionic/utils-fs": "3.1.7",
+ "@ionic/utils-process": "2.1.12",
+ "@ionic/utils-stream": "3.1.7",
+ "@ionic/utils-terminal": "2.3.5",
+ "cross-spawn": "^7.0.3",
+ "debug": "^4.0.0",
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@ionic/utils-terminal": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz",
+ "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/slice-ansi": "^4.0.0",
+ "debug": "^4.0.0",
+ "signal-exit": "^3.0.3",
+ "slice-ansi": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0",
+ "tslib": "^2.0.1",
+ "untildify": "^4.0.0",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@types/fs-extra": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz",
+ "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
+ "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/slice-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz",
+ "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.9.12",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz",
+ "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.6"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/astral-regex": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/big-integer": {
+ "version": "1.6.52",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
+ "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
+ "dev": true,
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/bplist-creator": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
+ "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "stream-buffers": "2.2.x"
+ }
+ },
+ "node_modules/bplist-parser": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz",
+ "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "big-integer": "1.6.x"
+ },
+ "engines": {
+ "node": ">= 5.10.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/capacitor-nodejs": {
+ "version": "1.0.0-beta.10",
+ "resolved": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz",
+ "integrity": "sha512-5swca3bhfcLSKrRVYReNKR/gXwoG15bjTOOSjQRDSd6JgC9Yi1NeDng2dFGz24vqhTBt4huVQHe6lmmwfGEzHA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": ">=8.0.0"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/elementtree": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz",
+ "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "sax": "1.1.4"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "11.4.1",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz",
+ "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/glob": {
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
+ "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/native-run": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz",
+ "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ionic/utils-fs": "^3.1.7",
+ "@ionic/utils-terminal": "^2.3.4",
+ "bplist-parser": "^0.3.2",
+ "debug": "^4.3.4",
+ "elementtree": "^0.1.7",
+ "ini": "^4.1.1",
+ "plist": "^3.1.0",
+ "split2": "^4.2.0",
+ "through2": "^4.0.2",
+ "tslib": "^2.6.2",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "native-run": "bin/native-run"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/open": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/plist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
+ "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.9.10",
+ "base64-js": "^1.5.1",
+ "xmlbuilder": "^15.1.1"
+ },
+ "engines": {
+ "node": ">=10.4.0"
+ }
+ },
+ "node_modules/prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/prompts/node_modules/kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
+ "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "glob": "^13.0.3",
+ "package-json-from-dist": "^1.0.1"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/sax": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz",
+ "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/simple-plist": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz",
+ "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bplist-creator": "0.1.0",
+ "bplist-parser": "0.3.1",
+ "plist": "^3.0.5"
+ }
+ },
+ "node_modules/simple-plist/node_modules/bplist-parser": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz",
+ "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "big-integer": "1.6.x"
+ },
+ "engines": {
+ "node": ">= 5.10.0"
+ }
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/slice-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+ "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/stream-buffers": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
+ "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==",
+ "dev": true,
+ "license": "Unlicense",
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.22",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
+ "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/through2": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
+ "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "3"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/untildify": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
+ "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uuid": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
+ "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/xcode": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz",
+ "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "simple-plist": "^1.1.0",
+ "uuid": "^7.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xml2js/node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "15.1.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+ "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ }
+ }
+}
diff --git a/mobile/package.json b/mobile/package.json
new file mode 100644
index 0000000..7c73b9a
--- /dev/null
+++ b/mobile/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "mrterm-mobile",
+ "private": true,
+ "version": "0.0.0",
+ "description": "MrTerm for Android",
+ "scripts": {
+ "build": "node build.js",
+ "apk": "node build.js --apk"
+ },
+ "devDependencies": {
+ "@capacitor/cli": "^8.5.2",
+ "esbuild": "^0.28.2"
+ },
+ "allowScripts": {
+ "esbuild@0.28.2": true
+ },
+ "dependencies": {
+ "@capacitor/android": "^8.5.2",
+ "@capacitor/core": "^8.5.2",
+ "capacitor-nodejs": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz"
+ }
+}
diff --git a/mobile/web/app.js b/mobile/web/app.js
new file mode 100644
index 0000000..c839cd4
--- /dev/null
+++ b/mobile/web/app.js
@@ -0,0 +1,1020 @@
+// MrTerm für Android: Hosts, Terminal, Schlüssel, Snippets, LAN-Sync und App-Sperre.
+// Die Logik (SSH, Tresor, Sync) läuft im Node-Backend (nodejs/server.js), diese Datei ist nur die Oberfläche.
+'use strict';
+const T = (s, v) => I18N.t(s, v);
+const $ = (s, r = document) => r.querySelector(s);
+const $$ = (s, r = document) => [...r.querySelectorAll(s)];
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
+const uid = () => crypto.randomUUID();
+
+// ============================================================ Plattform
+// In der App: Capacitor-Plugins. Zum Entwickeln am Desktop stellt dev/electron-dev.js window.DevBridge bereit.
+const Cap = window.Capacitor;
+const NodeJS = window.DevBridge?.NodeJS || Cap.registerPlugin('CapacitorNodeJS');
+const Native = window.DevBridge?.Native || Cap.registerPlugin('MrTermNative');
+
+const waits = new Map();
+const events = new Map();
+let seq = 0;
+const api = {
+ call(ch, ...args) {
+ return new Promise((resolve, reject) => {
+ const id = ++seq;
+ waits.set(id, { resolve, reject });
+ NodeJS.send({ eventName: 'call', args: [id, ch, args] });
+ });
+ },
+ send(ch, ...args) { NodeJS.send({ eventName: 'send', args: [ch, args] }); },
+ on(ch, fn) { if (!events.has(ch)) events.set(ch, new Set()); events.get(ch).add(fn); return () => events.get(ch).delete(fn); },
+};
+NodeJS.addListener('reply', ({ args: [id, ok, value] }) => {
+ const w = waits.get(id);
+ if (!w) return;
+ waits.delete(id);
+ ok ? w.resolve(value) : w.reject(new Error(value));
+});
+NodeJS.addListener('evt', ({ args: [ch, a] }) => events.get(ch)?.forEach((fn) => { try { fn(...a); } catch (e) { console.error(e); } }));
+
+// Aufruf mit Fehlermeldung als Toast
+async function call(ch, ...args) {
+ try { return await api.call(ch, ...args); } catch (e) { toast(e.message, 'error'); throw e; }
+}
+
+// ============================================================ Icons
+const I = {
+ plus: ' ',
+ search: ' ',
+ more: ' ',
+ folder: ' ',
+ term: ' ',
+ edit: ' ',
+ trash: ' ',
+ copy: ' ',
+ key: ' ',
+ code: ' ',
+ lock: ' ',
+ sync: ' ',
+ eye: ' ',
+ play: ' ',
+ x: ' ',
+ paste: ' ',
+ zoomIn: ' ',
+ zoomOut: ' ',
+ refresh: ' ',
+ finger: ' ',
+ shield: ' ',
+ devices: ' ',
+ down: ' ',
+ info: ' ',
+ up: ' ', left: ' ', right: ' ', downA: ' ',
+};
+const icon = (n) => `${I[n] || ''} `;
+
+// ============================================================ Zustand
+const S = { vault: null, lock: null, sync: null, view: 'hosts', sessions: [], active: null, filter: '', collapsed: new Set(), bio: { available: false, enrolled: false } };
+const settings = () => S.vault?.settings || {};
+const COLORS = ['#6e7bff', '#3ecf8e', '#ffb347', '#ff5f6d', '#c792ea', '#56d6d6', '#f78c6c', '#82aaff'];
+function colorFor(x) {
+ if (x.color) return x.color;
+ let n = 0;
+ for (const c of String(x.label || x.address || '')) n = (n * 31 + c.charCodeAt(0)) >>> 0;
+ return COLORS[n % COLORS.length];
+}
+const hostName = (x) => x.label || x.address;
+const hostSub = (x) => `${x.username ? `${x.username}@` : ''}${x.address}${x.port && Number(x.port) !== 22 ? `:${x.port}` : ''}`;
+
+// ============================================================ Bausteine: Toast, Dialog, Sheet, Seite
+function toast(msg, type = '') {
+ const t = h(`${esc(msg)}
`);
+ $('#toasts').append(t);
+ setTimeout(() => t.remove(), type === 'error' ? 5000 : 2600);
+}
+
+// Hardware-Zurück schließt die oberste Ebene
+const backStack = [];
+function pushLayer(el, onClose) {
+ $('#layer').append(el);
+ const entry = { el, close: () => { el.remove(); const i = backStack.indexOf(entry); if (i >= 0) backStack.splice(i, 1); onClose?.(); } };
+ backStack.push(entry);
+ return entry.close;
+}
+
+// Dialog: buttons [{label, value, cls}], value 'form' liefert die Eingabefelder als Objekt
+function modal({ title, text, body = '', buttons = [{ label: T('OK'), value: true, cls: 'primary' }], dismiss = true }) {
+ return new Promise((resolve) => {
+ const el = h(`${esc(title)} ${text ? `
${esc(text)}
` : ''}
`);
+ let done = false;
+ const finish = (v) => { if (done) return; done = true; close(); resolve(v); };
+ const close = pushLayer(el, () => finish(null));
+ const form = $('form', el);
+ const collect = () => Object.fromEntries($$('input,select,textarea', form).filter((i) => i.name).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value]));
+ for (const b of buttons) {
+ const btn = h(`${esc(b.label)} `);
+ btn.onclick = () => finish(b.value === 'form' ? collect() : b.value);
+ $('.btns', el).append(btn);
+ }
+ form.onsubmit = (e) => { e.preventDefault(); const p = buttons.find((b) => b.cls?.includes('primary')); if (p) finish(p.value === 'form' ? collect() : p.value); };
+ if (dismiss) el.addEventListener('click', (e) => { if (e.target === el) finish(null); });
+ wirePw(el);
+ setTimeout(() => $('input:not([type=checkbox])', form)?.focus(), 60);
+ });
+}
+const confirmBox = async (title, text, ok = T('Delete'), cls = 'danger') =>
+ (await modal({ title, text, buttons: [{ label: T('Cancel'), value: false }, { label: ok, value: true, cls: `primary ${cls === 'danger' ? '' : ''}` }] })) === true;
+
+// Auswahlliste von unten: [{label, icon, danger, run}]
+function sheet(title, items) {
+ const el = h(`
${title ? `
${esc(title)}
` : ''}
`);
+ const close = pushLayer(el);
+ for (const it of items.filter(Boolean)) {
+ const b = h(`${icon(it.icon)}${esc(it.label)} `);
+ b.onclick = () => { close(); it.run(); };
+ $('.sheet', el).append(b);
+ }
+ el.addEventListener('click', (e) => { if (e.target === el) close(); });
+}
+
+// Vollbild-Formular mit Speichern-Knopf
+function page(title, body, { save, saveLabel = T('Save'), extra } = {}) {
+ const el = h(`${icon('left')}
${esc(title)}
`);
+ const close = pushLayer(el);
+ $('[data-back]', el).onclick = close;
+ if (save) {
+ const b = h(`${esc(saveLabel)} `);
+ b.onclick = async () => {
+ b.disabled = true;
+ try { if ((await save(collectForm($('.page-body', el)))) !== false) close(); } catch (e) { toast(e.message, 'error'); }
+ b.disabled = false;
+ };
+ $('.page-head', el).append(b);
+ }
+ if (extra) $('.page-head', el).insertBefore(extra, $('.page-head .btn', el));
+ $('.page-body', el).append(...[].concat(body));
+ wirePw(el);
+ return { el, close };
+}
+const collectForm = (root) => Object.fromEntries($$('input,select,textarea', root).filter((i) => i.name).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value]));
+
+function field(label, name, value = '', o = {}) {
+ let input;
+ if (o.type === 'select') input = `${o.options.map(([v, l]) => `${esc(l)} `).join('')} `;
+ else if (o.type === 'textarea') input = ``;
+ else if (o.type === 'password') input = `${icon('eye')}
`;
+ else input = ` `;
+ return `${esc(label)} ${input}${o.hint ? `
${esc(o.hint)}
` : ''}
`;
+}
+function wirePw(root) {
+ $$('[data-eye]', root).forEach((b) => { b.onclick = () => { const i = b.previousElementSibling; i.type = i.type === 'password' ? 'text' : 'password'; }; });
+}
+const switchEl = (checked) => ` `;
+
+// ============================================================ Start
+async function boot() {
+ const info = await Native.info().catch(() => ({ device: 'Android', locale: navigator.language, version: '0.0.0' }));
+ S.info = info;
+ I18N.setLanguage('auto', info.locale);
+ const ready = new Promise((r) => { const off = NodeJS.addListener('ready', () => r()); void off; });
+ const { key } = await Native.vaultKey();
+ await NodeJS.start({ env: { MRTERM_VAULT_KEY: key, MRTERM_DEVICE: info.device, MRTERM_LOCALE: info.locale, MRTERM_VERSION: info.version } });
+ await Promise.race([ready, new Promise((r) => setTimeout(r, 8000))]);
+ try { Object.assign(S.bio, await Native.bioAvailable()); } catch {}
+ Native.multicast({ on: true }).catch(() => {});
+ $('#boot').hidden = true;
+ await reload();
+}
+
+async function reload() {
+ S.lock = await api.call('lock:status');
+ I18N.setLanguage(S.lock.meta.language || 'auto', S.info.locale);
+ applyStatic();
+ if (S.lock.locked) return showLock();
+ S.vault = await api.call('vault:get');
+ I18N.setLanguage(settings().language || 'auto', S.info.locale);
+ applyStatic();
+ S.sync = await api.call('sync:status').catch(() => null);
+ $('#main').hidden = false;
+ render();
+}
+
+// Statische Texte in index.html übersetzen
+function applyStatic() {
+ $$('[data-t]').forEach((e) => { e.dataset.t ||= e.textContent; e.textContent = T(e.dataset.t); });
+ document.documentElement.lang = I18N.lang;
+}
+
+$$('#tabs button').forEach((b) => (b.onclick = () => { S.view = b.dataset.view; render(); $('#view').scrollTop = 0; }));
+
+function render() {
+ $$('#tabs button').forEach((b) => b.classList.toggle('active', b.dataset.view === S.view));
+ const v = $('#view');
+ v.innerHTML = '';
+ ({ hosts: viewHosts, keys: viewKeys, snippets: viewSnippets, settings: viewSettings })[S.view](v);
+}
+
+// ============================================================ Hosts
+function groupPath(id) {
+ const parts = [];
+ const seen = new Set();
+ let g = S.vault.groups.find((x) => x.id === id);
+ while (g && !seen.has(g.id)) { seen.add(g.id); parts.unshift(g.label); g = S.vault.groups.find((x) => x.id === g.parentId); }
+ return parts.join(' / ');
+}
+
+function viewHosts(v) {
+ const top = h(`
MrTerm `);
+ if (S.sync?.enabled) {
+ const on = S.sync.peers.some((p) => p.online);
+ const b = h(`${icon('sync')} `);
+ b.onclick = async () => { try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} };
+ top.append(b);
+ }
+ if (S.lock.enabled) {
+ const b = h(`${icon('lock')} `);
+ b.onclick = lockNow;
+ top.append(b);
+ }
+ v.append(top);
+
+ const search = h(`${icon('search')} `);
+ const input = $('input', search);
+ input.value = S.filter;
+ input.oninput = () => { S.filter = input.value; drawList(); };
+ input.onkeydown = (e) => { if (e.key === 'Enter') quickConnect(input.value); };
+ v.append(search);
+
+ if (S.sessions.length) {
+ const bar = h('
');
+ for (const s of S.sessions) {
+ const c = h(` ${esc(s.title)} `);
+ c.onclick = () => showSession(s);
+ bar.append(c);
+ }
+ v.append(bar);
+ }
+
+ const box = h('
');
+ v.append(box);
+ const fab = h(`${icon('plus')} `);
+ fab.onclick = () => sheet(null, [
+ { label: T('New host'), icon: 'term', run: () => editHost({}) },
+ { label: T('New group'), icon: 'folder', run: () => editGroup({}) },
+ ]);
+ v.append(fab);
+
+ function drawList() {
+ box.innerHTML = '';
+ const q = S.filter.trim().toLowerCase();
+ const hosts = S.vault.hosts.filter((x) => !q || [x.label, x.address, x.username, ...(x.tags || [])].some((f) => String(f || '').toLowerCase().includes(q)));
+ if (!S.vault.hosts.length) {
+ box.append(h(`${icon('term')}
${esc(T('No hosts yet'))} ${esc(T('Add your first server or pair this phone with MrTerm on your computer to take over your hosts.'))}
+
${icon('plus')}${esc(T('New host'))} ${icon('sync')}${esc(T('Synchronization'))}
`));
+ $('[data-a=new]', box).onclick = () => editHost({});
+ $('[data-a=sync]', box).onclick = () => { S.view = 'settings'; render(); };
+ return;
+ }
+ if (!hosts.length && q) {
+ box.append(h(`${esc(T('No matching hosts. Press Enter to connect to “{q}”.', { q: S.filter.trim() }))}
`));
+ return;
+ }
+ // Gruppen in Pfadreihenfolge, Hosts ohne Gruppe zuerst
+ const byGroup = new Map();
+ for (const x of hosts) {
+ const k = x.groupId && S.vault.groups.some((g) => g.id === x.groupId) ? x.groupId : '';
+ if (!byGroup.has(k)) byGroup.set(k, []);
+ byGroup.get(k).push(x);
+ }
+ const keys = [...byGroup.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : groupPath(a).localeCompare(groupPath(b))));
+ for (const k of keys) {
+ const list = byGroup.get(k).sort((a, b) => hostName(a).localeCompare(hostName(b)));
+ const g = S.vault.groups.find((x) => x.id === k);
+ const collapsed = !q && S.collapsed.has(k);
+ const head = h(`${k ? `${icon('folder')} ` : ''}${esc(k ? groupPath(k) : T('Hosts'))} ${list.length} ${k ? `${icon('down')} ` : ''}
`);
+ if (k) {
+ head.onclick = () => { S.collapsed.has(k) ? S.collapsed.delete(k) : S.collapsed.add(k); drawList(); };
+ head.oncontextmenu = (e) => { e.preventDefault(); groupMenu(g); };
+ let t; head.addEventListener('touchstart', () => { t = setTimeout(() => groupMenu(g), 550); }, { passive: true });
+ head.addEventListener('touchend', () => clearTimeout(t)); head.addEventListener('touchmove', () => clearTimeout(t), { passive: true });
+ }
+ box.append(head);
+ if (collapsed) continue;
+ const ul = h('
');
+ for (const x of list) ul.append(hostItem(x));
+ box.append(ul);
+ }
+ }
+ drawList();
+}
+
+function hostItem(x) {
+ const rdp = x.protocol === 'rdp';
+ const live = S.sessions.some((s) => s.host.id === x.id && s.alive);
+ const el = h(`${esc((hostName(x)[0] || '?').toUpperCase())}
+
+ ${live ? '
' : ''}
${icon('more')} `);
+ el.onclick = (e) => {
+ if (e.target.closest('.more')) return hostMenu(x);
+ if (rdp) return toast(T('RDP is not available on Android'));
+ openTerminal(x);
+ };
+ return el;
+}
+
+function hostMenu(x) {
+ const ssh = x.protocol !== 'rdp';
+ sheet(hostName(x), [
+ ssh && { label: T('Connect'), icon: 'term', run: () => openTerminal(x) },
+ { label: T('Edit'), icon: 'edit', run: () => editHost(x) },
+ { label: T('Duplicate'), icon: 'copy', run: async () => { const { id, createdAt, updatedAt, ...rest } = x; await call('vault:upsert', 'hosts', { ...rest, label: hostName(x) + T(' (copy)') }); reload(); } },
+ { label: T('Copy address'), icon: 'copy', run: () => copyText(hostSub(x)) },
+ { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hostName(x) }))) { await call('vault:remove', 'hosts', x.id); reload(); } } },
+ ]);
+}
+
+function groupMenu(g) {
+ sheet(g.label, [
+ { label: T('Edit'), icon: 'edit', run: () => editGroup(g) },
+ { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } },
+ ]);
+}
+
+function groupOptions(exclude) {
+ return [['', T('— No group —')], ...S.vault.groups.filter((g) => g.id !== exclude).map((g) => [g.id, groupPath(g.id)]).sort((a, b) => a[1].localeCompare(b[1]))];
+}
+
+function editGroup(g) {
+ page(g.id ? T('Edit group') : T('New group'), h(`${field(T('Name'), 'label', g.label)}${field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: groupOptions(g.id) })}
`), {
+ save: async (v) => {
+ if (!v.label.trim()) throw new Error(T('Please enter a name.'));
+ await call('vault:upsert', 'groups', { ...g, label: v.label.trim(), parentId: v.parentId || null });
+ reload();
+ },
+ });
+}
+
+function editHost(x) {
+ const isNew = !x.id;
+ const keys = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])];
+ const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((o) => o.id !== x.id && o.protocol !== 'rdp').map((o) => [o.id, hostName(o)])];
+ const body = h(`
+
${field(T('Address'), 'address', x.address, { placeholder: '192.168.0.10', inputmode: 'url' })}${field(T('Port'), 'port', x.port || '', { placeholder: '22', inputmode: 'numeric' })}
+ ${field(T('Label'), 'label', x.label)}
+ ${field(T('Username'), 'username', x.username, { placeholder: 'root' })}
+ ${field(T('Password'), 'password', x.password, { type: 'password', placeholder: T('optional') })}
+ ${field(T('SSH key'), 'keyId', x.keyId, { type: 'select', options: keys })}
+ ${field(T('Group'), 'groupId', x.groupId, { type: 'select', options: groupOptions() })}
+ ${field(T('Jump host (ProxyJump)'), 'jumpHostId', x.jumpHostId, { type: 'select', options: jumps })}
+ ${field(T('Startup command'), 'startupCommand', x.startupCommand, { placeholder: T('e.g. tmux attach || tmux') })}
+ ${field(T('Tags'), 'tags', (x.tags || []).join(', '), { placeholder: 'prod, web, db' })}
+
`);
+ const del = isNew ? null : h(`${icon('trash')} `);
+ const pg = page(isNew ? T('New host') : T('Edit host'), body, {
+ extra: del,
+ save: async (v) => {
+ if (!v.address.trim()) throw new Error(T('Please enter an address.'));
+ const item = {
+ ...x, ...v, protocol: x.protocol || 'ssh',
+ address: v.address.trim(), port: v.port ? Number(v.port) : undefined,
+ tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
+ groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null,
+ };
+ const saved = await call('vault:upsert', 'hosts', item);
+ if (item.password && item.password !== x.password) askShareSecret('hosts', saved, hostName(item));
+ reload();
+ },
+ });
+ if (del) del.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hostName(x) }))) { await call('vault:remove', 'hosts', x.id); pg.close(); reload(); } };
+}
+
+// user@host:port als temporäre Verbindung
+function quickConnect(q) {
+ const m = String(q).trim().match(/^(?:ssh:\/\/)?(?:([^@\s]+)@)?([^\s:@]+)(?::(\d+))?$/);
+ if (!m) return;
+ openTerminal({ label: m[2], address: m[2], username: m[1] || '', port: m[3] ? Number(m[3]) : 22, protocol: 'ssh', quick: true });
+}
+
+// ============================================================ Terminal
+const KEYS = [
+ { id: 'esc', label: 'Esc', seq: '\x1b' }, { id: 'tab', label: 'Tab', seq: '\t' },
+ { id: 'ctrl', label: 'Ctrl', mod: true }, { id: 'alt', label: 'Alt', mod: true },
+ { id: 'left', icon: 'left', seq: '\x1b[D', app: '\x1bOD' }, { id: 'up', icon: 'up', seq: '\x1b[A', app: '\x1bOA' },
+ { id: 'down', icon: 'downA', seq: '\x1b[B', app: '\x1bOB' }, { id: 'right', icon: 'right', seq: '\x1b[C', app: '\x1bOC' },
+ { label: '|', seq: '|' }, { label: '-', seq: '-' }, { label: '/', seq: '/' }, { label: '~', seq: '~' },
+ { label: 'Home', seq: '\x1b[H' }, { label: 'End', seq: '\x1b[F' }, { label: 'PgUp', seq: '\x1b[5~' }, { label: 'PgDn', seq: '\x1b[6~' },
+ { label: '^C', seq: '\x03' }, { label: '^D', seq: '\x04' }, { label: '^Z', seq: '\x1a' }, { label: '^R', seq: '\x12' },
+];
+const mods = { ctrl: 0, alt: 0 }; // 0 aus, 1 einmal, 2 festgestellt
+
+function termTheme() { return TERM_THEMES[settings().terminalTheme] || TERM_THEMES.mrterm; }
+const fontSize = () => Number(settings().mobileFontSize) || 12;
+
+class Session {
+ constructor(host) {
+ this.id = uid();
+ this.host = host;
+ this.title = hostName(host);
+ this.alive = false;
+ this.connecting = false;
+ this.el = h('
');
+ $('#terms').append(this.el);
+ this.term = new Terminal({
+ fontFamily: 'ui-monospace, "JetBrains Mono", "DejaVu Sans Mono", "Droid Sans Mono", monospace',
+ fontSize: fontSize(), theme: termTheme(), scrollback: 5000, cursorBlink: true, allowProposedApi: true,
+ });
+ this.fit = new FitAddon.FitAddon();
+ this.term.loadAddon(this.fit);
+ this.term.open(this.el);
+ const ta = this.term.textarea;
+ ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocorrect', 'off'); ta.setAttribute('spellcheck', 'false'); ta.setAttribute('autocomplete', 'off');
+ this.term.onData((d) => this.input(d));
+ this.term.onResize(({ cols, rows }) => { if (this.alive) api.send('ssh:resize', this.id, cols, rows); });
+ this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
+ this.pinch(this.el);
+ S.sessions.push(this);
+ showSession(this);
+ this.connect();
+ }
+
+ async connect() {
+ this.connecting = true;
+ this.overlay?.remove();
+ this.overlay = h(`
${esc(T('Connecting …'))}
`);
+ this.el.append(this.overlay);
+ updateTermHead();
+ this.refit();
+ try {
+ const ref = this.host.quick ? { ...this.host } : this.host.id;
+ await api.call('ssh:open', this.id, ref, { cols: this.term.cols, rows: this.term.rows });
+ this.alive = true;
+ this.overlay.remove(); this.overlay = null;
+ api.send('ssh:resize', this.id, this.term.cols, this.term.rows);
+ if (S.active === this) this.term.focus();
+ } catch (e) {
+ this.alive = false;
+ $('.spinner', this.overlay)?.remove();
+ $('.msg', this.overlay).innerHTML = `${esc(e.message)}
`;
+ const b = h(`${icon('refresh')}${esc(T('Try again'))} `);
+ b.onclick = () => this.connect();
+ this.overlay.append(b);
+ }
+ this.connecting = false;
+ updateTermHead();
+ }
+
+ input(d) {
+ if (mods.ctrl && d.length === 1) {
+ const c = d.toUpperCase().charCodeAt(0);
+ if (c >= 64 && c <= 95) d = String.fromCharCode(c - 64);
+ else if (d === ' ') d = '\x00';
+ if (mods.ctrl === 1) mods.ctrl = 0;
+ }
+ if (mods.alt) { d = '\x1b' + d; if (mods.alt === 1) mods.alt = 0; }
+ drawKeybar();
+ if (!this.alive) { if (d === '\r' && !this.connecting) this.connect(); return; }
+ api.send('ssh:write', this.id, d);
+ }
+
+ key(k) {
+ if (k.seq === undefined) return;
+ const app = k.app && this.term.modes.applicationCursorKeysMode;
+ this.input(app ? k.app : k.seq);
+ }
+
+ onEvent(type, payload) {
+ if (type === 'data') this.term.write(payload);
+ else if (type === 'status' && this.overlay) $('.msg', this.overlay).textContent = payload;
+ else if (type === 'error') this.term.write(`\r\n\x1b[31m${payload}\x1b[0m\r\n`);
+ else if (type === 'closed') {
+ if (!this.alive) return;
+ this.alive = false;
+ this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
+ updateTermHead();
+ }
+ }
+
+ refit() {
+ if (this.el.hidden) return;
+ try { this.fit.fit(); } catch {}
+ }
+
+ setFont(size) {
+ size = Math.max(7, Math.min(28, Math.round(size)));
+ if (size === this.term.options.fontSize) return;
+ S.sessions.forEach((s) => { s.term.options.fontSize = size; });
+ this.refit();
+ clearTimeout(this.fontSave);
+ this.fontSave = setTimeout(() => { api.call('vault:settings', { mobileFontSize: size }).catch(() => {}); S.vault.settings.mobileFontSize = size; }, 600);
+ }
+
+ // Zwei-Finger-Zoom ändert die Schriftgröße
+ pinch(el) {
+ let d0 = 0, f0 = 0;
+ const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
+ el.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { d0 = dist(e.touches); f0 = this.term.options.fontSize; } }, { passive: true });
+ el.addEventListener('touchmove', (e) => { if (e.touches.length === 2 && d0) { e.preventDefault(); this.setFont(f0 * (dist(e.touches) / d0)); } }, { passive: false });
+ el.addEventListener('touchend', () => { d0 = 0; }, { passive: true });
+ }
+
+ close() {
+ this.unsub();
+ api.send('ssh:close', this.id);
+ this.term.dispose();
+ this.el.remove();
+ S.sessions = S.sessions.filter((s) => s !== this);
+ }
+}
+
+function openTerminal(host) { return new Session(host); }
+
+function showSession(s) {
+ S.active = s;
+ S.sessions.forEach((x) => { x.el.hidden = x !== s; });
+ $('#termScreen').hidden = false;
+ $('#main').hidden = true;
+ document.documentElement.style.setProperty('--term-bg', termTheme().background);
+ updateTermHead();
+ drawKeybar();
+ requestAnimationFrame(() => { s.refit(); if (s.alive) s.term.focus(); });
+}
+
+function hideTerminal() {
+ $('#termScreen').hidden = true;
+ $('#main').hidden = false;
+ S.active?.term.blur();
+ S.active = null;
+ render();
+}
+
+function updateTermHead() {
+ const s = S.active;
+ if (!s) return;
+ $('#termTitle .name').textContent = s.title;
+ $('#termTitle .dot').className = `dot ${s.alive ? 'on' : s.connecting ? '' : 'err'}`;
+ $('#termTitle .count')?.remove();
+ if (S.sessions.length > 1) $('#termTitle .name').after(h(`${S.sessions.length} `));
+}
+
+function drawKeybar() {
+ const bar = $('#keybar');
+ bar.innerHTML = '';
+ for (const k of KEYS) {
+ const on = k.mod && mods[k.id];
+ const b = h(`${k.icon ? icon(k.icon) : esc(k.label)} `);
+ // pointerdown + preventDefault: Tastatur bleibt offen, Fokus bleibt im Terminal
+ b.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ if (!S.active) return;
+ if (k.mod) { mods[k.id] = (mods[k.id] + 1) % 3; drawKeybar(); return; }
+ S.active.key(k);
+ });
+ bar.append(b);
+ }
+}
+
+$('#termBack').onclick = hideTerminal;
+$('#termKbd').onclick = () => {
+ const s = S.active;
+ if (!s) return;
+ if (document.activeElement === s.term.textarea) s.term.blur(); else s.term.focus();
+};
+$('#termTitle').onclick = () => {
+ sheet(T('Sessions'), [
+ ...S.sessions.map((s) => ({ label: `${s === S.active ? '● ' : ''}${s.title}${s.alive ? '' : ` (${T('disconnected')})`}`, icon: 'term', run: () => showSession(s) })),
+ { label: T('New connection'), icon: 'plus', run: () => { hideTerminal(); } },
+ ]);
+};
+$('#termMenu').onclick = () => {
+ const s = S.active;
+ if (!s) return;
+ sheet(s.title, [
+ { label: T('Snippets'), icon: 'code', run: () => pickSnippet(s) },
+ { label: T('Paste'), icon: 'paste', run: async () => { const { text } = await Native.paste(); if (text) s.alive ? api.send('ssh:write', s.id, text) : null; } },
+ { label: T('Copy selection'), icon: 'copy', run: () => { const t = s.term.getSelection(); if (t) copyText(t); else toast(T('Nothing selected. Long-press the terminal to select text.')); } },
+ { label: T('Larger font'), icon: 'zoomIn', run: () => s.setFont(s.term.options.fontSize + 1) },
+ { label: T('Smaller font'), icon: 'zoomOut', run: () => s.setFont(s.term.options.fontSize - 1) },
+ s.alive ? { label: T('Disconnect'), icon: 'x', run: () => api.send('ssh:close', s.id) } : { label: T('Reconnect'), icon: 'refresh', run: () => s.connect() },
+ { label: T('Close session'), icon: 'trash', danger: true, run: () => {
+ s.close();
+ const next = S.sessions[S.sessions.length - 1];
+ next ? showSession(next) : hideTerminal();
+ } },
+ ]);
+};
+
+// Bildschirmtastatur verkleinert den sichtbaren Bereich: Terminal an visualViewport anpassen
+function fitViewport() {
+ const vv = window.visualViewport;
+ const scr = $('#termScreen');
+ if (vv) scr.style.height = `${vv.height}px`;
+ S.active?.refit();
+}
+window.visualViewport?.addEventListener('resize', fitViewport);
+window.addEventListener('resize', fitViewport);
+
+function pickSnippet(s) {
+ if (!S.vault.snippets.length) return toast(T('No snippets'));
+ sheet(T('Snippets'), S.vault.snippets.map((sn) => ({ label: sn.label, icon: 'code', run: () => { if (s.alive) api.send('ssh:write', s.id, sn.command.endsWith('\n') ? sn.command.replace(/\n$/, '\r') : sn.command); s.term.focus(); } })));
+}
+
+// ============================================================ Schlüssel
+function viewKeys(v) {
+ v.append(h(`
${esc(T('Keys'))} `));
+ const fab = h(`${icon('plus')} `);
+ fab.onclick = () => sheet(null, [
+ { label: T('Generate key'), icon: 'key', run: generateKey },
+ { label: T('Import key'), icon: 'paste', run: () => editKey({}) },
+ ]);
+ if (!S.vault.keys.length) {
+ v.append(h(`${icon('key')}
${esc(T('No keys'))} ${esc(T('Generate a new SSH key or import an existing one.'))}
`));
+ v.append(fab);
+ return;
+ }
+ const list = h('
');
+ for (const k of S.vault.keys) {
+ const type = (k.publicKey || '').split(' ')[0].replace('ssh-', '') || 'key';
+ const el = h(`${icon('key')}
${icon('more')} `);
+ el.onclick = () => sheet(k.label, [
+ { label: T('Copy public key'), icon: 'copy', run: () => copyText(k.publicKey) },
+ { label: T('Edit'), icon: 'edit', run: () => editKey(k) },
+ { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete key?'), k.label)) { await call('vault:remove', 'keys', k.id); reload(); } } },
+ ]);
+ list.append(el);
+ }
+ v.append(list, fab);
+}
+
+function generateKey() {
+ page(T('Generate key'), h(`
+ ${field(T('Name'), 'label', `${S.info.device.replace(/\s+/g, '-').toLowerCase()}`)}
+ ${field(T('Type'), 'type', 'ed25519', { type: 'select', options: [['ed25519', `Ed25519 (${T('recommended')})`], ['ecdsa', 'ECDSA'], ['rsa', 'RSA 4096']] })}
+ ${field('Passphrase (optional)', 'passphrase', '', { type: 'password' })}
+
`), {
+ saveLabel: T('Generate'),
+ save: async (v) => {
+ if (!v.label.trim()) throw new Error(T('Please enter a name.'));
+ const k = await api.call('key:generate', { type: v.type, passphrase: v.passphrase, comment: v.label.trim() });
+ const saved = await call('vault:upsert', 'keys', { label: v.label.trim(), privateKey: k.privateKey, publicKey: k.publicKey, passphrase: v.passphrase || undefined });
+ await reload();
+ askShareSecret('keys', saved, saved.label);
+ const r = await modal({ title: T('Key created'), body: `${esc(T('Add the public key to ~/.ssh/authorized_keys on your servers.'))}
${esc(k.publicKey)}
`, buttons: [{ label: T('Close'), value: false }, { label: T('Copy public key'), value: true, cls: 'primary' }] });
+ if (r) copyText(k.publicKey);
+ },
+ });
+}
+
+function editKey(k) {
+ page(k.id ? T('Edit key') : T('Import key'), h(`
+ ${field(T('Name'), 'label', k.label)}
+ ${field('Private Key', 'privateKey', k.privateKey, { type: 'textarea', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----' })}
+ ${field('Passphrase', 'passphrase', k.passphrase, { type: 'password' })}
+ ${field('Public Key', 'publicKey', k.publicKey, { type: 'textarea', hint: T('Derived from the private key automatically if left empty.') })}
+
`), {
+ save: async (v) => {
+ if (!v.privateKey.trim()) throw new Error(T('Private key is missing.'));
+ const parsed = await api.call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase });
+ const saved = await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey, passphrase: v.passphrase || undefined });
+ await reload();
+ if (!k.id) askShareSecret('keys', saved, saved.label);
+ },
+ });
+}
+
+// ============================================================ Snippets
+function viewSnippets(v) {
+ v.append(h(`
${esc(T('Snippets'))} `));
+ const fab = h(`${icon('plus')} `);
+ fab.onclick = () => editSnippet({});
+ if (!S.vault.snippets.length) {
+ v.append(h(`${icon('code')}
${esc(T('No snippets'))} ${esc(T('Save frequently used commands and send them to a terminal with one tap.'))}
`));
+ v.append(fab);
+ return;
+ }
+ const list = h('
');
+ for (const s of S.vault.snippets) {
+ const el = h(`${icon('code')}
${icon('more')} `);
+ el.onclick = () => sheet(s.label, [
+ S.sessions.some((x) => x.alive) && { label: T('Run in terminal'), icon: 'play', run: () => { const t = S.sessions.find((x) => x.alive); showSession(t); api.send('ssh:write', t.id, s.command.replace(/\n$/, '\r')); } },
+ { label: T('Copy'), icon: 'copy', run: () => copyText(s.command) },
+ { label: T('Edit'), icon: 'edit', run: () => editSnippet(s) },
+ { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete snippet?'), s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } } },
+ ]);
+ list.append(el);
+ }
+ v.append(list, fab);
+}
+
+function editSnippet(s) {
+ page(s.id ? T('Edit snippet') : T('New snippet'), h(`${field(T('Name'), 'label', s.label)}${field(T('Command'), 'command', s.command, { type: 'textarea', placeholder: 'sudo apt update && sudo apt upgrade -y' })}
${esc(T('End the command with a line break to run it immediately.'))}
`), {
+ save: async (v) => {
+ if (!v.command.trim()) throw new Error(T('Please enter a command.'));
+ await call('vault:upsert', 'snippets', { ...s, ...v, label: v.label || v.command.split('\n')[0].slice(0, 40) });
+ reload();
+ },
+ });
+}
+
+// ============================================================ Einstellungen
+function viewSettings(v) {
+ v.append(h(`
${esc(T('Settings'))} `));
+ const st = settings();
+
+ // Sprache & Terminal
+ const gen = h(`
${esc(T('General'))}
+ ${field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System language')], ...Object.entries(I18N.LANGUAGES)] })}
+ ${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'mrterm', { type: 'select', options: Object.entries(TERM_THEMES).map(([id, t]) => [id, t.name]) })}
+ ${field(T('Font size'), 'mobileFontSize', fontSize(), { type: 'select', options: [8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20].map((n) => [n, `${n} px`]) })}
+ `);
+ $('[name=language]', gen).onchange = async (e) => { await call('vault:settings', { language: e.target.value }); await reload(); };
+ $('[name=terminalTheme]', gen).onchange = async (e) => {
+ await call('vault:settings', { terminalTheme: e.target.value }); S.vault.settings.terminalTheme = e.target.value;
+ S.sessions.forEach((s) => { s.term.options.theme = termTheme(); });
+ };
+ $('[name=mobileFontSize]', gen).onchange = async (e) => {
+ const n = Number(e.target.value); await call('vault:settings', { mobileFontSize: n }); S.vault.settings.mobileFontSize = n;
+ S.sessions.forEach((s) => { s.term.options.fontSize = n; });
+ };
+ v.append(gen);
+
+ v.append(lockCard());
+ const sc = h('
');
+ v.append(sc);
+ renderSyncCard(sc);
+
+ // Bekannte Hosts
+ const kh = h(`${esc(T('Known hosts'))} ${esc(T('{n} saved host keys', { n: Object.keys(S.vault.knownHosts || {}).length }))}
${esc(T('Show'))} `);
+ $('button', kh).onclick = showKnownHosts;
+ v.append(kh);
+
+ v.append(h(`MrTerm ${esc(T('Version {v}', { v: S.info.version }))} · Android ${esc(T('Updates are installed with Obtainium.'))}
`));
+}
+
+function showKnownHosts() {
+ const body = h('
');
+ const draw = () => {
+ body.innerHTML = '';
+ const entries = Object.entries(S.vault.knownHosts || {});
+ if (!entries.length) { body.append(h(`${esc(T('No known hosts yet.'))}
`)); return; }
+ const list = h('
');
+ for (const [id, kh] of entries) {
+ const el = h(`${icon('shield')}
${icon('trash')} `);
+ $('.more', el).onclick = async () => { if (await confirmBox(T('Remove host key?'), id, T('Remove'))) { await call('vault:forgetHost', id); S.vault = await api.call('vault:get'); draw(); } };
+ list.append(el);
+ }
+ body.append(list);
+ };
+ draw();
+ page(T('Known hosts'), body);
+}
+
+// ---------- App-Sperre ----------
+const AUTOLOCK = [[0, 'Immediately'], [60, 'After 1 minute'], [300, 'After 5 minutes'], [-1, 'Never']];
+function lockCard() {
+ const L = S.lock;
+ const c = h(`${esc(T('App lock'))} ${esc(T('Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.'))}
`);
+ const pw = h(`${esc(T('Password'))} ${esc(L.hasPassword ? T('Set') : T('Not set'))}
`);
+ const setPw = h(`${esc(L.hasPassword ? T('Change') : T('Set password'))} `);
+ setPw.onclick = async () => {
+ const r = await modal({ title: T('Set password'), body: `${field(T('New password'), 'a', '', { type: 'password' })}${field(T('Repeat password'), 'b', '', { type: 'password' })}`, buttons: [{ label: T('Cancel'), value: null }, { label: T('Save'), value: 'form', cls: 'primary' }] });
+ if (!r) return;
+ if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
+ try { S.lock = await call('lock:setPassword', r.a); toast(T('Saved'), 'ok'); render(); } catch {}
+ };
+ pw.append(setPw);
+ c.append(pw);
+ if (L.hasPassword) {
+ const rm = h(`${esc(T('Remove app lock'))}
${esc(T('Remove'))} `);
+ $('button', rm).onclick = async () => {
+ if (!(await confirmBox(T('Remove app lock?'), T('MrTerm will open without a password.'), T('Remove')))) return;
+ S.lock = await call('lock:removePassword');
+ Native.bioDisable().catch(() => {});
+ render();
+ };
+ c.append(rm);
+ if (S.bio.available) {
+ const bio = h(`${esc(T('Unlock with fingerprint'))} ${esc(T('Uses the fingerprint or face unlock of this phone.'))}
${switchEl(L.bio)}
`);
+ $('input', bio).onchange = async (e) => {
+ try {
+ if (e.target.checked) {
+ const { secret } = await Native.bioEnroll({ title: T('Confirm fingerprint'), cancel: T('Cancel') });
+ S.lock = await call('lock:setBio', secret);
+ } else {
+ S.lock = await call('lock:setBio', null);
+ await Native.bioDisable();
+ }
+ } catch (err) { if (err.message !== 'cancelled') toast(err.message, 'error'); }
+ render();
+ };
+ c.append(bio);
+ }
+ const al = h(`${esc(T('Lock when leaving the app'))}
${AUTOLOCK.map(([s, l]) => `${esc(T(l))} `).join('')} `);
+ $('select', al).onchange = async (e) => { const n = Number(e.target.value); await call('vault:settings', { mobileAutoLock: n }); S.vault.settings.mobileAutoLock = n; };
+ c.append(al);
+ }
+ return c;
+}
+
+async function lockNow() {
+ S.lock = await api.call('lock:lock');
+ if (S.lock.locked) showLock();
+}
+
+function showLock() {
+ $('#main').hidden = true;
+ $('#termScreen').hidden = true;
+ $$('#layer > *').forEach((e) => e.remove());
+ backStack.length = 0;
+ const el = $('#lockScreen');
+ el.hidden = false;
+ el.innerHTML = `${esc(T('MrTerm is locked'))} ${esc(T('Enter your password to unlock.'))}
+
+ ${S.lock.bio && S.bio.enrolled !== false ? `${icon('finger')} ` : ''}`;
+ wirePw(el);
+ const done = async () => {
+ el.hidden = true;
+ el.innerHTML = '';
+ await reload();
+ if (S.active) showSession(S.active);
+ };
+ $('form', el).onsubmit = async (e) => {
+ e.preventDefault();
+ const pw = $('[name=pw]', el).value;
+ try { await api.call('lock:unlockPassword', pw); await done(); } catch (err) { toast(err.message, 'error'); $('[name=pw]', el).select(); }
+ };
+ const bioUnlock = async () => {
+ try {
+ const { secret } = await Native.bioUnlock({ title: T('Unlock MrTerm'), cancel: T('Use password') });
+ await api.call('lock:unlockBio', secret);
+ await done();
+ } catch (err) {
+ if (err.message === 'invalidated') toast(T('Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.'), 'error');
+ else if (err.message !== 'cancelled') toast(err.message, 'error');
+ $('[name=pw]', el)?.focus();
+ }
+ };
+ const bb = $('.bio-btn', el);
+ if (bb) { bb.onclick = bioUnlock; setTimeout(bioUnlock, 250); } else setTimeout(() => $('[name=pw]', el).focus(), 100);
+}
+
+// Automatisch sperren, wenn die App länger im Hintergrund war; Sync-Sockets im Hintergrund schließen
+let hiddenAt = 0;
+document.addEventListener('visibilitychange', async () => {
+ if (!S.lock || !S.vault) return;
+ if (document.hidden) {
+ hiddenAt = Date.now();
+ Native.multicast({ on: false }).catch(() => {});
+ return;
+ }
+ Native.multicast({ on: true }).catch(() => {});
+ const after = Number(settings().mobileAutoLock ?? 60);
+ if (S.lock.enabled && !S.lock.locked && after >= 0 && Date.now() - hiddenAt >= after * 1000) await lockNow();
+ else api.call('app:resume').catch(() => {});
+});
+
+// ============================================================ Synchronisation
+async function renderSyncCard(card) {
+ const st = S.sync || (S.sync = await api.call('sync:status').catch(() => null));
+ if (!st) return;
+ card.innerHTML = `${esc(T('Synchronization'))} ${esc(T('Take over hosts, groups, snippets and known hosts from MrTerm on your computer. The connection is end-to-end encrypted and stays in your local network.'))}
`;
+ const en = h(`${esc(T('Enable LAN synchronization'))}
${switchEl(st.enabled)}
`);
+ $('input', en).onchange = async (e) => { S.sync = await call('sync:enable', e.target.checked, st.deviceName || S.info.device); renderSyncCard(card); };
+ card.append(en);
+ if (!st.enabled) return;
+ const name = h(`${field(T('Device name'), 'devName', st.deviceName)}
`);
+ $('input', name).onchange = async (e) => { S.sync = await call('sync:enable', true, e.target.value.trim()); };
+ card.append(name);
+ if (!st.peers.length) card.append(h(`${esc(T('No paired devices yet.'))}
`));
+ for (const p of st.peers) {
+ const row = h(`${esc(p.name)} ${esc(p.online ? T('online') : T('offline'))}${p.lastSync ? ` · ${esc(T('last sync {time}', { time: new Date(p.lastSync).toLocaleString(I18N.lang) }))}` : ''}${p.error ? ` · ${esc(p.error)} ` : ''}
${icon('trash')} `);
+ $('button', row).onclick = async () => { if (await confirmBox(T('Unpair device?'), T('“{name}” will no longer synchronize with this device.', { name: p.name }), T('Unpair'))) { S.sync = await call('sync:unpair', p.id); renderSyncCard(card); } };
+ card.append(row);
+ }
+ const acts = h('
');
+ const pair = h(`${icon('plus')}${esc(T('Pair new device'))} `); pair.onclick = openPairing;
+ const now = h(`${icon('sync')}${esc(T('Sync now'))} `);
+ now.onclick = async () => { now.disabled = true; try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} now.disabled = false; };
+ const sec = h(`${icon('key')}${esc(T('Choose shared secrets'))} `); sec.onclick = chooseSharedSecrets;
+ acts.append(pair, now, sec);
+ card.append(acts);
+ card.append(h(`${esc(T('Both devices must be in the same Wi-Fi. If nothing is found, allow UDP port 47811 and TCP port 47812 in the firewall of your computer.'))}
`));
+}
+
+async function openPairing() {
+ S.sync = await call('sync:pairable', true);
+ const body = h(`${esc(T('Open “Pair new device” on the other device too, then select it here. Both devices show a code that you confirm on both sides.'))}
+
`);
+ const pg = page(T('Pair new device'), body);
+ const draw = () => {
+ const box = $('.pair-list', body);
+ const near = S.sync?.nearby || [];
+ box.innerHTML = near.length ? '' : ` ${esc(T('Searching for devices …'))}
`;
+ if (!near.length) return;
+ const list = h('
');
+ for (const d of near) {
+ const row = h(`${icon('devices')}
${esc(T('Pair'))} `);
+ const b = $('button', row);
+ b.onclick = async () => {
+ b.disabled = true; b.innerHTML = ' ';
+ try { await call('sync:pair', d.id); pg.close(); } catch {}
+ b.disabled = false; b.textContent = T('Pair');
+ };
+ list.append(row);
+ }
+ box.append(list);
+ };
+ draw();
+ const timer = setInterval(() => { if (!pg.el.isConnected) { clearInterval(timer); api.call('sync:pairable', false).then((s) => { S.sync = s; }).catch(() => {}); return; } draw(); }, 1500);
+ $('[data-probe]', body).onclick = async () => { const ip = $('[name=ip]', body).value.trim(); if (ip) { try { await call('sync:probe', ip); toast(T('Device found'), 'ok'); draw(); } catch {} } };
+}
+
+async function chooseSharedSecrets() {
+ const sh = S.sync?.share || { keys: [], hosts: [], vpns: [] };
+ const sect = (title, c, items, label) => items.length ? `${esc(title)} ${items.map((x) => ` ${esc(label(x))} `).join('')}` : '';
+ const hostsPw = S.vault.hosts.filter((x) => x.password);
+ const none = !S.vault.keys.length && !hostsPw.length && !S.vault.vpns.length;
+ const body = h(`${esc(T('Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.'))}
+
${sect(T('SSH keys'), 'keys', S.vault.keys, (x) => x.label)}${sect(T('Host passwords'), 'hosts', hostsPw, (x) => `${hostName(x)} (${x.username || '?'}@${x.address})`)}${sect(T('VPN configurations'), 'vpns', S.vault.vpns, (x) => x.label)}
+ ${none ? `
${esc(T('There are no secrets on this device yet.'))}
` : ''}
`);
+ page(T('Choose shared secrets'), body, {
+ save: async (v) => {
+ const picked = { keys: [], hosts: [], vpns: [] };
+ for (const [k, on] of Object.entries(v)) { const [c, id] = k.split('|'); if (on && picked[c]) picked[c].push(id); }
+ S.sync = await call('sync:share', picked);
+ toast(T('Saved'), 'ok');
+ },
+ });
+}
+
+// Neues Geheimnis bei aktiver Synchronisation: fragen, ob es geteilt werden soll (Antwort wird gemerkt)
+async function askShareSecret(c, item, label) {
+ try {
+ const st = S.sync || (S.sync = await api.call('sync:status'));
+ if (!st.enabled || !st.peers.length || !item?.id) return;
+ if ((st.share[c] || []).includes(item.id) || (st.share.declined?.[c] || []).includes(item.id)) return;
+ const what = { keys: T('SSH key'), hosts: T('Host password'), vpns: T('VPN configuration') }[c];
+ const r = await modal({ title: T('Share with paired devices?'), text: `${what}: ${label || ''}`,
+ body: `${esc(T('Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.', { names: st.peers.map((p) => p.name).join(', ') }))}
`,
+ buttons: [{ label: T("Don't share"), value: 'no' }, { label: T('Share'), value: 'yes', cls: 'primary' }] });
+ if (!r) return;
+ S.sync = await api.call('sync:shareItem', c, item.id, r === 'yes');
+ } catch {}
+}
+
+api.on('sync:state', (st) => {
+ S.sync = st;
+ const card = $('.sync-card');
+ if (card && !card.contains(document.activeElement)) renderSyncCard(card);
+});
+api.on('sync:changed', async () => {
+ if (S.lock?.locked || !$('#lockScreen').hidden) return;
+ S.vault = await api.call('vault:get');
+ if (!$('#main').hidden && !$('#layer').children.length) render();
+});
+api.on('sync:paired', (p) => { toast(T('Paired with {name}', { name: p.name }), 'ok'); chooseSharedSecrets(); });
+api.on('sync:pairPrompt', async (req) => {
+ const r = await modal({ title: T('Confirm pairing'), body: `${esc(T('Pairing with “{name}”. Does the other device show the same code?', { name: req.name }))}
${esc(req.code.slice(0, 3))} ${esc(req.code.slice(3))}
`,
+ buttons: [{ label: T('Codes differ'), value: false, cls: 'danger' }, { label: T('Codes match'), value: true, cls: 'primary' }], dismiss: false });
+ api.send('ask:reply', req.reqId, r === true);
+});
+
+// ============================================================ Rückfragen des Backends
+api.on('hostkey:request', async (req) => {
+ const changed = !!req.previous;
+ const r = await modal({
+ title: changed ? T('Host key has changed!') : T('Unknown host'),
+ body: `${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}
+ ${esc(req.fingerprint)}
${changed ? `${esc(T('Previously stored:'))}
${esc(req.previous)}
` : ''}`,
+ buttons: [{ label: T('Cancel'), value: false }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }],
+ dismiss: false,
+ });
+ api.send('ask:reply', req.reqId, r === true);
+});
+api.on('secret:request', async (req) => {
+ const r = await modal({
+ title: req.title || T('Authentication'), text: req.host,
+ body: field(req.prompt || T('Password'), 'v', '', { type: req.echo ? 'text' : 'password' }),
+ buttons: [{ label: T('Cancel'), value: null }, { label: T('Log in'), value: 'form', cls: 'primary' }],
+ });
+ api.send('ask:reply', req.reqId, r ? r.v : null);
+});
+api.on('toast', (m, t) => toast(m, t));
+
+// ============================================================ Hilfen
+function copyText(t) {
+ Native.copy({ text: t }).then(() => toast(T('Copied'), 'ok')).catch(() => toast(T('Copy failed'), 'error'));
+}
+
+// Android-Zurück: oberste Ebene schließen, dann Terminal verlassen, dann App in den Hintergrund
+function onBack() {
+ const top = backStack[backStack.length - 1];
+ if (top) return top.close();
+ if (!$('#termScreen').hidden) return hideTerminal();
+ if (S.view !== 'hosts') { S.view = 'hosts'; render(); return; }
+ Native.background?.().catch(() => {});
+}
+window.mrtermBack = onBack; // wird von MainActivity bei der Zurück-Taste aufgerufen
+// MainActivity meldet die Bildschirmtastatur (WebView wird dann um ihre Höhe verkleinert)
+window.mrtermIme = (open) => { document.body.classList.toggle('kbd-open', open); setTimeout(fitViewport, 50); };
+
+boot().catch((e) => {
+ $('#boot').innerHTML = `${esc(e.message || e)}
`;
+});
diff --git a/mobile/web/index.html b/mobile/web/index.html
new file mode 100644
index 0000000..debf687
--- /dev/null
+++ b/mobile/web/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ MrTerm
+
+
+
+
+
+
+
+
+
+
+ Hosts
+ Keys
+ Snippets
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/web/styles.css b/mobile/web/styles.css
new file mode 100644
index 0000000..29831c7
--- /dev/null
+++ b/mobile/web/styles.css
@@ -0,0 +1,190 @@
+:root {
+ --bg: #14161d;
+ --surface: #1c1f29;
+ --surface2: #252936;
+ --line: #2c3040;
+ --text: #e6e8ef;
+ --muted: #9aa0b4;
+ --faint: #5d6377;
+ --accent: #6e7bff;
+ --accent-soft: #6e7bff26;
+ --green: #3ecf8e;
+ --red: #ff5f6d;
+ --orange: #ffb347;
+ --radius: 14px;
+ /* Capacitor setzt --safe-area-inset-* bei Edge-to-Edge; env() als Rückfall */
+ --top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px));
+ --bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px));
+ color-scheme: dark;
+}
+* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden; }
+body { font: 15px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
+[hidden] { display: none !important; }
+button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
+input, select, textarea { font: inherit; color: var(--text); -webkit-user-select: text; user-select: text; }
+svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+.mono { font-family: ui-monospace, 'JetBrains Mono', 'DejaVu Sans Mono', monospace; }
+
+/* ---------- Start ---------- */
+#boot { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 28px; }
+#boot img { width: 84px; height: 84px; }
+.spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--surface2); border-top-color: var(--accent); animation: spin .8s linear infinite; }
+.spinner.sm { width: 16px; height: 16px; border-width: 2px; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* ---------- Grundlayout ---------- */
+#main { position: fixed; inset: 0; display: flex; flex-direction: column; }
+#view { flex: 1; overflow-y: auto; padding: calc(var(--top) + 8px) 16px 96px; -webkit-overflow-scrolling: touch; }
+#tabs { display: flex; background: var(--surface); border-top: 1px solid var(--line); padding-bottom: var(--bottom); }
+#tabs button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 9px 0 8px; color: var(--faint); font-size: 11.5px; font-weight: 600; }
+#tabs button.active { color: var(--accent); }
+
+.top { display: flex; align-items: center; gap: 10px; min-height: 52px; }
+.top h1 { font-size: 26px; margin: 0; flex: 1; letter-spacing: -.3px; }
+.top h1 .brand { color: var(--green); }
+.icon { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: var(--muted); }
+.icon:active { background: var(--surface2); }
+
+.search { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 0 12px; margin: 6px 0 14px; }
+.search svg { color: var(--faint); width: 18px; height: 18px; }
+.search input { flex: 1; background: none; border: 0; outline: 0; padding: 12px 0; }
+
+.section { margin: 18px 4px 8px; font-size: 12px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 8px; }
+.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
+.section svg { width: 16px; height: 16px; }
+
+.list { background: var(--surface); border-radius: var(--radius); overflow: hidden; }
+.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 62px; position: relative; }
+.item + .item::before { content: ''; position: absolute; top: 0; left: 64px; right: 0; border-top: 1px solid var(--line); }
+.item:active { background: var(--surface2); }
+.item .meta { flex: 1; min-width: 0; }
+.item .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.item .sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.item .more { color: var(--faint); width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; margin-right: -6px; }
+.item.disabled { opacity: .55; }
+.avatar { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; font-weight: 800; color: #fff; flex: none; font-size: 16px; }
+.avatar svg { width: 20px; height: 20px; }
+.avatar.soft { background: var(--accent-soft); color: var(--accent); }
+.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
+.dot.on { background: var(--green); }
+.dot.err { background: var(--red); }
+
+.sessions { display: flex; gap: 8px; overflow-x: auto; margin: 0 -16px 4px; padding: 2px 16px 8px; scrollbar-width: none; }
+.sessions::-webkit-scrollbar { display: none; }
+.chip { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; padding: 8px 14px; white-space: nowrap; font-weight: 600; font-size: 14px; }
+
+.empty { text-align: center; padding: 60px 24px; color: var(--muted); }
+.empty .avatar { margin: 0 auto 16px; width: 60px; height: 60px; border-radius: 18px; }
+.empty .avatar svg { width: 28px; height: 28px; }
+.empty h3 { color: var(--text); margin: 0 0 6px; }
+
+.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: 18px; background: var(--accent); color: #fff; display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
+.fab svg { width: 26px; height: 26px; }
+
+/* ---------- Karten / Einstellungen ---------- */
+.card { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 14px; }
+.card h3 { margin: 0 0 4px; font-size: 16px; }
+.card p, .hint { color: var(--muted); font-size: 13.5px; margin: 4px 0 10px; }
+.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
+.row + .row { border-top: 1px solid var(--line); }
+.row .grow { flex: 1; min-width: 0; }
+.row .sub { color: var(--muted); font-size: 13px; }
+.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
+
+.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); font-weight: 600; }
+.btn svg { width: 18px; height: 18px; }
+.btn.primary { background: var(--accent); color: #fff; }
+.btn.danger { color: var(--red); }
+.btn.block { width: 100%; }
+.btn:disabled { opacity: .5; }
+.btn:active { filter: brightness(1.15); }
+
+.field { margin-bottom: 14px; }
+.field label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted); margin: 0 2px 6px; }
+.field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px 13px; outline: 0; min-height: 48px; }
+.field textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, monospace; font-size: 13px; }
+.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
+.field .hint { margin: 6px 2px 0; font-size: 12.5px; }
+.pw { position: relative; }
+.pw input { padding-right: 48px; }
+.pw button { position: absolute; right: 4px; top: 4px; width: 40px; height: 40px; display: grid; place-items: center; color: var(--faint); }
+.two { display: grid; grid-template-columns: 1fr 96px; gap: 10px; }
+
+.switch { position: relative; width: 50px; height: 30px; flex: none; }
+.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
+.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
+.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
+.switch input:checked + span { background: var(--accent); }
+.switch input:checked + span::after { transform: translateX(20px); background: #fff; }
+
+.warn-box { border: 1px solid #ffb34755; background: #ffb34712; border-radius: 12px; padding: 10px 12px; color: var(--orange); font-size: 13px; margin: 8px 0; }
+
+/* ---------- Ebenen: Seiten, Sheets, Dialoge ---------- */
+#layer > * { position: fixed; inset: 0; z-index: 20; }
+.page { background: var(--bg); display: flex; flex-direction: column; animation: slidein .18s ease-out; }
+@keyframes slidein { from { transform: translateX(30px); opacity: 0; } }
+.page-head { display: flex; align-items: center; gap: 6px; padding: calc(var(--top) + 6px) 8px 6px; border-bottom: 1px solid var(--line); background: var(--surface); }
+.page-head h2 { flex: 1; font-size: 18px; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.page-head .btn { min-height: 38px; padding: 0 14px; }
+.page-body { flex: 1; overflow-y: auto; padding: 16px 16px calc(24px + var(--bottom)); }
+
+.scrim { background: #0009; display: flex; align-items: flex-end; animation: fade .15s; }
+.scrim.center { align-items: center; justify-content: center; padding: 20px; }
+@keyframes fade { from { opacity: 0; } }
+.sheet { width: 100%; background: var(--surface); border-radius: 20px 20px 0 0; padding: 8px 8px calc(10px + var(--bottom)); max-height: 85vh; overflow-y: auto; animation: up .18s ease-out; }
+@keyframes up { from { transform: translateY(40px); } }
+.sheet .grab { width: 38px; height: 4px; background: var(--line); border-radius: 4px; margin: 4px auto 8px; }
+.sheet .sheet-title { padding: 6px 12px 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
+.sheet button.opt { display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 14px; border-radius: 12px; text-align: left; font-weight: 500; }
+.sheet button.opt:active { background: var(--surface2); }
+.sheet button.opt.danger { color: var(--red); }
+.sheet button.opt svg { color: var(--muted); }
+.sheet button.opt.danger svg { color: var(--red); }
+
+.dialog { width: 100%; max-width: 420px; background: var(--surface); border-radius: 20px; padding: 20px; max-height: 88vh; overflow-y: auto; animation: pop .15s ease-out; }
+@keyframes pop { from { transform: scale(.96); opacity: 0; } }
+.dialog h3 { margin: 0 0 8px; font-size: 18px; }
+.dialog p { color: var(--muted); margin: 0 0 14px; }
+.dialog .btns { justify-content: flex-end; margin-top: 16px; }
+.fp { font-size: 12.5px; background: var(--bg); border-radius: 10px; padding: 10px; word-break: break-all; color: var(--text); -webkit-user-select: text; user-select: text; }
+.pair-code { font: 700 38px ui-monospace, monospace; letter-spacing: 6px; text-align: center; padding: 16px; background: var(--bg); border-radius: 14px; color: var(--accent); }
+.checks label { display: flex; align-items: center; gap: 12px; padding: 10px 2px; }
+.checks input { width: 20px; height: 20px; accent-color: var(--accent); }
+.checks h4 { margin: 12px 0 2px; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
+
+#toasts { position: fixed; left: 16px; right: 16px; bottom: calc(84px + var(--bottom)); z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
+.toast { background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 12px; padding: 12px 14px; box-shadow: 0 8px 24px #0008; animation: up .2s; font-size: 14px; }
+.toast.ok { border-color: var(--green); }
+.toast.error { border-color: var(--red); }
+
+/* ---------- Sperrbildschirm ---------- */
+#lockScreen { position: fixed; inset: 0; z-index: 40; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 14px; }
+#lockScreen img { width: 72px; height: 72px; margin-bottom: 6px; }
+#lockScreen h2 { margin: 0; }
+#lockScreen p { margin: 0 0 10px; color: var(--muted); text-align: center; }
+#lockScreen form { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 10px; }
+#lockScreen .field { margin: 0; }
+.bio-btn { width: 64px; height: 64px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; margin-top: 10px; }
+.bio-btn svg { width: 32px; height: 32px; }
+
+/* ---------- Terminal ---------- */
+#termScreen { position: fixed; left: 0; right: 0; top: 0; height: 100%; display: flex; flex-direction: column; background: var(--term-bg, #13151c); z-index: 10; }
+.term-head { display: flex; align-items: center; gap: 2px; padding: calc(var(--top) + 2px) 4px 2px; background: var(--surface); border-bottom: 1px solid var(--line); }
+.term-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 8px; font-weight: 700; }
+.term-title .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.term-title svg { width: 16px; height: 16px; color: var(--faint); }
+.term-title .count { background: var(--surface2); color: var(--muted); border-radius: 8px; padding: 0 7px; font-size: 12px; }
+#terms { flex: 1; position: relative; overflow: hidden; }
+.term-wrap { position: absolute; inset: 0; padding: 4px 2px 0 6px; }
+.term-wrap .xterm { height: 100%; }
+.term-wrap .xterm-viewport { background-color: transparent !important; }
+.term-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: var(--term-bg, #13151c); color: var(--muted); padding: 24px; text-align: center; z-index: 5; }
+#keybar { display: flex; gap: 6px; overflow-x: auto; padding: 6px 6px calc(6px + var(--bottom)); background: var(--surface); border-top: 1px solid var(--line); scrollbar-width: none; }
+#keybar::-webkit-scrollbar { display: none; }
+#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
+#keybar button svg { width: 18px; height: 18px; }
+#keybar button.on { background: var(--accent); color: #fff; }
+#keybar button.lock { box-shadow: inset 0 0 0 2px #fff; }
+body.kbd-open #keybar { padding-bottom: 6px; }
+body.kbd-open #tabs, body.kbd-open .fab { display: none; }
diff --git a/scripts/release-all.sh b/scripts/release-all.sh
index 5e4e7ae..5bf1453 100755
--- a/scripts/release-all.sh
+++ b/scripts/release-all.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-# Fragt nach der Version, baut Windows (Setup + Portable, per wine) und Arch/CachyOS (pacman + AppImage)
-# und lädt alles als Release v nach Gitea hoch.
+# Fragt nach der Version, baut Windows (Setup + Portable, per wine), Arch/CachyOS (pacman + AppImage)
+# und Android (APK, signiert mit mobile/android/keystore.properties) und lädt alles als Release v nach Gitea hoch.
# Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt).
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -31,5 +31,11 @@ git -c http.extraHeader="Authorization: Basic ${auth}" push origin HEAD
rm -rf dist
npm run dist:linux
npm run dist:win
+if [[ -f mobile/android/keystore.properties ]]; then
+ [[ -d mobile/node_modules/capacitor-nodejs ]] || (cd mobile && node build.js --setup)
+ (cd mobile && node build.js --apk)
+else
+ echo "mobile/android/keystore.properties fehlt – Android-APK wird übersprungen"
+fi
npm run release -- --notes "$notes" "${prerelease[@]}"
diff --git a/scripts/release.js b/scripts/release.js
index ce4d121..9e798b9 100644
--- a/scripts/release.js
+++ b/scripts/release.js
@@ -28,7 +28,7 @@ const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.a
rel = await res.json();
console.log('Release angelegt:', rel.html_url);
}
- const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman)$/.test(f));
+ const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman|apk)$/.test(f));
if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.');
for (const f of files) {
const old = (rel.assets || []).find((a) => a.name === f);
diff --git a/src/i18n.js b/src/i18n.js
index 4beba53..edc97f0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -597,6 +597,59 @@
'Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.': 'Soll dieses Geheimnis mit deinen gekoppelten Geräten ({names}) synchronisiert werden? Du kannst das später unter Einstellungen → Synchronisation ändern.',
'Don\'t share': 'Nicht teilen',
'Share': 'Teilen',
+ // Android-App
+ 'Lock': 'Sperren',
+ 'Add your first server or pair this phone with MrTerm on your computer to take over your hosts.': 'Lege deinen ersten Server an oder kopple dieses Handy mit MrTerm auf deinem Computer, um deine Hosts zu übernehmen.',
+ 'No matching hosts. Press Enter to connect to “{q}”.': 'Keine passenden Hosts. Drücke Enter, um dich mit „{q}“ zu verbinden.',
+ 'RDP is not available on Android': 'RDP ist unter Android nicht verfügbar',
+ 'Please enter a name.': 'Bitte einen Namen eingeben.',
+ 'Sessions': 'Sitzungen',
+ 'New connection': 'Neue Verbindung',
+ 'Paste': 'Einfügen',
+ 'Copy selection': 'Auswahl kopieren',
+ 'Nothing selected. Long-press the terminal to select text.': 'Nichts ausgewählt. Halte das Terminal gedrückt, um Text auszuwählen.',
+ 'Larger font': 'Schrift größer',
+ 'Smaller font': 'Schrift kleiner',
+ 'Close session': 'Sitzung schließen',
+ 'Generate a new SSH key or import an existing one.': 'Erzeuge einen neuen SSH-Schlüssel oder importiere einen vorhandenen.',
+ 'Key created': 'Schlüssel erstellt',
+ 'Add the public key to ~/.ssh/authorized_keys on your servers.': 'Trage den öffentlichen Schlüssel auf deinen Servern in ~/.ssh/authorized_keys ein.',
+ 'Save frequently used commands and send them to a terminal with one tap.': 'Speichere häufig genutzte Befehle und sende sie mit einem Tippen an ein Terminal.',
+ 'Run in terminal': 'Im Terminal ausführen',
+ 'Command': 'Befehl',
+ 'End the command with a line break to run it immediately.': 'Endet der Befehl mit einem Zeilenumbruch, wird er sofort ausgeführt.',
+ 'Please enter a command.': 'Bitte einen Befehl eingeben.',
+ 'General': 'Allgemein',
+ 'System language': 'Systemsprache',
+ 'Known hosts': 'Bekannte Hosts',
+ '{n} saved host keys': '{n} gespeicherte Hostschlüssel',
+ 'Show': 'Anzeigen',
+ 'Version {v}': 'Version {v}',
+ 'Updates are installed with Obtainium.': 'Updates werden mit Obtainium installiert.',
+ 'No known hosts yet.': 'Noch keine bekannten Hosts.',
+ 'Remove host key?': 'Hostschlüssel entfernen?',
+ 'Immediately': 'Sofort',
+ 'After 1 minute': 'Nach 1 Minute',
+ 'After 5 minutes': 'Nach 5 Minuten',
+ 'Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.': 'Schütze MrTerm mit einem Passwort und optional deinem Fingerabdruck. Der Tresor wird dann zusätzlich verschlüsselt.',
+ 'Remove app lock': 'App-Sperre entfernen',
+ 'Remove app lock?': 'App-Sperre entfernen?',
+ 'MrTerm will open without a password.': 'MrTerm öffnet sich dann ohne Passwort.',
+ 'Unlock with fingerprint': 'Mit Fingerabdruck entsperren',
+ 'Uses the fingerprint or face unlock of this phone.': 'Nutzt den Fingerabdruck- oder Gesichtsscanner dieses Handys.',
+ 'Confirm fingerprint': 'Fingerabdruck bestätigen',
+ 'Lock when leaving the app': 'Sperren beim Verlassen der App',
+ 'Enter your password to unlock.': 'Gib dein Passwort ein, um zu entsperren.',
+ 'Unlock MrTerm': 'MrTerm entsperren',
+ 'Use password': 'Passwort verwenden',
+ 'Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.': 'Deine Fingerabdrücke haben sich geändert. Entsperre mit deinem Passwort und richte das Entsperren per Fingerabdruck neu ein.',
+ '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.': 'Übernimm Hosts, Gruppen, Snippets und bekannte Hosts von MrTerm auf deinem Computer. Die Verbindung ist Ende-zu-Ende verschlüsselt und bleibt in deinem lokalen Netzwerk.',
+ '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.': 'Beide Geräte müssen im selben WLAN sein. Wird nichts gefunden, gib in der Firewall deines Computers UDP-Port 47811 und TCP-Port 47812 frei.',
+ 'Or enter an IP address': 'Oder IP-Adresse eingeben',
+ 'Copy failed': 'Kopieren fehlgeschlagen',
+ 'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.',
+ 'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.',
+ 'Set a password first.': 'Lege zuerst ein Passwort fest.',
// Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host',
diff --git a/src/main/ssh.js b/src/main/ssh.js
index aa7b82a..e0a5cdd 100644
--- a/src/main/ssh.js
+++ b/src/main/ssh.js
@@ -35,7 +35,7 @@ class SshManager {
const cfg = {
host: host.address,
port: Number(host.port) || 22,
- username: host.username || os.userInfo().username,
+ username: host.username || (() => { try { return os.userInfo().username; } catch { return 'root'; } })(),
readyTimeout: 20000,
keepaliveInterval: (this.store.get().settings.keepAlive || 0) * 1000,
tryKeyboard: true,
diff --git a/src/main/sync.js b/src/main/sync.js
index 2f4846d..72c1654 100644
--- a/src/main/sync.js
+++ b/src/main/sync.js
@@ -193,7 +193,7 @@ class SyncService {
d.sync ||= { enabled: false, peers: [], share: { keys: [], hosts: [], vpns: [] } };
const s = d.sync;
if (!s.deviceId) s.deviceId = crypto.randomUUID();
- if (!s.deviceName) s.deviceName = os.hostname();
+ if (!s.deviceName) s.deviceName = process.env.MRTERM_DEVICE || os.hostname();
s.peers ||= []; s.share ||= { keys: [], hosts: [], vpns: [] };
return s;
}
@@ -241,7 +241,9 @@ class SyncService {
// ------------------------------------------------ Finden
broadcastAddrs() {
const out = new Set(['255.255.255.255']);
- for (const list of Object.values(os.networkInterfaces())) {
+ let ifaces = {};
+ try { ifaces = os.networkInterfaces(); } catch {} // Android: je nach Version eingeschränkt
+ for (const list of Object.values(ifaces)) {
for (const a of list || []) {
if (a.family !== 'IPv4' || a.internal) continue;
const ip = a.address.split('.').map(Number), mask = a.netmask.split('.').map(Number);