Initial commit — Proxmox SPICE Client (Electron)

This commit is contained in:
aria.hacker
2026-07-01 09:23:50 +02:00
commit efb8c7f446
15 changed files with 1223 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { spawn } = require('child_process');
const Store = require('electron-store');
const ProxmoxClient = require('./proxmox');
const store = new Store({ name: 'proxmox-spice-client' });
let mainWindow;
let client; // ProxmoxClient instance (persists across login → VM views)
// ---------- 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);
app.on('activate', () => {
if (!mainWindow) createWindow('login.html', 460, 500, false);
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// ---------- 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 }) => {
try {
client = new ProxmoxClient(host);
await client.login(username, password);
// Switch to VM list view
createWindow('vms.html', 680, 520, true);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.handle('proxmox:getVMs', async () => {
try {
const vms = await client.getSpiceVMs();
return { success: true, vms };
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.handle('proxmox:logout', () => {
client = null;
createWindow('login.html', 460, 500, false);
});
ipcMain.handle('proxmox:connect', async (_e, { node, vmid }) => {
try {
const params = await client.getSpiceTicket(node, vmid);
const vvPath = buildVVFile(params, vmid);
launchRemoteViewer(vvPath);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
});
// ---------- SPICE helpers ----------
function buildVVFile(params, vmid) {
const lines = ['[virt-viewer]', `type=${params.type || 'spice'}`];
// Proxmox spiceproxy returns 'proxy' as the SPICE host
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}`);
// Write CA cert to temp file if provided
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']}`);
// UX + USB
lines.push('fullscreen=0');
lines.push('title=%d — SPICE');
lines.push('delete-this-file=1'); // remote-viewer deletes the file after reading (hides password)
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'); // allow all USB devices
const vvPath = path.join(os.tmpdir(), `spice-${vmid}-${Date.now()}.vv`);
fs.writeFileSync(vvPath, lines.join('\n') + '\n', { mode: 0o600 });
return vvPath;
}
function launchRemoteViewer(vvPath) {
const candidates =
process.platform === 'win32'
? [
'remote-viewer',
path.join('C:', 'Program Files', 'VirtViewer', 'bin', 'remote-viewer.exe'),
path.join('C:', 'Program Files (x86)', 'VirtViewer', 'bin', 'remote-viewer.exe'),
]
: ['remote-viewer', 'virt-viewer'];
let launched = false;
function tryNext(i) {
if (i >= candidates.length) {
dialog.showErrorBox(
'remote-viewer nicht gefunden',
'Bitte installiere virt-viewer:\n\n' +
' Linux: sudo apt install virt-viewer\n' +
' Windows: https://virt-manager.org/download/\n\n' +
'Danach bitte erneut verbinden.'
);
return;
}
const child = spawn(candidates[i], [vvPath], { detached: true, stdio: 'ignore' });
child.on('error', () => tryNext(i + 1));
child.on('spawn', () => { launched = true; });
child.unref();
}
tryNext(0);
}