const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { spawn, spawnSync } = require('child_process'); const Store = require('electron-store'); const ProxmoxClient = require('./proxmox'); const store = new Store({ name: 'proxmox-spice-client' }); let mainWindow; // One ProxmoxClient per logged-in host — several standalone Proxmox hosts can // share the same root@pam credentials without being a real PVE cluster, so we // log into every host the user entered and merge their VM lists. let clients = []; // ---------- Window management ---------- function createWindow(file, width, height, resizable = false) { if (mainWindow) { mainWindow.setResizable(true); mainWindow.setSize(width, height); mainWindow.setResizable(resizable); mainWindow.loadFile(path.join(__dirname, 'renderer', file)); return; } mainWindow = new BrowserWindow({ width, height, resizable, title: 'Proxmox SPICE Client', autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, }); mainWindow.loadFile(path.join(__dirname, 'renderer', file)); mainWindow.on('closed', () => { mainWindow = null; }); } app.whenReady().then(() => { createWindow('login.html', 460, 500, false); checkDependencies(); app.on('activate', () => { if (!mainWindow) createWindow('login.html', 460, 500, false); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); // ---------- Dependency check (AppImage / manual install on Linux) ---------- function checkDependencies() { // On Windows the NSIS installer handles virt-viewer — no runtime check needed. // On Linux (AppImage or manual install) we check here so the user knows before // trying to connect for the first time. if (process.platform !== 'linux') return; const candidates = ['remote-viewer', 'virt-viewer']; const found = candidates.some(bin => { const r = spawnSync('which', [bin], { encoding: 'utf8' }); return r.status === 0; }); if (!found) { setImmediate(() => { const choice = dialog.showMessageBoxSync(mainWindow, { type: 'warning', title: 'Abhängigkeit fehlt', message: 'VirtViewer nicht gefunden', detail: 'VirtViewer (remote-viewer) wird für SPICE-Verbindungen benötigt und ist auf diesem System nicht installiert.\n\n' + 'Installieren mit:\n sudo apt install virt-viewer\n\n' + 'Soll die App versuchen, VirtViewer jetzt automatisch zu installieren?', buttons: ['Jetzt installieren', 'Später (manuell)'], defaultId: 0, cancelId: 1, }); if (choice === 0) { const installer = spawn( 'pkexec', ['apt', 'install', '-y', 'virt-viewer'], { detached: false, stdio: 'ignore' } ); installer.on('close', code => { if (code === 0) { dialog.showMessageBox(mainWindow, { type: 'info', title: 'VirtViewer installiert', message: 'VirtViewer wurde erfolgreich installiert.', buttons: ['OK'], }); } else { dialog.showMessageBox(mainWindow, { type: 'error', title: 'Installation fehlgeschlagen', message: `Installation schlug fehl (Code ${code}).\nBitte manuell ausführen:\n sudo apt install virt-viewer`, buttons: ['OK'], }); } }); } }); } } // ---------- IPC: credentials ---------- ipcMain.handle('credentials:load', () => store.get('credentials', null)); ipcMain.handle('credentials:save', (_e, creds) => { store.set('credentials', creds); }); ipcMain.handle('credentials:clear', () => { store.delete('credentials'); }); // ---------- IPC: Proxmox ---------- ipcMain.handle('proxmox:login', async (_e, { host, username, password }) => { const hosts = String(host || '') .split(',') .map((h) => h.trim()) .filter(Boolean); if (hosts.length === 0) { return { success: false, error: 'Bitte mindestens einen Host angeben.' }; } const attempts = await Promise.all( hosts.map(async (h) => { const c = new ProxmoxClient(h); try { await c.login(username, password); return { host: h, client: c, success: true }; } catch (err) { return { host: h, success: false, error: err.message }; } }) ); const succeeded = attempts.filter((a) => a.success); const failed = attempts.filter((a) => !a.success); if (succeeded.length === 0) { // Single host → show the real error. Multiple hosts, all failing → the // per-host errors are usually identical (wrong creds), so the first is enough. return { success: false, error: attempts[0].error }; } clients = succeeded; createWindow('vms.html', 680, 520, true); if (failed.length > 0) { setImmediate(() => { dialog.showMessageBox(mainWindow, { type: 'warning', title: 'Nicht alle Hosts erreichbar', message: `${failed.length} von ${hosts.length} Host(s) konnten nicht angemeldet werden.`, detail: failed.map((f) => `${f.host}: ${f.error}`).join('\n'), buttons: ['OK'], }); }); } return { success: true }; }); ipcMain.handle('proxmox:getVMs', async () => { try { const perHost = await Promise.all( clients.map(async ({ host, client }) => { try { const vms = await client.getSpiceVMs(); return vms.map((vm) => ({ ...vm, host })); } catch { return []; } }) ); return { success: true, vms: perHost.flat() }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('proxmox:logout', () => { clients = []; createWindow('login.html', 460, 500, false); }); ipcMain.handle('proxmox:connect', async (_e, { host, node, vmid }) => { try { const entry = clients.find((c) => c.host === host) || clients[0]; if (!entry) throw new Error('Nicht angemeldet.'); const params = await entry.client.getSpiceTicket(node, vmid); const vvPath = buildVVFile(params, vmid, entry.client); launchRemoteViewer(vvPath); return { success: true }; } catch (err) { return { success: false, error: err.message }; } }); // ---------- IPC: settings ---------- ipcMain.handle('settings:platform', () => process.platform); ipcMain.handle('settings:getViewerPath', () => store.get('viewerPath', null)); ipcMain.handle('settings:clearViewerPath', () => { store.delete('viewerPath'); }); ipcMain.handle('settings:browseViewerPath', async () => { const result = await dialog.showOpenDialog(mainWindow, { title: 'remote-viewer.exe auswählen', properties: ['openFile'], filters: [{ name: 'remote-viewer', extensions: ['exe'] }], }); if (result.canceled || result.filePaths.length === 0) return null; const chosen = result.filePaths[0]; store.set('viewerPath', chosen); return chosen; }); // ---------- SPICE helpers ---------- function buildVVFile(params, vmid, client) { const lines = ['[virt-viewer]', `type=${params.type || 'spice'}`]; const host = params.proxy || params.host || client.host.split(':')[0]; lines.push(`host=${host}`); if (params['tls-port']) lines.push(`tls-port=${params['tls-port']}`); if (params.port) lines.push(`port=${params.port}`); if (params.password) lines.push(`password=${params.password}`); if (params.ca) { const caPath = path.join(os.tmpdir(), 'proxmox-spice-ca.pem'); fs.writeFileSync(caPath, params.ca); lines.push(`tls-ca-file=${caPath}`); } if (params['host-subject']) lines.push(`host-subject=${params['host-subject']}`); lines.push('fullscreen=0'); lines.push('title=%d — SPICE'); lines.push('delete-this-file=1'); lines.push('toggle-fullscreen=shift+f11'); lines.push('release-cursor=shift+f12'); lines.push('secure-attention=ctrl+alt+end'); lines.push('usb-filter=-1,-1,-1,-1,0'); const vvPath = path.join(os.tmpdir(), `spice-${vmid}-${Date.now()}.vv`); fs.writeFileSync(vvPath, lines.join('\n') + '\n', { mode: 0o600 }); return vvPath; } // VirtViewer's Windows installer names its folder after the bundled version // (e.g. "VirtViewer v11.0-256"), so a fixed path breaks on every version bump. // Scan the two Program Files dirs for any "VirtViewer*" folder instead. function findWindowsViewerCandidates() { const roots = [ process.env['ProgramFiles'] || 'C:\\Program Files', process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', ]; const found = []; for (const root of roots) { let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory() || !/^virtviewer/i.test(entry.name)) continue; const exe = path.join(root, entry.name, 'bin', 'remote-viewer.exe'); if (fs.existsSync(exe)) found.push(exe); } } return found; } function launchRemoteViewer(vvPath) { let candidates; if (process.platform === 'win32') { const customPath = store.get('viewerPath', null); candidates = [ ...(customPath ? [customPath] : []), 'remote-viewer', ...findWindowsViewerCandidates(), ]; } else { candidates = ['remote-viewer', 'virt-viewer']; } function tryNext(i) { if (i >= candidates.length) { const hint = process.platform === 'win32' ? 'Windows: https://virt-manager.org/download/\n\n' + 'Falls VirtViewer an einem nicht-standardmäßigen Ort installiert ist, ' + 'trage den Pfad unter „Einstellungen → Viewer-Pfad" ein.' : 'Linux: sudo apt install virt-viewer'; dialog.showErrorBox( 'remote-viewer nicht gefunden', 'Bitte installiere virt-viewer:\n\n' + hint + '\n\nDanach bitte erneut verbinden.' ); return; } const child = spawn(candidates[i], [vvPath], { detached: true, stdio: 'ignore' }); child.on('error', () => tryNext(i + 1)); child.on('spawn', () => { }); child.unref(); } tryNext(0); }