5 Commits
Author SHA1 Message Date
aria.hacker 7fbac7c18a Add per-VM autostart checkbox (single-select, persisted)
Only one VM can be marked for autostart at a time — checking a new
one unchecks the previous. Matched by host+vmid since the same vmid
can exist on multiple standalone Proxmox hosts in a cluster setup.
2026-07-06 13:43:38 +02:00
aria.hacker 5c4e3781fd Bump version to 1.0.3 2026-07-06 12:19:52 +02:00
aria.hacker f4828f73b9 Fix broken VirtViewer download links (virt-manager.org dead, use gitlab.com release) 2026-07-06 11:01:30 +02:00
aria.hacker ebb405ab9b Support multiple standalone Proxmox hosts with shared credentials
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.
2026-07-06 10:29:44 +02:00
aria.hacker a0e7aa8f52 Fix VirtViewer detection on Windows + manual path override
VirtViewer's installer folder name includes the version number
(e.g. "VirtViewer v11.0-256"), so the previous hardcoded path never
matched a real install. Now scans Program Files for any VirtViewer*
folder, and adds a Settings dialog (Windows only) to pick the
remote-viewer.exe path manually as a fallback.
2026-07-06 10:02:53 +02:00
8 changed files with 354 additions and 41 deletions
+3 -3
View File
@@ -18,7 +18,7 @@ Jetzt automatisch herunterladen und installieren (ca. 30 MB)?" \
DetailPrint "Lade VirtViewer herunter..."
nsExec::ExecToLog 'powershell.exe -NonInteractive -Command \
"Invoke-WebRequest \
-Uri \"https://virt-manager.org/download/virt-viewer-x64.msi\" \
-Uri \"https://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0/downloads/virt-viewer-x64-11.0-1.0.msi\" \
-OutFile \"$TEMP\virt-viewer-setup.msi\" \
-UseBasicParsing"'
Pop $0
@@ -26,7 +26,7 @@ Jetzt automatisch herunterladen und installieren (ca. 30 MB)?" \
MessageBox MB_OK|MB_ICONEXCLAMATION \
"Download fehlgeschlagen (Fehlercode: $0).$\n$\n\
Bitte VirtViewer nach der Installation manuell installieren:$\n\
https://virt-manager.org/download/"
https://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0"
Goto vv_skip
${EndIf}
@@ -40,7 +40,7 @@ https://virt-manager.org/download/"
MessageBox MB_OK|MB_ICONEXCLAMATION \
"VirtViewer-Installation schlug fehl (Code: $0).$\n$\n\
Bitte nach Abschluss manuell installieren:$\n\
https://virt-manager.org/download/"
https://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0"
${EndIf}
Goto vv_skip
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxmox-spice-client",
"version": "1.0.0",
"version": "1.0.4",
"description": "VDI SPICE Client f\u00fcr Proxmox",
"main": "src/main.js",
"scripts": {
+141 -27
View File
@@ -8,7 +8,10 @@ const ProxmoxClient = require('./proxmox');
const store = new Store({ name: 'proxmox-spice-client' });
let mainWindow;
let client; // ProxmoxClient instance (persists across login → VM views)
// 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 ----------
@@ -120,34 +123,83 @@ ipcMain.handle('credentials:clear', () => {
// ---------- IPC: Proxmox ----------
ipcMain.handle('proxmox:login', async (_e, { host, username, password }) => {
try {
client = new ProxmoxClient(host);
await client.login(username, password);
createWindow('vms.html', 680, 520, true);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
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 vms = await client.getSpiceVMs();
return { success: true, vms };
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', () => {
client = null;
clients = [];
createWindow('login.html', 460, 500, false);
});
ipcMain.handle('proxmox:connect', async (_e, { node, vmid }) => {
ipcMain.handle('proxmox:connect', async (_e, { host, node, vmid }) => {
try {
const params = await client.getSpiceTicket(node, vmid);
const vvPath = buildVVFile(params, vmid);
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) {
@@ -155,9 +207,40 @@ ipcMain.handle('proxmox:connect', async (_e, { node, vmid }) => {
}
});
// ---------- 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;
});
// Only one VM can be marked for autostart at a time — identified by
// host+vmid since the same vmid can exist on several hosts.
ipcMain.handle('settings:getAutoConnect', () => store.get('autoConnect', null));
ipcMain.handle('settings:setAutoConnect', (_e, target) => {
if (target) store.set('autoConnect', target);
else store.delete('autoConnect');
});
// ---------- SPICE helpers ----------
function buildVVFile(params, vmid) {
function buildVVFile(params, vmid, client) {
const lines = ['[virt-viewer]', `type=${params.type || 'spice'}`];
const host = params.proxy || params.host || client.host.split(':')[0];
@@ -187,24 +270,55 @@ function buildVVFile(params, vmid) {
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) {
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 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://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0\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' +
' Linux: sudo apt install virt-viewer\n' +
' Windows: https://virt-manager.org/download/\n\n' +
'Danach bitte erneut verbinden.'
'Bitte installiere virt-viewer:\n\n' + hint + '\n\nDanach bitte erneut verbinden.'
);
return;
}
+8
View File
@@ -12,4 +12,12 @@ contextBridge.exposeInMainWorld('api', {
connect: (vm) => ipcRenderer.invoke('proxmox:connect', vm),
logout: () => ipcRenderer.invoke('proxmox:logout'),
},
settings: {
platform: () => ipcRenderer.invoke('settings:platform'),
getViewerPath: () => ipcRenderer.invoke('settings:getViewerPath'),
browseViewerPath: () => ipcRenderer.invoke('settings:browseViewerPath'),
clearViewerPath: () => ipcRenderer.invoke('settings:clearViewerPath'),
getAutoConnect: () => ipcRenderer.invoke('settings:getAutoConnect'),
setAutoConnect: (target) => ipcRenderer.invoke('settings:setAutoConnect', target),
},
});
+4 -2
View File
@@ -17,12 +17,14 @@
<form id="loginForm" autocomplete="on">
<div class="field">
<label for="host">Proxmox Host</label>
<label for="host">Proxmox Host(s)</label>
<input type="text" id="host" placeholder="pve.example.com" autocomplete="url" spellcheck="false">
<div class="field-hint">Mehrere eigenständige Hosts mit denselben Zugangsdaten? Mit Komma trennen, z. B. pve1.example.com, pve2.example.com</div>
</div>
<div class="field">
<label for="username">Benutzer</label>
<input type="text" id="username" placeholder="user@pam" autocomplete="username" spellcheck="false">
<input type="text" id="username" placeholder="root@pam" autocomplete="username" spellcheck="false">
<div class="field-hint">Format: Benutzer@Realm — z. B. root@pam</div>
</div>
<div class="field">
<label for="password">Passwort</label>
+77
View File
@@ -76,6 +76,13 @@ body.login-page {
margin-bottom: 0.4rem;
}
.field-hint {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.35rem;
line-height: 1.4;
}
input[type="text"],
input[type="password"] {
width: 100%;
@@ -243,6 +250,20 @@ body.vms-page {
.vm-info { flex: 1; min-width: 0; }
.vm-autostart {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
user-select: none;
}
.vm-autostart input { cursor: pointer; }
.vm-name {
font-size: 0.97rem;
font-weight: 600;
@@ -292,3 +313,59 @@ body.vms-page {
margin-top: 0.5rem;
color: #3a4a60;
}
/* ── Settings modal ───────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(5, 8, 16, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.modal-card {
width: 420px;
max-width: 90vw;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.6rem 1.7rem;
box-shadow: 0 24px 60px rgba(0,0,0,0.55);
}
.modal-card h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.6rem;
}
.modal-card .hint {
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 1.1rem;
line-height: 1.5;
}
.path-display {
padding: 0.6rem 0.85rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 7px;
font-size: 0.82rem;
color: var(--text);
word-break: break-all;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.4rem;
}
.modal-actions .modal-close {
width: auto;
padding: 0.42rem 1rem;
}
+21
View File
@@ -14,6 +14,7 @@
<div class="page-header">
<h2>Virtuelle Maschinen</h2>
<div class="header-actions">
<button id="settingsBtn" class="btn-secondary hidden" title="Viewer-Pfad einstellen">⚙ Einstellungen</button>
<button id="refreshBtn" class="btn-secondary" title="Liste aktualisieren">↻ Aktualisieren</button>
<button id="logoutBtn" class="btn-secondary">Abmelden</button>
</div>
@@ -42,6 +43,26 @@
</div>
<!-- Settings modal (Windows only — VirtViewer's install folder name changes per version) -->
<div id="settingsOverlay" class="modal-overlay hidden">
<div class="modal-card">
<h3>Viewer-Pfad</h3>
<p class="hint">
Wird VirtViewer (remote-viewer.exe) nicht automatisch gefunden, kannst du hier
den Pfad zur .exe manuell festlegen.
</p>
<div class="field">
<label>Aktueller Pfad</label>
<div id="viewerPathDisplay" class="path-display">automatisch erkannt</div>
</div>
<div class="modal-actions">
<button id="browseViewerBtn" class="btn-secondary">Durchsuchen…</button>
<button id="resetViewerBtn" class="btn-secondary">Zurücksetzen</button>
<button id="closeSettingsBtn" class="btn-primary modal-close">Schließen</button>
</div>
</div>
</div>
<script src="vms.js"></script>
</body>
</html>
+99 -8
View File
@@ -6,6 +6,9 @@ const emptyEl = document.getElementById('emptyState');
const errorEl = document.getElementById('error');
const noticeEl = document.getElementById('notice');
const refreshBtn = document.getElementById('refreshBtn');
const settingsBtn = document.getElementById('settingsBtn');
const settingsOverlay = document.getElementById('settingsOverlay');
const viewerPathDisplay = document.getElementById('viewerPathDisplay');
document.getElementById('logoutBtn').addEventListener('click', async () => {
await api.proxmox.logout();
@@ -13,6 +16,42 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
refreshBtn.addEventListener('click', () => loadVMs());
// ── Settings (Windows only — VirtViewer's install folder name carries the
// version number, so auto-detection can miss non-standard installs) ──
initSettings();
async function initSettings() {
const platform = await api.settings.platform();
if (platform !== 'win32') return;
settingsBtn.classList.remove('hidden');
settingsBtn.addEventListener('click', openSettings);
document.getElementById('closeSettingsBtn').addEventListener('click', closeSettings);
document.getElementById('browseViewerBtn').addEventListener('click', async () => {
const chosen = await api.settings.browseViewerPath();
if (chosen) await refreshViewerPathDisplay();
});
document.getElementById('resetViewerBtn').addEventListener('click', async () => {
await api.settings.clearViewerPath();
await refreshViewerPathDisplay();
});
}
async function openSettings() {
await refreshViewerPathDisplay();
settingsOverlay.classList.remove('hidden');
}
function closeSettings() {
settingsOverlay.classList.add('hidden');
}
async function refreshViewerPathDisplay() {
const viewerPath = await api.settings.getViewerPath();
viewerPathDisplay.textContent = viewerPath || 'automatisch erkannt';
}
// ── Load on mount ─────────────────────────────────────────
async function loadVMs() {
@@ -44,42 +83,94 @@ async function loadVMs() {
return;
}
renderList(vms);
// Auto-connect the VM the user marked for autostart (only one at a time)
const autoConnect = await api.settings.getAutoConnect();
const autoMatch = autoConnect && vms.find(
(vm) => vm.host === autoConnect.host && String(vm.vmid) === String(autoConnect.vmid)
);
if (autoMatch) {
showNotice(`Autostart-VM „${autoMatch.name}" markiert — verbinde …`);
await connectVM(autoMatch);
return;
}
renderList(vms, autoConnect);
}
loadVMs();
// ── Render ────────────────────────────────────────────────
function renderList(vms) {
function renderList(vms, autoConnect) {
// Only show the host when there's more than one — single-host setups don't need the noise.
const multiHost = new Set(vms.map((vm) => vm.host)).size > 1;
listEl.innerHTML = vms
.map(
(vm) => `
.map((vm) => {
const isAutostart = !!(
autoConnect &&
autoConnect.host === vm.host &&
String(autoConnect.vmid) === String(vm.vmid)
);
return `
<div class="vm-card" data-vmid="${vm.vmid}">
<div class="vm-info">
<div class="vm-name">${esc(vm.name)}</div>
<div class="vm-meta">
<span class="status-dot"></span>
Läuft &nbsp;·&nbsp; Node: ${esc(vm.node)} &nbsp;·&nbsp; ID: ${vm.vmid}
Läuft &nbsp;·&nbsp; ${multiHost ? `Host: ${esc(vm.host)} &nbsp;·&nbsp; ` : ''}Node: ${esc(vm.node)} &nbsp;·&nbsp; ID: ${vm.vmid}
</div>
</div>
<label class="vm-autostart" title="Beim Start automatisch mit dieser VM verbinden">
<input
type="checkbox"
class="autostart-checkbox"
data-vmid="${vm.vmid}"
data-host="${esc(vm.host)}"
data-name="${esc(vm.name)}"
${isAutostart ? 'checked' : ''}
>
Autostart
</label>
<button
class="btn-connect"
data-vmid="${vm.vmid}"
data-host="${esc(vm.host)}"
data-node="${esc(vm.node)}"
data-name="${esc(vm.name)}"
>Verbinden</button>
</div>
`
)
`;
})
.join('');
listEl.querySelectorAll('.btn-connect').forEach((btn) => {
btn.addEventListener('click', () =>
connectVM({ vmid: btn.dataset.vmid, node: btn.dataset.node, name: btn.dataset.name })
connectVM({
vmid: btn.dataset.vmid,
host: btn.dataset.host,
node: btn.dataset.node,
name: btn.dataset.name,
})
);
});
listEl.querySelectorAll('.autostart-checkbox').forEach((cb) => {
cb.addEventListener('change', async () => {
if (cb.checked) {
// Only one VM can be the autostart VM — uncheck any other.
listEl.querySelectorAll('.autostart-checkbox').forEach((other) => {
if (other !== cb) other.checked = false;
});
await api.settings.setAutoConnect({ host: cb.dataset.host, vmid: cb.dataset.vmid });
showNotice(`${cb.dataset.name}" wird beim nächsten Start automatisch verbunden.`);
} else {
await api.settings.setAutoConnect(null);
}
});
});
listEl.classList.remove('hidden');
}