Files
MrTerm/src/main/main.js
T

594 lines
27 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 { 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));
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 ? 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);
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 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' }; });
}
// 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 ----------
ipcMain.on('win:min', () => win.minimize());
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
ipcMain.on('win:close', () => win.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));
// ---------- 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));
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 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: 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);
});
}