Files
MrTerm/src/main/main.js
T
MrBlakeandClaude Opus 5.5 fd155fe2db Add web server, certificates and Samba/AD management tabs, a web server config generator, toggleable feature menus and an animated start page background
- Web server tab (nginx, Apache, Caddy, PHP-FPM): service control with autostart, config test and live logs, sites list with enable/disable/edit/delete, new site wizard (static, reverse proxy, PHP) and a code editor for all config files; every change is checked (nginx -t, apachectl configtest, caddy validate, php-fpm -t) before reloading and rolled back if the check fails
- Config generator: all options grouped and searchable (reverse proxy with load balancing and WebSockets, HTTPS/TLS, HSTS, security headers, IP allow list, basic auth, rate limit, gzip, caching, upload size, PHP, redirects, logs, custom directives), presets, live preview for nginx/Apache/Caddy; save as a site (needed Apache modules are enabled) or open in the editor first
- Certificates tab: Let's Encrypt via certbot (request with nginx/Apache/webroot/standalone/DNS challenge, renew, dry run, delete) in a terminal tab, acme.sh certificates, expiry of all found certificate files, auto-renew timer, self-signed certificates and a TLS endpoint check from this computer
- Samba/AD tab: shares (smb.conf edited section by section, checked with testparm), Samba users, services and connected clients; create a new AD domain (samba-tool domain provision), join or leave a domain (realmd/sssd) and manage domain users, groups, members and computers; passwords are passed via stdin
- Settings: feature menus (Docker, firewall, network, web server, certificates, Samba/AD) can be enabled or disabled for the host menu, terminal toolbar and quick search
- Start page: slowly drifting, blurred colour glows in the theme colours behind the vault (can be turned off in the design settings, respects reduced motion)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:18 +02:00

685 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { app, BrowserWindow, ipcMain, dialog, shell, clipboard, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const { utils: sshUtils } = require('ssh2');
const { Store } = require('./store');
const { SshManager } = require('./ssh');
const rdp = require('./rdp');
const { Updater } = require('./updater');
const { createEmbed, embedSupported } = require('./rdp-embed');
const i18n = require('../i18n');
const fido = require('./fido');
const { VpnManager } = require('./vpn');
const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall');
const { NetworkConfigManager } = require('./network');
const { WebServerManager } = require('./webserver');
const { CertManager, probe: probeTls } = require('./certs');
const { SambaManager } = require('./samba');
const { SyncService } = require('./sync');
const backup = require('../core/backup');
const composeTemplates = require('./compose-templates');
const { AiAssistant } = require('./ai');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win;
const store = new Store();
// Unter Linux muss MrTerm für eingebettete RDP-Tabs als X11-Client laufen (unter Wayland via XWayland).
// Die Wahl wird unverschlüsselt in launch.json gespeichert, weil sie vor dem Start des Fensters gebraucht wird.
const launchFile = path.join(app.getPath('userData'), 'launch.json');
function readLaunch() { try { return JSON.parse(fs.readFileSync(launchFile, 'utf8')); } catch { return {}; } }
// appendSwitch kommt für ozone-platform zu spät (Browser läuft schon unter Wayland, GPU-Prozess stürzt ab),
// daher einmalig mit dem Schalter auf der Kommandozeile neu starten.
if (process.platform === 'linux' && readLaunch().x11 !== false && !process.argv.some(a => a.startsWith('--ozone-platform'))) {
app.relaunch({ args: process.argv.slice(1).concat('--ozone-platform=x11') });
app.exit(0);
}
const pendingSecrets = new Map();
// Sicherheitsnetz: unerwartete Fehler (z. B. aus Netzwerkbibliotheken) als Meldung anzeigen statt den Hauptprozess abstürzen zu lassen
process.on('uncaughtException', (e) => {
console.error('Uncaught exception:', e);
send('toast', e?.message || String(e), 'error');
});
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
// Abgelöste Tabs laufen in eigenen Fenstern. Ereignisse gehen an alle Fenster (die Oberfläche filtert nach Sitzungs-ID),
// Passwortabfragen nur an das Fenster, das die Sitzung zeigt; Kopplung/Updates nur an das Hauptfenster.
const tabWins = new Set();
const sessionOwner = new Map(); // Sitzungs-ID -> BrowserWindow (nur für abgelöste Fenster)
const MAIN_ONLY = new Set(['sync:pairPrompt', 'update:available']);
const alive = (w) => w && !w.isDestroyed();
function send(channel, ...args) {
if (channel === 'secret:request') {
const w = sessionOwner.get(args[0]?.sessionId);
if (alive(w)) return w.webContents.send(channel, ...args);
}
if (alive(win)) win.webContents.send(channel, ...args);
if (!MAIN_ONLY.has(channel)) for (const w of tabWins) if (alive(w)) w.webContents.send(channel, ...args);
}
async function confirmHostKey(target, fingerprint, known) {
if (process.env.MRTERM_SMOKE) return true;
const changed = !!known;
const r = await dialog.showMessageBox(win, {
type: changed ? 'warning' : 'question',
title: changed ? i18n.t('Host key has changed!') : i18n.t('Unknown host'),
message: changed
? i18n.t('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: `${target.host}:${target.port}` })
: i18n.t('The authenticity of {target} cannot be verified.', { target: `${target.host}:${target.port}` }),
detail: `Fingerprint:\n${fingerprint}${changed ? `\n\n${i18n.t('Previously stored:')}\n${known.fingerprint}` : ''}`,
buttons: [changed ? i18n.t('Connect anyway & replace') : i18n.t('Trust & connect'), i18n.t('Cancel')],
defaultId: changed ? 1 : 0,
cancelId: 1,
});
return r.response === 0;
}
function askSecret(sessionId, req) {
return new Promise((resolve) => {
const reqId = crypto.randomUUID();
pendingSecrets.set(reqId, resolve);
send('secret:request', { reqId, sessionId, ...req });
});
}
const ssh = new SshManager(store, confirmHostKey, askSecret);
const updater = new Updater(store, send);
const vpn = new VpnManager(store, app.getPath('userData'));
const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
// Verwaltungs-Tabs mit gemeinsamen Kanälen <prefix>:open/read/op/close (siehe admin.js)
const admins = { web: new WebServerManager(ssh), certs: new CertManager(ssh), samba: new SambaManager(ssh) };
const ai = new AiAssistant(store, send);
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
const pairReplies = new Map();
const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
const reqId = crypto.randomUUID();
pairReplies.set(reqId, resolve);
send('sync:pairPrompt', { reqId, ...req });
setTimeout(() => { if (pairReplies.delete(reqId)) resolve(false); }, 115000);
}));
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
store.onChange = () => sync.schedule();
function newWindow(opts = {}) {
const w = new BrowserWindow({
width: 1360,
height: 860,
minWidth: 600,
minHeight: 400,
...opts,
backgroundColor: '#14161d',
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
frame: false,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
w.on('maximize', () => w.webContents.send('win:state', true));
w.on('unmaximize', () => w.webContents.send('win:state', false));
w.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
return w;
}
function createWindow() {
win = newWindow({ minWidth: 900, minHeight: 560 });
Menu.setApplicationMenu(null);
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
// Hauptfenster zu = MrTerm beenden, auch abgelöste Tab-Fenster schließen
win.on('closed', () => { for (const w of tabWins) if (alive(w)) w.close(); });
// Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen
const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } };
win.on('move', followRdp);
win.on('resize', followRdp);
win.on('restore', followRdp);
}
// Tab in ein eigenes Fenster lösen: data = { kind, host, id, title, … } wird vom neuen Fenster per win:handoff abgeholt
const handoffs = new Map();
function openTabWindow(data, pos) {
const token = crypto.randomUUID();
handoffs.set(token, data);
const w = newWindow({ width: 1000, height: 650, ...(pos ? { x: Math.round(pos.x - 120), y: Math.round(pos.y - 20) } : {}) });
tabWins.add(w);
w.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'), { query: { detached: token } });
// Fenster zu: dort noch offene Sitzungen beenden
w.on('closed', () => {
tabWins.delete(w);
for (const [id, o] of sessionOwner) {
if (o !== w) continue;
sessionOwner.delete(id);
for (const m of [ssh, docker, firewall, network, ...Object.values(admins)]) { try { m.close(id); } catch {} }
}
});
}
ipcMain.handle('win:detach', (_e, data, pos) => { openTabWindow(data, pos); return true; });
ipcMain.handle('win:handoff', (_e, token) => { const d = handoffs.get(token); handoffs.delete(token); return d || null; });
// Sitzung gehört (jetzt) dem aufrufenden Fenster; im Hauptfenster gilt keine Zuordnung
ipcMain.on('win:claim', (e, id) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w === win) sessionOwner.delete(id); else sessionOwner.set(id, w);
});
// Tab ins Hauptfenster zurück
ipcMain.handle('win:attach', (_e, data) => { if (alive(win)) { win.webContents.send('win:adopt', data); win.focus(); } return true; });
// Ein anderes Fenster hat den Tab übernommen (Drag & Drop): beim bisherigen Fenster ohne Trennen entfernen
ipcMain.on('win:released', (e, id) => {
for (const w of [win, ...tabWins]) if (alive(w) && w.webContents !== e.sender) w.webContents.send('win:released', id);
});
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
const OPEN_WHILE_LOCKED = new Set(['app:version']);
function handle(channel, fn) {
ipcMain.handle(channel, async (_e, ...args) => {
try {
if (store.locked && !channel.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(channel)) throw new Error(i18n.t('MrTerm is locked.'));
return { ok: true, value: await fn(...args) };
}
catch (e) { return { ok: false, error: e.message || String(e) }; }
});
}
// ---------- Fenster ----------
const senderWin = (e) => BrowserWindow.fromWebContents(e.sender);
ipcMain.on('win:min', (e) => senderWin(e)?.minimize());
ipcMain.on('win:max', (e) => { const w = senderWin(e); if (w) w.isMaximized() ? w.unmaximize() : w.maximize(); });
ipcMain.on('win:close', (e) => senderWin(e)?.close());
// ---------- App-Sperre (Passwort / FIDO2) ----------
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 fidoKek = (secret) => Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.alloc(0), 'mrterm-fido-kek', 32));
function lockStatus() {
const { language, appTheme, accent } = store.get().settings;
return {
enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password,
fido: (store.lock?.fido || []).map(({ id, label }) => ({ id, label })), meta: { language, appTheme, accent },
};
}
function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }
const ensureLock = () => (store.lock = store.lock || { password: null, fido: [] });
function afterUnlock() { applyLanguage(); scheduleUpdateCheck(); 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:unlockFido', async () => {
const list = store.lock?.fido || [];
if (!list.length) throw new Error(i18n.t('No security key registered.'));
const r = await fido.derive(win, list.map(({ credId, prfSalt }) => ({ credId, prfSalt })));
const entry = list.find((f) => f.credId === r.credId);
if (!entry) throw new Error(i18n.t('Unknown security key.'));
try { store.unlockWith(fidoKek(r.secret), entry.wrap); } catch { throw new Error(i18n.t('This security key could not unlock the vault.')); }
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);
ensureLock().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.dropLockIfEmpty();
store.save();
return lockStatus();
});
handle('lock:addFido', async (label) => {
requireUnlocked();
const credId = await fido.register(win);
const prfSalt = crypto.randomBytes(32).toString('base64url');
const r = await fido.derive(win, [{ credId, prfSalt }], i18n.t('Touch your security key again to finish.'));
ensureLock().fido.push({ id: crypto.randomUUID(), label: label || i18n.t('Security key'), credId, prfSalt, wrap: store.wrapDek(fidoKek(r.secret)) });
store.save();
return lockStatus();
});
handle('lock:removeFido', (id) => {
requireUnlocked();
if (store.lock) store.lock.fido = store.lock.fido.filter((f) => f.id !== id);
store.dropLockIfEmpty();
store.save();
return lockStatus();
});
// ---------- Vault ----------
// Kopplungsschlüssel und Löschvermerke bleiben im Hauptprozess
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: process.platform }));
handle('vault:upsert', async (col, item) => {
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
if (col === 'vpns' && item.id) {
const old = store.get().vpns.find((v) => v.id === item.id);
if (old && ['type', 'config', 'username', 'password'].some((k) => (old[k] || '') !== (item[k] || ''))) await vpn.forget(old);
}
return store.upsert(col, item);
});
handle('vault:remove', async (col, id) => {
if (col === 'vpns') {
await vpn.forget(store.get().vpns.find((v) => v.id === id));
store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
}
return store.remove(col, id);
});
// ---------- Docker ----------
handle('docker:open', async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return docker.open(id, host, () => 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('docker:stacks', (id, dir) => docker.stacks(id, dir));
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
handle('docker:templates', (url) => composeTemplates.list(url));
handle('docker:template', (index, url) => composeTemplates.build(index, url));
// ---------- Firewall ----------
handle('firewall:open', async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return firewall.open(id, host, () => 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));
// ---------- Netzwerk ----------
handle('network:open', async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return network.open(id, host, () => 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:readFile', (id, path) => network.readFile(id, path));
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
handle('network:close', (id) => network.close(id));
// ---------- Webserver / Zertifikate / Samba-AD ----------
for (const [p, m] of Object.entries(admins)) {
handle(`${p}:open`, async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return m.open(id, host, () => send(`${p}:closed`, id));
});
handle(`${p}:read`, (id) => m.read(id));
handle(`${p}:op`, (id, op, ...args) => m.op(id, op, ...args));
handle(`${p}:close`, (id) => m.close(id));
}
handle('certs:probe', (host, port) => probeTls(host, port));
// ---------- 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));
// ---------- Support-Assistent (Ollama) ----------
handle('ai:models', (url) => ai.models(url));
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
handle('ai:stop', (id) => ai.stop(id));
// ---------- VPN ----------
handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id));
handle('vpn:down', (id) => vpn.down(id));
handle('vault:settings', (s) => {
store.setSettings(s);
if ('language' in s) applyLanguage();
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
});
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
// Backup: vollständig, optional per Passwort verschlüsselt (src/core/backup.js)
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version: app.getVersion() }));
handle('backup:inspect', (content) => backup.inspectBackup(content));
handle('backup:import', (content, password, opts) => {
const r = backup.importBackup(store, content, password, opts);
if (opts?.settings) applyLanguage();
return r;
});
handle('backup:saveFile', async (content, name) => {
const r = await dialog.showSaveDialog(win, { defaultPath: name, filters: [{ name: 'MrTerm Backup', extensions: ['json'] }] });
if (r.canceled) return false;
fs.writeFileSync(r.filePath, content, { mode: 0o600 });
return r.filePath;
});
// Import aus ~/.ssh/config
handle('vault:importSshConfig', () => {
const file = path.join(os.homedir(), '.ssh', 'config');
if (!fs.existsSync(file)) throw new Error(i18n.t('~/.ssh/config not found'));
const entries = [];
let cur = null;
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const [k, ...rest] = line.split(/\s+/);
const v = rest.join(' ');
if (k.toLowerCase() === 'host') {
cur = v.includes('*') ? null : { label: v, address: v, port: 22, protocol: 'ssh' };
if (cur) entries.push(cur);
} else if (cur) {
const key = k.toLowerCase();
if (key === 'hostname') cur.address = v;
else if (key === 'user') cur.username = v;
else if (key === 'port') cur.port = Number(v);
else if (key === 'identityfile') cur._identity = v.replace(/^~/, os.homedir());
}
}
let n = 0;
for (const e of entries) {
if (store.get().hosts.some((h) => h.address === e.address && h.username === e.username && Number(h.port) === e.port)) continue;
if (e._identity && fs.existsSync(e._identity)) {
const pk = fs.readFileSync(e._identity, 'utf8');
let key = store.get().keys.find((k) => k.privateKey === pk);
if (!key) key = store.upsert('keys', { label: path.basename(e._identity), privateKey: pk, publicKey: readPub(e._identity) });
e.keyId = key.id;
}
delete e._identity;
store.upsert('hosts', e);
n++;
}
return n;
});
function readPub(p) {
try { return fs.readFileSync(p + '.pub', 'utf8').trim(); } catch { return ''; }
}
// ---------- Schlüssel ----------
handle('key:generate', ({ type, bits, comment, passphrase }) => {
const opts = { comment: comment || `${os.userInfo().username}@mrterm` };
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() };
});
handle('key:pickFile', async () => {
const r = await dialog.showOpenDialog(win, { defaultPath: path.join(os.homedir(), '.ssh'), properties: ['openFile', 'showHiddenFiles'] });
if (r.canceled) return null;
return { name: path.basename(r.filePaths[0]), content: fs.readFileSync(r.filePaths[0], 'utf8'), publicKey: readPub(r.filePaths[0]) };
});
// ---------- SSH-Terminal ----------
function hostWithOverrides(hostOrId) {
if (typeof hostOrId === 'string') {
const h = store.resolveHost(hostOrId);
if (!h) throw new Error(i18n.t('Host not found'));
return h;
}
// Gespeicherter Host mit Zusätzen, z. B. { ref, execCommand } für Container-Shells
if (hostOrId?.ref) return { ...hostWithOverrides(hostOrId.ref), execCommand: hostOrId.execCommand, label: hostOrId.label };
return hostOrId; // Quick-Connect: temporärer Host
}
handle('ssh:open', async (sessionId, hostRef, size) => {
const host = hostWithOverrides(hostRef);
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
return true;
});
ipcMain.on('ssh:write', (_e, id, data) => ssh.write(id, data));
ipcMain.on('ssh:resize', (_e, id, cols, rows) => ssh.resize(id, cols, rows));
ipcMain.on('ssh:close', (_e, id) => ssh.close(id));
ipcMain.on('secret:reply', (_e, reqId, value) => {
pendingSecrets.get(reqId)?.(value);
pendingSecrets.delete(reqId);
});
// ---------- SFTP ----------
handle('sftp:open', async (sessionId, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return ssh.openSftp(sessionId, host);
});
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
// Dateien für den SFTP-Upload auswählen (Mehrfachauswahl)
handle('sftp:pickUpload', async () => {
const r = await dialog.showOpenDialog(win, { title: i18n.t('Upload'), properties: ['openFile', 'multiSelections', 'showHiddenFiles'] });
return r.canceled ? [] : r.filePaths.map((p) => ({ path: p, name: path.basename(p) }));
});
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
let last = 0;
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
const now = Date.now();
if (now - last > 100 || done === total) { last = now; send('sftp:progress', transferId, done, total, dir === 'download' ? remotePath : localPath); }
});
return true;
});
handle('sftp:close', (id) => ssh.close(id));
// Lokales Dateisystem (für den Dual-Pane-SFTP-Browser)
handle('local:home', () => os.homedir());
handle('local:list', (dir) => fs.readdirSync(dir, { withFileTypes: true }).map((d) => {
let st = {};
try { st = fs.statSync(path.join(dir, d.name)); } catch {}
return { name: d.name, isDir: d.isDirectory() || (d.isSymbolicLink() && st.isDirectory?.()), size: st.size || 0, mtime: st.mtimeMs || 0 };
}));
handle('local:join', (...p) => path.join(...p));
handle('local:parent', (p) => path.dirname(p));
handle('local:roots', () => {
if (process.platform !== 'win32') return ['/'];
return 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((l) => `${l}:\\`).filter((d) => fs.existsSync(d));
});
handle('local:op', (op, a, b) => {
if (op === 'mkdir') return fs.mkdirSync(a, { recursive: true });
if (op === 'rename') return fs.renameSync(a, b);
if (op === 'delete') return shell.trashItem(a);
if (op === 'open') return shell.openPath(a);
});
// ---------- Port-Forwarding ----------
handle('fw:start', async (id) => {
const fw = store.get().forwards.find((f) => f.id === id);
if (!fw) throw new Error(i18n.t('Rule not found'));
if (await vpn.ensureForHost(store.resolveHost(fw.hostId))) send('vpn:changed');
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
});
handle('fw:stop', (id) => ssh.stopForward(id));
handle('fw:active', () => ssh.activeForwards());
// ---------- RDP ----------
handle('rdp:detect', () => rdp.detect(store.get().settings));
handle('rdp:launch', async (hostId) => {
const host = hostWithOverrides(hostId);
if (await vpn.ensureForHost(host)) send('vpn:changed');
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
const r = rdp.launch(host, store.get().settings);
if (r.process) {
r.process.on('exit', (code) => { if (code && code !== 0 && code !== 12) send('toast', i18n.t('RDP exited (code {code}): {err}', { code, err: r.getErr().split('\n').filter(Boolean).slice(-1)[0] || '' }), 'error'); });
r.process.on('error', (e) => send('toast', i18n.t('RDP launch failed: {msg}', { msg: e.message }), 'error'));
}
return r.client;
});
// Eingebettete RDP-Sitzungen (Anzeige im Tab)
const rdpSessions = new Map();
handle('rdp:embedSupported', () => {
const s = embedSupported();
if (s.ok && process.platform === 'linux' && !rdp.linuxClient(null, true)) return { ok: false, reason: i18n.t('xfreerdp3 missing (sudo pacman -S freerdp)') };
return s;
});
handle('rdp:open', async (id, hostRef, bounds) => {
const host = hostWithOverrides(hostRef);
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
if (await vpn.ensureForHost(host)) send('vpn:changed');
const sess = { cancelled: false };
rdpSessions.set(id, sess);
return startRdpEmbed(id, sess, host, bounds);
});
async function startRdpEmbed(id, sess, host, bounds, acceptCert = false) {
const emb = createEmbed(win.getNativeWindowHandle());
emb.acceptCert = acceptCert;
sess.emb = emb;
const size = { width: bounds.width, height: bounds.height };
let proc;
if (process.platform === 'win32') {
const args = rdp.mstscArgs(host, size);
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ args }))));
} else {
proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password });
}
const current = () => rdpSessions.get(id) === sess && sess.emb === emb;
proc.on('error', (e) => { if (current()) send('rdp:event', id, 'exit', { code: -1, message: e.message }); });
proc.on('exit', (code) => {
if (!current()) return; // durch Neuverbindung ersetzt
const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || '';
send('rdp:event', id, 'exit', { code, message: err });
rdpSessions.delete(id);
});
// Server ohne dynamische Auflösung: mit neuer Größe neu verbinden (die Sitzung auf dem Server bleibt erhalten)
emb.onResizeNeeded = (b) => {
if (!current()) return;
send('rdp:event', id, 'resizing');
emb.kill();
startRdpEmbed(id, sess, host, b, emb.certAccepted).then((ok) => {
if (!ok && rdpSessions.get(id) === sess && !sess.cancelled) send('rdp:event', id, 'exit', { code: -1, message: '' });
});
};
const ok = await emb.attach(bounds, () => sess.cancelled || !current());
if (ok && current()) {
if (sess.hidden) emb.hide();
send('rdp:event', id, 'attached');
}
return ok;
}
ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} });
ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } });
ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
ipcMain.on('rdp:focus', (_e, id) => { try { rdpSessions.get(id)?.emb.focus(); } catch {} });
ipcMain.on('rdp:close', (_e, id) => {
const s = rdpSessions.get(id);
if (!s) return;
s.cancelled = true;
rdpSessions.delete(id);
s.emb.kill();
});
handle('rdp:detach', (id) => {
const s = rdpSessions.get(id);
if (!s) return false;
if (process.platform !== 'win32') return false;
s.emb.detach();
rdpSessions.delete(id); // läuft als eigenständiges Fenster weiter
return true;
});
// ---------- Import aus anderen Programmen (z. B. Devolutions RDM) ----------
handle('import:pickFile', async (title, extensions) => {
const r = await dialog.showOpenDialog(win, { title, filters: [{ name: title, extensions }, { name: i18n.t('All files'), extensions: ['*'] }], properties: ['openFile'] });
if (r.canceled) return null;
const buf = fs.readFileSync(r.filePaths[0]);
// UTF-16-Exporte (BOM) erkennen
const text = buf[0] === 0xff && buf[1] === 0xfe ? buf.toString('utf16le') : buf.toString('utf8');
return { name: path.basename(r.filePaths[0]), content: text.replace(/^/, '') };
});
// entries: [{ folder: ['A','B'], host?: {...} }] – Ordner werden per Pfad angelegt bzw. wiederverwendet
handle('vault:bulkImport', (entries) => {
const groups = store.get().groups;
const ensure = (parts) => {
let parent = null;
for (const label of parts) {
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
parent = g.id;
}
return parent;
};
let hosts = 0, skipped = 0;
for (const e of entries) {
const groupId = ensure(e.folder || []);
if (!e.host) continue;
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
if (dup) { skipped++; continue; }
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
hosts++;
}
store.save();
return { hosts, skipped, groups: groups.length };
});
// ---------- Updates ----------
handle('update:check', () => updater.check());
handle('update:download', () => updater.download());
handle('update:install', () => updater.install());
handle('app:version', () => app.getVersion());
handle('clipboard:write', (t) => clipboard.writeText(t));
handle('clipboard:read', () => clipboard.readText());
handle('shell:open', (url) => shell.openExternal(url));
app.whenReady().then(() => {
store.load();
applyLanguage();
createWindow();
sync.start().catch(() => {});
scheduleUpdateCheck();
});
// Automatische Update-Prüfung – bei gesperrtem Vault erst nach dem Entsperren (Einstellungen sind verschlüsselt)
let updateScheduled = false;
function scheduleUpdateCheck() {
if (updateScheduled || store.sealed || !store.get().settings.updateAutoCheck || !app.isPackaged) return;
updateScheduled = true;
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
}
app.on('window-all-closed', async () => { ssh.closeAll(); sync.stop(); await vpn.downOnQuit(); app.quit(); });
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
if (process.env.MRTERM_SMOKE) {
app.whenReady().then(() => {
win.webContents.on('console-message', (e) => console.log('[renderer]', e.level, e.message));
setTimeout(async () => {
if (process.env.MRTERM_SMOKE_JS) await win.webContents.executeJavaScript(process.env.MRTERM_SMOKE_JS).catch((e) => console.log('js error', e));
setTimeout(async () => {
fs.writeFileSync(process.env.MRTERM_SMOKE, (await win.webContents.capturePage()).toPNG());
app.quit();
}, 1500);
}, 2500);
});
}