Comma-separated host list logs into every host, merges VM lists tagged by origin host, and routes SPICE connections to the right one. Also adds a username format hint (user@realm) on the login form.
324 lines
10 KiB
JavaScript
324 lines
10 KiB
JavaScript
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);
|
|
}
|