Initial commit — Proxmox SPICE Client (Electron)
This commit is contained in:
+166
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user