Files
MrTerm/src/main/main.js
T

382 lines
17 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');
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=<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);
});
}