Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4828f73b9 | ||
|
|
ebb405ab9b | ||
|
|
a0e7aa8f52 |
+3
-3
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxmox-spice-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"description": "VDI SPICE Client f\u00fcr Proxmox",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
|
||||
+132
-27
@@ -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,31 @@ 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;
|
||||
});
|
||||
|
||||
// ---------- 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 +261,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;
|
||||
}
|
||||
|
||||
@@ -12,4 +12,10 @@ 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'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
@@ -292,3 +299,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+50
-2
@@ -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() {
|
||||
@@ -52,6 +91,9 @@ loadVMs();
|
||||
// ── Render ────────────────────────────────────────────────
|
||||
|
||||
function renderList(vms) {
|
||||
// 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) => `
|
||||
@@ -60,12 +102,13 @@ function renderList(vms) {
|
||||
<div class="vm-name">${esc(vm.name)}</div>
|
||||
<div class="vm-meta">
|
||||
<span class="status-dot"></span>
|
||||
Läuft · Node: ${esc(vm.node)} · ID: ${vm.vmid}
|
||||
Läuft · ${multiHost ? `Host: ${esc(vm.host)} · ` : ''}Node: ${esc(vm.node)} · ID: ${vm.vmid}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -76,7 +119,12 @@ function renderList(vms) {
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user