feat: Installer mit automatischer VirtViewer-Abhängigkeit

- Windows NSIS: lädt VirtViewer MSI herunter und installiert es
  während der App-Installation (via PowerShell + msiexec)
- Linux .deb: Depends: virt-viewer — apt zieht es automatisch rein
- Linux AppImage: Startup-Check mit pkexec-Dialog falls virt-viewer fehlt
- BUILD.md aktualisiert: Installer-Verhalten dokumentiert
This commit is contained in:
2026-07-03 02:56:23 +02:00
parent efb8c7f446
commit 47db668ccc
4 changed files with 172 additions and 31 deletions
+45 -21
View File
@@ -7,35 +7,62 @@ node >= 18
npm >= 9 npm >= 9
``` ```
Für Linux-Builds auf Linux-Host, für Windows-Builds entweder Windows oder Wine (cross-build eingeschränkt — lieber nativ bauen). Für Linux-Builds auf Linux-Host. Windows-Builds entweder nativ auf Windows oder via Wine (cross-build eingeschränkt — nativ empfohlen).
## Entwicklung ---
## Entwicklung (ohne Build)
```bash ```bash
npm install npm install
npm start npm start
``` ```
---
## Produktions-Build ## Produktions-Build
### Linux (AppImage + .deb) ### Linux AppImage + .deb
```bash ```bash
npm run build:linux npm run build:linux
# Output: dist/Proxmox SPICE Client-1.0.0.AppImage # Output:
# dist/proxmox-spice-client_1.0.0_amd64.deb # dist/Proxmox SPICE Client-1.0.0.AppImage ← portable, kein Install nötig
# dist/proxmox-spice-client_1.0.0_amd64.deb ← Paket für apt
``` ```
### Windows (.exe Installer) **Abhängigkeiten:**
- **AppImage**: Selbst-enthalten (Electron inklusive). Beim ersten Start wird geprüft ob `virt-viewer` installiert ist. Falls nicht: Dialog mit automatischer Installation via `pkexec apt install virt-viewer`.
- **.deb**: Deklariert `virt-viewer` als `Depends` — apt installiert es automatisch mit:
```bash
sudo apt install ./proxmox-spice-client_1.0.0_amd64.deb
```
### Windows — Setup.exe (NSIS)
```bash ```bash
# Am besten nativ auf Windows bauen:
npm run build:win npm run build:win
# Output: dist/Proxmox SPICE Client Setup 1.0.0.exe # Output:
# dist/Proxmox SPICE Client Setup 1.0.0.exe
``` ```
**Abhängigkeiten:**
Der NSIS-Installer prüft automatisch ob VirtViewer installiert ist. Falls nicht:
1. Fragt den Benutzer ob VirtViewer jetzt installiert werden soll
2. Lädt das MSI-Paket von virt-manager.org herunter (~30 MB, benötigt Internet)
3. Installiert VirtViewer still im Hintergrund
Kein manueller Schritt nötig — der Installer kümmert sich um alles.
### Beides auf einmal ### Beides auf einmal
```bash ```bash
npm run build:linux && npm run build:win npm run build:linux && npm run build:win
``` ```
---
## Icons ## Icons
Dateien in `assets/` müssen vor dem Build vorhanden sein: Dateien in `assets/` müssen vor dem Build vorhanden sein:
@@ -52,20 +79,7 @@ sudo apt install imagemagick
convert assets/icon.png -resize 256x256 assets/icon.ico convert assets/icon.png -resize 256x256 assets/icon.ico
``` ```
**PNG → ICNS (macOS):** ---
```bash
# mit electron-icon-maker:
npx electron-icon-maker --input=assets/icon.png --output=assets/
```
## Abhängigkeit: virt-viewer
Der Client startet `remote-viewer` (Teil von virt-viewer). Muss auf dem Zielrechner installiert sein:
- **Linux:** `sudo apt install virt-viewer`
- **Windows:** https://virt-manager.org/download/ → "Windows MSI"
Die App zeigt beim ersten Verbindungsversuch einen Dialog wenn `remote-viewer` nicht gefunden wird.
## Verzeichnisstruktur ## Verzeichnisstruktur
@@ -85,6 +99,8 @@ proxmox-spice-client/
│ ├── icon.png # Quell-Icon (mind. 512×512) │ ├── icon.png # Quell-Icon (mind. 512×512)
│ ├── icon.ico # Windows │ ├── icon.ico # Windows
│ └── icon.icns # macOS (optional) │ └── icon.icns # macOS (optional)
├── build/
│ └── installer.nsh # Benutzerdefiniertes NSIS-Script (VirtViewer-Install)
├── daten/ # persistente Einstellungen (Docker-Mount) ├── daten/ # persistente Einstellungen (Docker-Mount)
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
@@ -92,3 +108,11 @@ proxmox-spice-client/
├── package.json ├── package.json
└── BUILD.md └── BUILD.md
``` ```
---
## Hinweise
- `npm run build:win` auf Linux erfordert Wine (`sudo apt install wine`) für den cross-build. Nativ auf Windows ist stabiler.
- Der NSIS-Installer lädt VirtViewer zur Installationszeit herunter (benötigt Internetverbindung). Wer offline-fähig sein will: VirtViewer-MSI vorher separat verteilen und manuell installieren.
- USB-Redirect, Vollbild (`Shift+F11`), Multi-Monitor: alles via VirtViewer nativ — kein Extra-Setup.
+55
View File
@@ -0,0 +1,55 @@
; Proxmox SPICE Client — Custom NSIS Install Script
; Prüft ob virt-viewer installiert ist und installiert es bei Bedarf.
!macro customInstall
; Prüfen ob remote-viewer.exe bereits vorhanden ist
IfFileExists "$PROGRAMFILES64\VirtViewer\bin\remote-viewer.exe" vv_found
IfFileExists "$PROGRAMFILES\VirtViewer\bin\remote-viewer.exe" vv_found
; Nicht gefunden — Benutzer fragen
MessageBox MB_YESNO|MB_ICONQUESTION \
"VirtViewer wurde auf diesem System nicht gefunden.$\n$\n\
VirtViewer wird für SPICE-Verbindungen zu Proxmox-VMs benötigt.$\n\
Ohne VirtViewer können keine VMs geöffnet werden.$\n$\n\
Jetzt automatisch herunterladen und installieren (ca. 30 MB)?" \
IDYES vv_download IDNO vv_skip
vv_download:
DetailPrint "Lade VirtViewer herunter..."
nsExec::ExecToLog 'powershell.exe -NonInteractive -Command \
"Invoke-WebRequest \
-Uri \"https://virt-manager.org/download/virt-viewer-x64.msi\" \
-OutFile \"$TEMP\virt-viewer-setup.msi\" \
-UseBasicParsing"'
Pop $0
${If} $0 != 0
MessageBox MB_OK|MB_ICONEXCLAMATION \
"Download fehlgeschlagen (Fehlercode: $0).$\n$\n\
Bitte VirtViewer nach der Installation manuell installieren:$\n\
https://virt-manager.org/download/"
Goto vv_skip
${EndIf}
DetailPrint "Installiere VirtViewer (bitte warten)..."
ExecWait 'msiexec /i "$TEMP\virt-viewer-setup.msi" /quiet /norestart' $0
Delete "$TEMP\virt-viewer-setup.msi"
${If} $0 == 0
DetailPrint "VirtViewer erfolgreich installiert."
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION \
"VirtViewer-Installation schlug fehl (Code: $0).$\n$\n\
Bitte nach Abschluss manuell installieren:$\n\
https://virt-manager.org/download/"
${EndIf}
Goto vv_skip
vv_found:
DetailPrint "VirtViewer bereits installiert — OK."
vv_skip:
!macroend
!macro customUnInstall
; Nichts extra — VirtViewer bleibt installiert
!macroend
+10
View File
@@ -26,10 +26,20 @@
"category": "Network", "category": "Network",
"icon": "assets/icon.png" "icon": "assets/icon.png"
}, },
"deb": {
"depends": ["libgtk-3-0", "libnotify4", "libnss3", "libxss1", "libxtst6", "xdg-utils", "libatspi2.0-0", "libuuid1", "virt-viewer"]
},
"win": { "win": {
"target": ["nsis"], "target": ["nsis"],
"icon": "assets/icon.ico" "icon": "assets/icon.ico"
}, },
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerHeaderIcon": "assets/icon.ico",
"include": "build/installer.nsh",
"warningsAsErrors": false
},
"mac": { "mac": {
"target": ["dmg"], "target": ["dmg"],
"icon": "assets/icon.icns" "icon": "assets/icon.icns"
+62 -10
View File
@@ -2,7 +2,7 @@ const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const { spawn } = require('child_process'); const { spawn, spawnSync } = require('child_process');
const Store = require('electron-store'); const Store = require('electron-store');
const ProxmoxClient = require('./proxmox'); const ProxmoxClient = require('./proxmox');
@@ -38,6 +38,7 @@ function createWindow(file, width, height, resizable = false) {
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow('login.html', 460, 500, false); createWindow('login.html', 460, 500, false);
checkDependencies();
app.on('activate', () => { app.on('activate', () => {
if (!mainWindow) createWindow('login.html', 460, 500, false); if (!mainWindow) createWindow('login.html', 460, 500, false);
}); });
@@ -47,6 +48,63 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit(); 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 ---------- // ---------- IPC: credentials ----------
ipcMain.handle('credentials:load', () => store.get('credentials', null)); ipcMain.handle('credentials:load', () => store.get('credentials', null));
@@ -65,7 +123,6 @@ ipcMain.handle('proxmox:login', async (_e, { host, username, password }) => {
try { try {
client = new ProxmoxClient(host); client = new ProxmoxClient(host);
await client.login(username, password); await client.login(username, password);
// Switch to VM list view
createWindow('vms.html', 680, 520, true); createWindow('vms.html', 680, 520, true);
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
@@ -103,7 +160,6 @@ ipcMain.handle('proxmox:connect', async (_e, { node, vmid }) => {
function buildVVFile(params, vmid) { function buildVVFile(params, vmid) {
const lines = ['[virt-viewer]', `type=${params.type || 'spice'}`]; 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]; const host = params.proxy || params.host || client.host.split(':')[0];
lines.push(`host=${host}`); lines.push(`host=${host}`);
@@ -111,7 +167,6 @@ function buildVVFile(params, vmid) {
if (params.port) lines.push(`port=${params.port}`); if (params.port) lines.push(`port=${params.port}`);
if (params.password) lines.push(`password=${params.password}`); if (params.password) lines.push(`password=${params.password}`);
// Write CA cert to temp file if provided
if (params.ca) { if (params.ca) {
const caPath = path.join(os.tmpdir(), 'proxmox-spice-ca.pem'); const caPath = path.join(os.tmpdir(), 'proxmox-spice-ca.pem');
fs.writeFileSync(caPath, params.ca); fs.writeFileSync(caPath, params.ca);
@@ -119,14 +174,13 @@ function buildVVFile(params, vmid) {
} }
if (params['host-subject']) lines.push(`host-subject=${params['host-subject']}`); if (params['host-subject']) lines.push(`host-subject=${params['host-subject']}`);
// UX + USB
lines.push('fullscreen=0'); lines.push('fullscreen=0');
lines.push('title=%d — SPICE'); lines.push('title=%d — SPICE');
lines.push('delete-this-file=1'); // remote-viewer deletes the file after reading (hides password) lines.push('delete-this-file=1');
lines.push('toggle-fullscreen=shift+f11'); lines.push('toggle-fullscreen=shift+f11');
lines.push('release-cursor=shift+f12'); lines.push('release-cursor=shift+f12');
lines.push('secure-attention=ctrl+alt+end'); lines.push('secure-attention=ctrl+alt+end');
lines.push('usb-filter=-1,-1,-1,-1,0'); // allow all USB devices lines.push('usb-filter=-1,-1,-1,-1,0');
const vvPath = path.join(os.tmpdir(), `spice-${vmid}-${Date.now()}.vv`); const vvPath = path.join(os.tmpdir(), `spice-${vmid}-${Date.now()}.vv`);
fs.writeFileSync(vvPath, lines.join('\n') + '\n', { mode: 0o600 }); fs.writeFileSync(vvPath, lines.join('\n') + '\n', { mode: 0o600 });
@@ -143,8 +197,6 @@ function launchRemoteViewer(vvPath) {
] ]
: ['remote-viewer', 'virt-viewer']; : ['remote-viewer', 'virt-viewer'];
let launched = false;
function tryNext(i) { function tryNext(i) {
if (i >= candidates.length) { if (i >= candidates.length) {
dialog.showErrorBox( dialog.showErrorBox(
@@ -158,7 +210,7 @@ function launchRemoteViewer(vvPath) {
} }
const child = spawn(candidates[i], [vvPath], { detached: true, stdio: 'ignore' }); const child = spawn(candidates[i], [vvPath], { detached: true, stdio: 'ignore' });
child.on('error', () => tryNext(i + 1)); child.on('error', () => tryNext(i + 1));
child.on('spawn', () => { launched = true; }); child.on('spawn', () => { });
child.unref(); child.unref();
} }