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'); 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 {}; } } if (process.platform === 'linux' && readLaunch().x11 !== false) app.commandLine.appendSwitch('ozone-platform', 'x11'); const pendingSecrets = new Map(); function send(channel, ...args) { if (win && !win.isDestroyed()) win.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 ? 'Host-Schlüssel hat sich geändert!' : 'Unbekannter Host', message: changed ? `WARNUNG: Der Host-Schlüssel von ${target.host}:${target.port} hat sich geändert. Das kann auf einen Man-in-the-Middle-Angriff hindeuten.` : `Die Echtheit von ${target.host}:${target.port} kann nicht bestätigt werden.`, detail: `Fingerprint:\n${fingerprint}${changed ? `\n\nBisher gespeichert:\n${known.fingerprint}` : ''}`, buttons: [changed ? 'Trotzdem verbinden & ersetzen' : 'Vertrauen & verbinden', 'Abbrechen'], 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); function createWindow() { win = new BrowserWindow({ width: 1360, height: 860, minWidth: 900, minHeight: 560, 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, }, }); Menu.setApplicationMenu(null); win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); win.on('maximize', () => send('win:state', true)); // 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); win.on('unmaximize', () => send('win:state', false)); win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); } function handle(channel, fn) { ipcMain.handle(channel, async (_e, ...args) => { try { return { ok: true, value: await fn(...args) }; } catch (e) { return { ok: false, error: e.message || String(e) }; } }); } // ---------- Fenster ---------- ipcMain.on('win:min', () => win.minimize()); ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize())); ipcMain.on('win:close', () => win.close()); // ---------- Vault ---------- handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform })); handle('vault:upsert', (col, item) => store.upsert(col, item)); handle('vault:remove', (col, id) => store.remove(col, id)); handle('vault:settings', (s) => { store.setSettings(s); if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false })); }); handle('vault:forgetHost', (id) => { delete store.get().knownHosts[id]; store.save(); }); handle('vault:export', async () => { const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); if (r.canceled) return false; fs.writeFileSync(r.filePath, JSON.stringify(store.get(), null, 2), { mode: 0o600 }); return r.filePath; }); handle('vault:import', async () => { const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] }); if (r.canceled) return false; const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8')); for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards']) for (const item of data[col] || []) store.upsert(col, item); return true; }); // Import aus ~/.ssh/config handle('vault:importSshConfig', () => { const file = path.join(os.homedir(), '.ssh', 'config'); if (!fs.existsSync(file)) throw new Error('~/.ssh/config nicht gefunden'); 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('Host nicht gefunden'); return h; } return hostOrId; // Quick-Connect: temporärer Host } handle('ssh:open', async (sessionId, hostRef, size) => { const host = hostWithOverrides(hostRef); if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); 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', (sessionId, hostRef) => ssh.openSftp(sessionId, hostWithOverrides(hostRef))); handle('sftp:list', (id, dir) => ssh.sftpList(id, dir)); handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b)); 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', (id) => { const fw = store.get().forwards.find((f) => f.id === id); if (!fw) throw new Error('Regel nicht gefunden'); 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', (hostId) => { const host = hostWithOverrides(hostId); 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', `RDP beendet (Code ${code}): ${r.getErr().split('\n').filter(Boolean).slice(-1)[0] || ''}`, 'error'); }); r.process.on('error', (e) => send('toast', 'RDP-Start fehlgeschlagen: ' + 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: 'xfreerdp3 fehlt (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() }); const emb = createEmbed(win.getNativeWindowHandle()); const sess = { emb, cancelled: false }; rdpSessions.set(id, sess); const size = { width: bounds.width, height: bounds.height }; let proc; if (process.platform === 'win32') { const file = rdp.writeRdpFile(host, size); proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ rdpFile: file })))); } else { proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password }); } proc.on('error', (e) => send('rdp:event', id, 'exit', { code: -1, message: e.message })); proc.on('exit', (code) => { const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || ''; if (rdpSessions.get(id) === sess) send('rdp:event', id, 'exit', { code, message: err }); rdpSessions.delete(id); }); const ok = await emb.attach(bounds, () => sess.cancelled); if (ok) { 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: 'Alle Dateien', 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(); createWindow(); if (store.get().settings.updateAutoCheck && app.isPackaged) { setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000); } }); app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); }); // Smoke-Test: MRTERM_SMOKE= 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); }); }