578 lines
26 KiB
JavaScript
578 lines
26 KiB
JavaScript
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 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);
|
||
|
||
// 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));
|
||
|
||
// ---------- 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:probe', (address) => sync.probe(address));
|
||
|
||
// ---------- 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));
|
||
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(publicData(), 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', 'vpns'])
|
||
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(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);
|
||
});
|
||
}
|