diff --git a/package-lock.json b/package-lock.json index 3d85385..e7e51f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "aeromc", - "version": "1.1.0", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aeromc", - "version": "1.1.0", + "version": "1.1.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@xhayper/discord-rpc": "^1.3.4", "archiver": "^7.0.1", + "loopback-capture": "^3.0.2", + "node-window-manager": "^2.2.4", "skinview3d": "^3.4.2", "undici": "^6.28.0", "unzipper": "^0.12.5" @@ -1224,6 +1226,15 @@ ], "license": "MIT" }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2598,6 +2609,22 @@ "bare-events": "^2.7.0" } }, + "node_modules/extract-file-icon": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/extract-file-icon/-/extract-file-icon-0.3.2.tgz", + "integrity": "sha512-We5/HaMDlioOx3c9zDjit8RtKWEvT94sJF8jTujaKVOyWL8Kkqu4QGnwDr5fID6XsbKvWT0JJNuWuV2Tmr7yiw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "1.7.1" + } + }, + "node_modules/extract-file-icon/node_modules/node-addon-api": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.1.tgz", + "integrity": "sha512-2+DuKodWvwRTrCfKOeR24KIc5unKjOh8mz17NCzVnHWfjAdDqbfbjqh7gUT+BkXBRQM52+xCHciKWonJ3CbJMQ==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2666,6 +2693,12 @@ "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", "license": "MIT" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -3445,6 +3478,25 @@ "license": "MIT", "peer": true }, + "node_modules/loopback-capture": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/loopback-capture/-/loopback-capture-3.0.2.tgz", + "integrity": "sha512-65qyF9L7e+InnbEZP3Ic4C4J+SszoxEq84XpIZWJYH3/IgUpTKqBzJ4wCF+ShL3bvqV7I2wOEgQv9VfpaO2AMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "os": [ + "linux", + "win32" + ], + "dependencies": { + "bindings": "^1.5.0" + }, + "engines": { + "node": ">=18.17.0" + } + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -3636,12 +3688,41 @@ "license": "MIT", "optional": true }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT" }, + "node_modules/node-window-manager": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/node-window-manager/-/node-window-manager-2.2.4.tgz", + "integrity": "sha512-Dgq9a7qx6+Gi+BifWM79leteNz76QUtwSsdiqEe7SjvoJsc8TYQsRl1IA5uxqz5iSLeHS1iA/8YI/snoq0DblA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "extract-file-icon": "^0.3.2", + "node-addon-api": "2.0.0", + "node-gyp-build": "^4.2.1" + } + }, + "node_modules/node-window-manager/node_modules/node-addon-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz", + "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==", + "license": "MIT" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index d98a457..ae20405 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aeromc", "productName": "AeroMC", - "version": "1.1.0", + "version": "1.1.2", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "homepage": "https://m-viper.de", "launcherWebsite": "https://aeromc.viper.ipv64.net", @@ -26,6 +26,10 @@ "package.json", "LICENSE" ], + "asarUnpack": [ + "node_modules/loopback-capture/**/*", + "node_modules/node-window-manager/**/*" + ], "win": { "target": "nsis", "icon": "src/assets/icon.ico" @@ -42,6 +46,8 @@ "dependencies": { "@xhayper/discord-rpc": "^1.3.4", "archiver": "^7.0.1", + "loopback-capture": "^3.0.2", + "node-window-manager": "^2.2.4", "skinview3d": "^3.4.2", "undici": "^6.28.0", "unzipper": "^0.12.5" diff --git a/src/index.html b/src/index.html index 60c9774..3ec5169 100644 --- a/src/index.html +++ b/src/index.html @@ -51,6 +51,7 @@ + @@ -709,6 +710,11 @@ +
Noch keine Mods.
@@ -800,6 +806,123 @@ + + + + + + + + + - Die Vorschau zeigt die Figur so, wie sie im Spiel aussieht. Die Armform wirkt sich auf die Darstellung aus. + Mit der Maus ziehen dreht die Figur, Mausrad zoomt. Die Armform wirkt sich auf die Darstellung aus.
@@ -1275,6 +1398,7 @@ +
diff --git a/src/main.js b/src/main.js index e19ac98..8b0bccc 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ * (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.) */ -const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -85,6 +85,274 @@ function applyProxy(s) { let mainWindow = null; +// --------------------------------------------------------------------------- +// Popup-Fenster: alle großen Dialoge als eigene, frei verschiebbare Fenster +// (native Titelleiste ersetzt durch den eigenen dunklen Kopfbereich) statt +// als Modal im Hauptfenster. Laden dieselbe index.html mit z. B. +// "?popout=mods&instanceId=..." – der Renderer blendet dann nur diesen einen +// Dialog vollflächig ein. +// --------------------------------------------------------------------------- + +const popoutWindows = new Map(); // modalId -> BrowserWindow + +const POPOUT_SPEC = { + settings: { title: 'AeroMC – Einstellungen', width: 980, height: 720, minWidth: 700, minHeight: 500 }, + console: { title: 'AeroMC – Konsole / Log', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + mods: { title: 'AeroMC – Mods', width: 900, height: 700, minWidth: 640, minHeight: 480 }, + crash: { title: 'AeroMC – Absturzbericht', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + logs: { title: 'AeroMC – Logdateien', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + content: { title: 'AeroMC – Welten & Pakete', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 }, + import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 }, + instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 }, + update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 }, + record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 }, +}; +const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 }; +let pendingUpdateInfo = null; // letztes Ergebnis von update:check/checkForUpdate, für Update-Popup + +// an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt +// muss z. B. auch im herausgelösten Konsolen-Fenster ankommen) +function broadcast(channel, payload) { + for (const w of [mainWindow, ...popoutWindows.values()]) { + if (w && !w.isDestroyed()) w.webContents.send(channel, payload); + } +} + +function openPopout(modalId, params) { + let win = popoutWindows.get(modalId); + if (win && !win.isDestroyed()) { + win.show(); + win.focus(); + // erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu + // laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren + win.webContents.send('popout:reopen', params || {}); + return; + } + const query = { popout: modalId }; + for (const [k, v] of Object.entries(params || {})) if (v !== undefined && v !== null) query[k] = String(v); + const spec = POPOUT_SPEC[modalId] || POPOUT_DEFAULT; + win = new BrowserWindow({ + width: spec.width, + height: spec.height, + minWidth: spec.minWidth, + minHeight: spec.minHeight, + title: spec.title || POPOUT_DEFAULT.title, + backgroundColor: '#1b1e24', + icon: windowIcon(), + frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + popoutWindows.set(modalId, win); + win.loadFile(path.join(__dirname, 'index.html'), { query }); + win.once('ready-to-show', () => win.show()); + win.webContents.on('did-fail-load', (_e, code, desc) => { + console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc); + }); + win.on('close', (e) => { + if (appIsQuitting) return; + e.preventDefault(); + // Läuft gerade eine Aufzeichnung, darf ein natives Schließen (Alt+F4, + // Taskleiste) sie nicht stillschweigend verstecken -> der Renderer + // entscheidet selbst (Stoppen+Speichern oder Abbrechen) + if (modalId === 'record' && recordingGuardActive) { + win.webContents.send('record:closeAttempt'); + return; + } + win.hide(); + }); + win.on('closed', () => { popoutWindows.delete(modalId); }); +} + +let recordingGuardActive = false; +ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; }); + +ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg)); +ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params)); +ipcMain.on('popout:hide', (e) => { + const w = BrowserWindow.fromWebContents(e.sender); + if (w && !w.isDestroyed()) w.hide(); +}); +ipcMain.handle('update:getPending', () => pendingUpdateInfo); + +// --------------------------------------------------------------------------- +// Spielaufzeichnung: Fenster-Erkennung per Fenstertitel (kein PID-Tracking +// nötig dafür - funktioniert unabhängig von Konto/settings.wrapperCommand). +// Video kommt vom Fenster, Systemton separat vom Bildschirm (Chromium erlaubt +// keine Pro-Fenster-Tonaufnahme) - siehe renderer.js für den Aufnahme-Teil. +// Die PID wird NUR für die optionale isolierte "Nur Minecraft"-Tonspur +// gebraucht (per-Prozess-Aufnahme via loopback-capture, siehe unten). +// --------------------------------------------------------------------------- + +const letzteQuickPlayServer = new Map(); // instanceId -> Server-Adresse +// Minecraft schreibt diese Zeile bei JEDER Verbindung zu einem Server ins Log +// - egal ob per Quick Play oder von Hand im Spiel verbunden, und egal ob das +// Aufzeichnungs-Fenster dabei schon offen war. Hier (statt im Renderer) +// verfolgt, weil das hier zentral für alle Fenster gilt, auch für ein erst +// SPÄTER geöffnetes Aufzeichnungs-Popup, das die Zeile sonst verpasst hätte. +const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i; +ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null); + +const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses +ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null); + +// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden +// soll - vom Renderer kurz vor dem eigentlichen Aufruf gesetzt (siehe +// setDisplayMediaRequestHandler weiter unten). Für den cursorfreien +// Erfassungsversuch: ganzer Bildschirm ohne Mauszeiger, der Zuschnitt aufs +// Minecraft-Fenster passiert danach per Canvas im Renderer (siehe +// recording:getWindowBounds). Schlägt das fehl, läuft die Erfassung über die +// alte, zuverlässige chromeMediaSource-Fenster-Methode (Mauszeiger sichtbar). +let ausstehendeErfassungsQuelle = null; +ipcMain.handle('recording:setCaptureTarget', (_e, sourceId) => { ausstehendeErfassungsQuelle = sourceId; }); + +// Sucht das Fenster des laufenden Minecraft-Prozesses (über die PID aus +// laufendePid, robuster als ein Titel-Abgleich) und ermittelt daraus: +// - die aktuellen Fenstergrenzen in Bildschirmkoordinaten +// - den Bildschirm, auf dem das Fenster gerade sitzt (Mehrschirm-fähig) +// - die zu diesem Bildschirm gehörende Erfassungsquelle für getDisplayMedia +// Liefert null, wenn das Fenster nicht gefunden wird (z. B. Minecraft nicht +// über den Launcher gestartet, oder bereits beendet) - der Renderer fällt in +// dem Fall auf die alte Fenster-Erfassung zurück statt abzustürzen. +async function findeFensterUndBildschirm(pid, fensterTitel) { + let windowManager; + try { ({ windowManager } = require('node-window-manager')); } + catch (err) { console.log('[Aufzeichnung] node-window-manager Fehler:', err.message); return null; } + let alleFenster; + try { alleFenster = windowManager.getWindows(); } + catch (err) { console.log('[Aufzeichnung] getWindows Fehler:', err.message); return null; } + + // Die PID kennt der Launcher nur für Prozesse, die er in DIESER laufenden + // Sitzung selbst gestartet hat - nach einem Neustart des Launchers (z. B. + // während Minecraft weiterläuft) ist laufendePid leer, obwohl das Spiel + // noch läuft. Dann über den EXAKTEN Fenstertitel suchen, den der Renderer + // schon über recording:listWindows (dort per /Minecraft/i gefiltert und + // vom Nutzer im Suchdialog bestätigt) positiv identifiziert hat - NICHT + // erneut breit nach /Minecraft/i suchen, das kann auf einem Rechner mit + // vielen offenen Fenstern (Editor, Browser-Tabs usw.) ein völlig falsches, + // unbeteiligtes Fenster treffen und dessen Inhalt zeigen/aufnehmen. + let fenster = pid ? alleFenster.find((w) => w.processId === pid) : null; + if (!fenster && fensterTitel) { + fenster = alleFenster.find((w) => { try { return w.getTitle() === fensterTitel; } catch { return false; } }); + } + if (!fenster) { console.log('[Aufzeichnung] kein Minecraft-Fenster gefunden (PID', pid, ', Titel', fensterTitel, ')'); return null; } + const fensterBounds = fenster.getBounds(); + if (!fensterBounds || !fensterBounds.width || !fensterBounds.height) { + console.log('[Aufzeichnung] ungültige Fenstergrenzen:', JSON.stringify(fensterBounds)); + return null; + } + + const anzeige = screen.getDisplayMatching(fensterBounds); + const screenSources = await desktopCapturer.getSources({ types: ['screen'] }); + const quelle = screenSources.find((s) => String(s.display_id) === String(anzeige.id)) || screenSources[0]; + if (!quelle) { console.log('[Aufzeichnung] keine Bildschirmquelle gefunden'); return null; } + + console.log('[Aufzeichnung] gefunden:', JSON.stringify({ fensterBounds, bildschirmBounds: anzeige.bounds, screenSourceId: quelle.id })); + return { + fensterBounds, + bildschirmBounds: anzeige.bounds, + screenSourceId: quelle.id, + }; +} + +ipcMain.handle('recording:getWindowBounds', async (_e, instanceId, fensterTitel) => { + const pid = laufendePid.get(instanceId); + return findeFensterUndBildschirm(pid, fensterTitel); +}); + +// Isolierte Ton-Aufnahme nur eines Prozesses (inkl. Kindprozesse) über +// Windows' Process-Loopback-API. Nur eine Aufnahme gleichzeitig aktiv. +let aktiveSpielAudioCapture = null; + +ipcMain.handle('recording:startGameAudio', (event, pid) => { + if (aktiveSpielAudioCapture) return { ok: false, message: 'Läuft bereits.' }; + let loopback; + try { + loopback = require('loopback-capture'); + } catch (err) { + return { ok: false, message: 'Spielton-Isolierung nicht verfügbar: ' + err.message }; + } + const win = BrowserWindow.fromWebContents(event.sender); + try { + const capture = new loopback.LoopbackCapture(); + capture.start(pid, true, (chunk) => { + if (win && !win.isDestroyed()) win.webContents.send('recording:gameAudioChunk', new Uint8Array(chunk)); + }); + aktiveSpielAudioCapture = { capture }; + return { ok: true }; + } catch (err) { + return { ok: false, message: 'Spielton-Aufnahme fehlgeschlagen (Windows 10 Version 2004+ erforderlich, oder Spiel bereits beendet): ' + err.message }; + } +}); + +ipcMain.handle('recording:stopGameAudio', () => { + if (aktiveSpielAudioCapture) { + try { aktiveSpielAudioCapture.capture.stop(); } catch { /* egal, wird verworfen */ } + aktiveSpielAudioCapture = null; + } + return true; +}); + +ipcMain.handle('recording:listWindows', async () => { + const sources = await desktopCapturer.getSources({ + types: ['window'], + thumbnailSize: { width: 200, height: 120 }, + }); + return sources + .filter((s) => /Minecraft/i.test(s.name)) + .map((s) => ({ + id: s.id, + name: s.name, + thumbnail: s.thumbnail && !s.thumbnail.isEmpty() ? s.thumbnail.toDataURL() : null, + })); +}); + +// Für den Systemton reicht irgendeine Bildschirmquelle als Pflichtfeld für +// getUserMedia -- der Ton ist ohnehin nicht pro Bildschirm getrennt. +ipcMain.handle('recording:getAudioSourceId', async () => { + const sources = await desktopCapturer.getSources({ types: ['screen'] }); + return (sources[0] && sources[0].id) || null; +}); + +function aufzeichnungsOrdner(instanceName) { + return path.join(app.getPath('videos'), 'AeroMC Launcher', + String(instanceName || 'Instanz').replace(/[^\w.-]+/g, '_')); +} + +ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel) => { + try { + const zielOrdner = aufzeichnungsOrdner(instanceName); + fs.mkdirSync(zielOrdner, { recursive: true }); + const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16); + const serverTeil = serverLabel ? String(serverLabel).replace(/[^\w.-]+/g, '_') + '_' : ''; + let zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}.webm`); + let n = 2; + while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.webm`); n++; } + fs.writeFileSync(zielDatei, Buffer.from(buffer)); + notify('Aufzeichnung gespeichert', `${instanceName}: ${path.basename(zielDatei)}`); + return { ok: true, path: zielDatei }; + } catch (err) { + return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; + } +}); + +ipcMain.handle('recording:openFolder', (_e, instanceName) => { + const dir = aufzeichnungsOrdner(instanceName); + fs.mkdirSync(dir, { recursive: true }); + shell.openPath(dir); + return true; +}); + // --------------------------------------------------------------------------- // Verknüpfungen: "AeroMC.exe --launch " startet eine Instanz direkt // --------------------------------------------------------------------------- @@ -217,7 +485,12 @@ function createWindow(startHidden) { // Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der // Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist. mainWindow.on('close', (e) => { - if (appIsQuitting || !store.getSettings().autostart) return; + if (appIsQuitting || !store.getSettings().autostart) { + // Hauptfenster schließt wirklich -> keine nur versteckten Popup-Fenster + // zurücklassen (die sonst window-all-closed nie erreichen ließen) + for (const w of popoutWindows.values()) if (!w.isDestroyed()) w.destroy(); + return; + } e.preventDefault(); mainWindow.hide(); }); @@ -306,6 +579,17 @@ app.whenReady().then(() => { const startHidden = autostartAn && process.argv.includes('--hidden'); createWindow(startHidden); + // Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog: + // das vom Renderer per recording:setCaptureTarget gewählte Fenster wird + // hier direkt zurückgegeben, statt den Nutzer nochmal wählen zu lassen + session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => { + desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { + const quelle = sources.find((s) => s.id === ausstehendeErfassungsQuelle) || sources[0]; + console.log('[Aufzeichnung] setDisplayMediaRequestHandler: angefordert=', ausstehendeErfassungsQuelle, 'gewählt=', quelle && quelle.id); + callback(quelle ? { video: quelle } : {}); + }); + }, { useSystemPicker: false }); + // Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800); @@ -329,17 +613,14 @@ app.whenReady().then(() => { setTimeout(async () => { try { const info = await updater.checkForUpdate(app.getVersion()); - if (mainWindow && !mainWindow.isDestroyed()) { - // Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen - // kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt - mainWindow.webContents.send('update:checked', info); - if (info.available) mainWindow.webContents.send('update:available', info); - } + pendingUpdateInfo = info; + // Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen + // kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt + broadcast('update:checked', info); + if (info.available) broadcast('update:available', info); } catch (err) { // offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) }); - } + broadcast('update:checked', { error: (err && err.message) || String(err) }); } }, 4000); @@ -351,7 +632,7 @@ app.whenReady().then(() => { const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID; auth.getValidAccount(cid) .then((acc) => { - if (acc && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:refreshed'); + if (acc) broadcast('auth:refreshed'); }) .catch(() => { /* offline o. Ä. – Konto bleibt gespeichert */ }); @@ -393,6 +674,15 @@ ipcMain.handle('settings:save', (_e, patch) => { applyProxy(s); if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart); if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s); + // Einstellungen können jetzt auch aus dem Popup-Fenster gespeichert werden – + // das Hauptfenster muss Theme/Sortierung/Bedrock-Sichtbarkeit/Skin-Hintergrund + // dann selbst nachziehen. NICHT bei der beiläufigen "letzte Instanz"-Notiz + // (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das + // Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert. + const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId'); + if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('settings:changed', s); + } return s; }); @@ -522,14 +812,14 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => { * per Hand zugeschickt). Die Datei taucht danach ganz normal in der * "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt. */ -ipcMain.handle('mods:addFile', async (_e, id, art) => { +ipcMain.handle('mods:addFile', async (e, id, art) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const typ = services.inhaltsArt(art || 'mod'); const filters = (art || 'mod') === 'mod' ? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }] : [{ name: typ.label || 'Datei', extensions: ['zip'] }]; - const res = await dialog.showOpenDialog(mainWindow, { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Datei hinzufügen', properties: ['openFile', 'multiSelections'], filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]), @@ -629,7 +919,7 @@ ipcMain.handle('optifine:setup', async (_e, id) => { let prep; try { prep = await launcher.prepareAndLaunch(opts, (evt) => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt)); + broadcast('launch:progress', Object.assign({ id }, evt)); }); } catch (err) { return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message }; @@ -796,7 +1086,7 @@ ipcMain.handle('mods:checkUpdates', async (_e, id, art) => { if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' }; try { const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p); + broadcast('mods:updateProgress', p); }, art || 'mod'); return { ok: true, ...res }; } catch (err) { @@ -864,8 +1154,8 @@ ipcMain.handle('skin:profile', async () => { catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; } }); -ipcMain.handle('skin:choose', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('skin:choose', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Skin auswählen', properties: ['openFile'], filters: [{ name: 'PNG-Bild', extensions: ['png'] }], @@ -886,14 +1176,22 @@ ipcMain.handle('skin:choose', async () => { ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; - try { return await skins.setzeSkin(token, dateiPfad, variante); } + try { + const res = await skins.setzeSkin(token, dateiPfad, variante); + if (res && res.ok) broadcast('skin:changed'); + return res; + } catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; } }); ipcMain.handle('skin:reset', async () => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; - try { return await skins.setzeSkinZurueck(token); } + try { + const res = await skins.setzeSkinZurueck(token); + if (res && res.ok) broadcast('skin:changed'); + return res; + } catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } }); ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id)); @@ -905,7 +1203,7 @@ ipcMain.handle('auth:login', async () => { const account = await auth.login( clientId, (code) => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:code', code); + broadcast('auth:code', code); }, () => {}, ); @@ -923,9 +1221,17 @@ ipcMain.handle('instances:list', () => store.listInstances()); ipcMain.handle('instances:get', (_e, id) => store.getInstance(id)); -ipcMain.handle('instances:create', (_e, data) => store.createInstance(data)); +ipcMain.handle('instances:create', (_e, data) => { + const res = store.createInstance(data); + broadcast('instances:changed'); + return res; +}); -ipcMain.handle('instances:update', (_e, id, patch) => store.updateInstance(id, patch)); +ipcMain.handle('instances:update', (_e, id, patch) => { + const res = store.updateInstance(id, patch); + broadcast('instances:changed'); + return res; +}); ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id)); @@ -1121,9 +1427,7 @@ async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) if (geschuetzt(rel)) continue; const dest = path.join(gameDir, rel.replace(/\//g, path.sep)); if (!inside(dest)) continue; - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' }); - } + broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' }); try { const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (!res.ok) continue; @@ -1231,9 +1535,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => { const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0]; if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' }; - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' }); - } + broadcast('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' }); const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (!dl.ok) return { ok: false, message: 'Download fehlgeschlagen (HTTP ' + dl.status + ').' }; const buf = Buffer.from(await dl.arrayBuffer()); @@ -1276,6 +1578,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => { }), }); + broadcast('instances:changed'); notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`); return { ok: true, @@ -1317,9 +1620,7 @@ async function importCurseForge(buf, zip, zugang) { const gameDir = store.gameDir(created.id); const inside = (dest) => dest.startsWith(gameDir + path.sep); const melde = (detail, current, total) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { phase: 'modpack', current, total, detail }); - } + broadcast('launch:progress', { phase: 'modpack', current, total, detail }); }; // 1) Dateiliste über die CurseForge-API auflösen @@ -1388,11 +1689,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => { const projectId = project && (project.projectId || project.id); if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' }; const gameVersion = (project && project.gameVersion) || ''; - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { - phase: 'modpack', detail: 'Modpack-Version wird ermittelt …', - }); - } + broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' }); const file = await services.modrinthResolveModpack({ projectId, gameVersion }); if (!file) { return { @@ -1402,12 +1699,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => { : 'Keine Modpack-Version gefunden.', }; } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { - phase: 'modpack', - detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …', - }); - } + broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' }); const buf = await services.downloadBuffer(file.url); const zip = forge.readZipIndex(buf); if (!zip['modrinth.index.json']) { @@ -1427,6 +1719,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => { result.instance = store.getInstance(inst.id); } if (result && result.ok && result.instance) { + broadcast('instances:changed'); notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`); } return result; @@ -1446,11 +1739,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => { const modId = Number(rawId); if (!modId) return { ok: false, message: 'Kein Modpack gewählt.' }; const gameVersion = (project && project.gameVersion) || ''; - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { - phase: 'modpack', detail: 'Modpack-Version wird ermittelt …', - }); - } + broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' }); const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion); if (!file || !file.url) { return { @@ -1460,11 +1749,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => { : 'Keine passende Modpack-Datei gefunden.', }; } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { - phase: 'modpack', detail: 'Lade ' + file.filename + ' …', - }); - } + broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' }); const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url; const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen); const zip = forge.readZipIndex(buf); @@ -1473,6 +1758,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => { } const result = await importCurseForge(buf, zip, zugang); if (result && result.ok && result.instance) { + broadcast('instances:changed'); notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`); } return result; @@ -1505,6 +1791,7 @@ async function importModpackFromPath(filePath) { return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' }; } if (result && result.ok && result.instance) { + broadcast('instances:changed'); notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`); } return result; @@ -1513,8 +1800,8 @@ async function importModpackFromPath(filePath) { } } -ipcMain.handle('modpack:import', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('modpack:import', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Modpack importieren', properties: ['openFile'], filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }], @@ -1643,14 +1930,16 @@ async function importInstanceZipFromPath(filePath) { } } -ipcMain.handle('instances:import', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('instances:import', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Instanz importieren', properties: ['openFile'], filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }], }); if (res.canceled) return { ok: false, canceled: true }; - return importInstanceZipFromPath(res.filePaths[0]); + const result = await importInstanceZipFromPath(res.filePaths[0]); + if (result && result.ok) broadcast('instances:changed'); + return result; }); /* @@ -1696,6 +1985,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => { if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack); // .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen const instanceRes = await importInstanceZipFromPath(filePath); + if (instanceRes && instanceRes.ok) broadcast('instances:changed'); return Object.assign({ kind: 'instance' }, instanceRes); } @@ -1710,7 +2000,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => { function notify(title, body) { try { if (store.getSettings().desktopNotifications === false) return; - if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return; + if (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isFocused())) return; if (!Notification.isSupported()) return; const n = new Notification({ title, body, silent: false }); n.on('click', () => { @@ -1844,7 +2134,7 @@ ipcMain.handle('backup:restore', async (_e, sourcePath) => { const spielendeKonten = new Map(); function meldeKontenAenderung() { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('accounts:changed'); + broadcast('accounts:changed'); } function kontoSpieltJetzt(kontoId) { @@ -1862,6 +2152,12 @@ function kontoSpieltNichtMehr(kontoId) { } ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => { + // für die Spielaufzeichnung merken, mit welchem Server (Quick Play) diese + // Instanz zuletzt gestartet wurde -- muss im Hauptprozess liegen, weil + // Serverliste und Aufzeichnung zwei getrennte Popup-Fenster mit jeweils + // eigenem Renderer-Speicher sind + if (launchOpts && launchOpts.quickPlayServer) letzteQuickPlayServer.set(id, launchOpts.quickPlayServer); + else letzteQuickPlayServer.delete(id); const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!inst.minecraft.version) { @@ -1884,9 +2180,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => { // die nebenher läuft, während Minecraft dieselben Dateien anfasst, wäre // nicht verlässlich. if (settings.autoBackupEnabled && settings.autoBackupBeforeLaunch) { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' }); - } + broadcast('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' }); await backupNachZeitplan('vor-dem-start').catch(() => {}); } @@ -1947,6 +2241,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => { if (evt.phase === 'exit') { if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); } discordpresence.clearActivity(); + laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg if (launchStart) { const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000)); launchStart = 0; // Spielzeit nur einmal gutschreiben @@ -1954,14 +2249,23 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => { if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs }); } } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt)); + if (evt.phase === 'log') { + const m = VERBINDUNGS_ZEILE.exec(evt.line); + if (m) { + const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`; + letzteQuickPlayServer.set(id, server); + // damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im + // Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde + broadcast('recording:serverDetected', { id, server }); + } } + broadcast('launch:progress', Object.assign({ id }, evt)); }); // Start fehlgeschlagen, obwohl schon gezählt -> Zählung zurücknehmen, // sonst bliebe das Konto für immer als "spielt" stehen if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); } if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() }); + if (res.ok && res.pid) laufendePid.set(id, res.pid); return res; } catch (err) { if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); } @@ -1973,8 +2277,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => { // IPC – Dialoge // --------------------------------------------------------------------------- -ipcMain.handle('dialog:pickFolder', async (_e, title) => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('dialog:pickFolder', async (e, title) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: title || 'Ordner wählen', properties: ['openDirectory', 'createDirectory'], }); @@ -2010,8 +2314,8 @@ ipcMain.handle('file:dataUri', (_e, filePath) => { } catch { return null; } }); -ipcMain.handle('dialog:pickJava', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('dialog:pickJava', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'java.exe wählen', properties: ['openFile'], filters: [{ name: 'Java', extensions: ['exe'] }], @@ -2028,8 +2332,8 @@ ipcMain.handle('app:openExternal', (_e, url) => { // IPC – Instanzen aus MultiMC/Prism übernehmen // --------------------------------------------------------------------------- -ipcMain.handle('mmc:pickFolder', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('mmc:pickFolder', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'MultiMC-Ordner wählen (der Ordner mit MultiMC.exe)', properties: ['openDirectory'], }); @@ -2050,15 +2354,14 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => { done.push(imported); } catch (err) { failed.push({ name: entry.name, message: err.message }); } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length }); - } + broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length }); } + if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); -ipcMain.handle('cf:pickFolder', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('cf:pickFolder', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)', properties: ['openDirectory'], }); @@ -2079,15 +2382,14 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => { done.push(imported); } catch (err) { failed.push({ name: entry.name, message: err.message }); } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length }); - } + broadcast('cf:progress', { current: done.length + failed.length, total: entries.length }); } + if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); -ipcMain.handle('mc:pickFolder', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('mc:pickFolder', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)', properties: ['openDirectory'], }); @@ -2108,15 +2410,14 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => { done.push(imported); } catch (err) { failed.push({ name: entry.name, message: err.message }); } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length }); - } + broadcast('mc:progress', { current: done.length + failed.length, total: entries.length }); } + if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); -ipcMain.handle('technic:pickFolder', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('technic:pickFolder', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)', properties: ['openDirectory'], }); @@ -2137,10 +2438,9 @@ ipcMain.handle('technic:import', (_e, entries, copyData) => { done.push(imported); } catch (err) { failed.push({ name: entry.name, message: err.message }); } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length }); - } + broadcast('technic:progress', { current: done.length + failed.length, total: entries.length }); } + if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); @@ -2154,9 +2454,12 @@ ipcMain.handle('technicPlatform:search', async (_e, query) => { ipcMain.handle('technicPlatform:install', async (_e, slug) => { try { const res = await technicdiscover.installFromPlatform(store, slug, (p) => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p); + broadcast('technicPlatform:progress', p); }); - if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`); + if (res.ok && res.instance) { + broadcast('instances:changed'); + notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`); + } return res; } catch (err) { return { ok: false, message: err.message }; @@ -2179,8 +2482,8 @@ ipcMain.handle('bedrock:openFolder', (_e, kind) => { return true; }); -ipcMain.handle('bedrock:installAddon', async () => { - const res = await dialog.showOpenDialog(mainWindow, { +ipcMain.handle('bedrock:installAddon', async (e) => { + const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Add-on oder Welt importieren', properties: ['openFile'], filters: [{ name: 'Bedrock-Pakete', extensions: ['mcpack', 'mcaddon', 'mcworld', 'mctemplate'] }], @@ -2229,15 +2532,18 @@ ipcMain.handle('icons:extract', () => { // --------------------------------------------------------------------------- ipcMain.handle('update:check', async () => { - try { return await updater.checkForUpdate(app.getVersion()); } - catch (err) { return { available: false, error: err.message }; } + try { + const info = await updater.checkForUpdate(app.getVersion()); + pendingUpdateInfo = info; + return info; + } catch (err) { return { available: false, error: err.message }; } }); // Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden ipcMain.handle('update:install', async (_e, asset) => { try { const file = await updater.downloadAndInstall(asset, (p) => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update:progress', p); + broadcast('update:progress', p); }); const res = updater.applyUpdate(file); // Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt diff --git a/src/pcm-worklet.js b/src/pcm-worklet.js new file mode 100644 index 0000000..0c4a3c6 --- /dev/null +++ b/src/pcm-worklet.js @@ -0,0 +1,50 @@ +// AudioWorklet-Prozessor für die isolierte "Nur Minecraft"-Tonaufnahme. +// +// loopback-capture liefert PCM-Häppchen unregelmäßig getaktet über IPC (mal +// etwas früher, mal etwas später). Würde man sie direkt nacheinander als +// einzelne AudioBufferSourceNode-Aufrufe abspielen, würde jede kleine +// Verzögerung als hörbares Knacksen/Aussetzer auffallen. Stattdessen landen +// ankommende Häppchen hier in einem Ringpuffer; der Audio-Rendering-Thread +// liest in gleichmäßigem Takt (process() läuft alle 128 Samples) daraus. +// Kleine Zeitschwankungen bei der Anlieferung werden so unhörbar +// abgefedert. Läuft der Puffer doch mal leer, gibt es kurz Stille statt +// eines Knacksens. +class PcmRingBufferProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.capacity = sampleRate * 2; // 2 Sekunden Puffer je Kanal + this.ring = [new Float32Array(this.capacity), new Float32Array(this.capacity)]; + this.writeIdx = 0; + this.readIdx = 0; + this.available = 0; // gepufferte Frames (nicht Samples) + this.port.onmessage = (e) => this._enqueue(e.data); + } + + _enqueue([left, right]) { + const n = left.length; + for (let i = 0; i < n; i++) { + const idx = (this.writeIdx + i) % this.capacity; + this.ring[0][idx] = left[i]; + this.ring[1][idx] = right[i]; + } + this.writeIdx = (this.writeIdx + n) % this.capacity; + this.available = Math.min(this.capacity, this.available + n); + } + + process(_inputs, outputs) { + const output = outputs[0]; + const frames = output[0].length; + const playable = Math.min(frames, this.available); + for (let i = 0; i < playable; i++) { + const idx = (this.readIdx + i) % this.capacity; + output[0][i] = this.ring[0][idx]; + output[1][i] = this.ring[1][idx]; + } + for (let i = playable; i < frames; i++) { output[0][i] = 0; output[1][i] = 0; } + this.readIdx = (this.readIdx + playable) % this.capacity; + this.available -= playable; + return true; // Prozessor am Leben halten, auch wenn gerade nichts ankommt + } +} + +registerProcessor('pcm-player', PcmRingBufferProcessor); diff --git a/src/preload.js b/src/preload.js index aded893..5b65b2f 100644 --- a/src/preload.js +++ b/src/preload.js @@ -7,6 +7,15 @@ const { contextBridge, ipcRenderer } = require('electron'); +// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses, +// damit man sie auch ohne offene DevTools sieht +window.addEventListener('error', (e) => { + ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message); +}); +window.addEventListener('unhandledrejection', (e) => { + ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason)); +}); + contextBridge.exposeInMainWorld('api', { // Einstellungen getSettings: () => ipcRenderer.invoke('settings:get'), @@ -241,4 +250,57 @@ contextBridge.exposeInMainWorld('api', { pickJava: () => ipcRenderer.invoke('dialog:pickJava'), appInfo: () => ipcRenderer.invoke('app:info'), appLicense: () => ipcRenderer.invoke('app:license'), + + // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster + popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params), + popoutHide: () => ipcRenderer.send('popout:hide'), + onPopoutReopen: (cb) => { + const l = (_e, params) => cb(params); + ipcRenderer.on('popout:reopen', l); + return () => ipcRenderer.removeListener('popout:reopen', l); + }, + onSettingsChanged: (cb) => { + const l = (_e, s) => cb(s); + ipcRenderer.on('settings:changed', l); + return () => ipcRenderer.removeListener('settings:changed', l); + }, + onInstancesChanged: (cb) => { + const l = () => cb(); + ipcRenderer.on('instances:changed', l); + return () => ipcRenderer.removeListener('instances:changed', l); + }, + onSkinChanged: (cb) => { + const l = () => cb(); + ipcRenderer.on('skin:changed', l); + return () => ipcRenderer.removeListener('skin:changed', l); + }, + updateGetPending: () => ipcRenderer.invoke('update:getPending'), + + // Spielaufzeichnung + recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'), + recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'), + recordingSave: (instanceName, buffer, serverLabel) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel), + recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName), + recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId), + onRecordingServerDetected: (cb) => { + const l = (_e, payload) => cb(payload); + ipcRenderer.on('recording:serverDetected', l); + return () => ipcRenderer.removeListener('recording:serverDetected', l); + }, + recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId), + recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId), + recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel), + recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid), + recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'), + onGameAudioChunk: (cb) => { + const l = (_e, chunk) => cb(chunk); + ipcRenderer.on('recording:gameAudioChunk', l); + return () => ipcRenderer.removeListener('recording:gameAudioChunk', l); + }, + recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active), + onRecordCloseAttempt: (cb) => { + const l = () => cb(); + ipcRenderer.on('record:closeAttempt', l); + return () => ipcRenderer.removeListener('record:closeAttempt', l); + }, }); diff --git a/src/renderer.js b/src/renderer.js index 7624062..00456d0 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -496,6 +496,12 @@ const collapsedGroups = new Set(); // eingeklappte Gruppen let consoleBuffer = ''; // gesammelte Log-Ausgabe (roh, für Kopieren) let consolePending = ''; // noch nicht abgeschlossene Zeile +// ---------- Popup-Fenster ---------- +// In einem eigenen Popup-Fenster (aktuell nur Einstellungen) steht in der URL +// "?popout=settings" – dann blendet die Oberfläche die Haupt-UI aus und zeigt +// nur dieses eine Modal vollflächig (siehe styles.css ".is-popout"/".popout-host"). +const POPOUT = new URLSearchParams(location.search).get('popout'); + // ---------- DOM-Helfer ---------- const el = (id) => document.getElementById(id); function show(id) { el(id).classList.remove('hidden'); } @@ -568,6 +574,7 @@ function setImportKind(kind) { } function openImportDialog(kind = 'zip') { + if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; } mcFound = []; el('mc-result').classList.add('hidden'); el('mc-do').disabled = true; @@ -1102,6 +1109,7 @@ async function applyBedrockVisibility() { } async function openBedrock() { + if (POPOUT !== 'bedrock') { window.api.popoutOpen('bedrock'); return; } show('modal-bedrock'); el('bd-sub').textContent = 'Wird geprüft …'; const info = await window.api.bedrockDetect(); @@ -1214,6 +1222,8 @@ let pendingUpdate = null; const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' }; function showUpdateDialog(info) { + if (POPOUT !== 'update') { window.api.popoutOpen('update'); return; } + if (!info) return; // Popup geöffnet, aber (aktuell) kein Update-Ergebnis vorhanden pendingUpdate = info; const channel = CHANNEL_LABEL[info.installKind] || null; el('upd-current').textContent = formatVersionLabel(info.currentVersion); @@ -1923,6 +1933,7 @@ function resetIconControls() { } async function openCreate() { + if (POPOUT !== 'instance') { window.api.popoutOpen('instance'); return; } editingId = null; el('instance-modal-title').textContent = 'Neue Instanz'; el('f-name').value = ''; @@ -1945,6 +1956,7 @@ async function openCreate() { } async function openEdit(inst) { + if (POPOUT !== 'instance') { window.api.popoutOpen('instance', { instanceId: inst.id }); return; } editingId = inst.id; el('instance-modal-title').textContent = 'Instanz bearbeiten'; el('f-name').value = inst.name; @@ -2163,6 +2175,7 @@ let zuUebertragenderServer = null; async function openServers() { if (!currentDetailId) return; + if (POPOUT !== 'servers') { window.api.popoutOpen('servers', { instanceId: currentDetailId }); return; } const inst = await window.api.getInstance(currentDetailId); el('servers-title').textContent = 'Server – ' + (inst ? inst.name : ''); el('srv-name').value = ''; @@ -2333,60 +2346,92 @@ async function doServerCopy() { let gewaehlterSkin = null; -/* - * Bild nur zeigen, wenn es wirklich geladen wurde – ein ohne Quelle - * stellt sonst das Platzhalter-Symbol des Browsers dar. - */ -function zeigeSkinBild(bildId, leerId, quelle, leerText) { - const bild = el(bildId); - const leer = el(leerId); - leer.textContent = leerText; - if (!quelle) { - bild.classList.add('hidden'); - bild.removeAttribute('src'); - leer.classList.remove('hidden'); - return; - } - bild.onload = () => { bild.classList.remove('hidden'); leer.classList.add('hidden'); }; - bild.onerror = () => { - bild.classList.add('hidden'); - bild.removeAttribute('src'); - leer.textContent = 'Vorschau nicht ladbar'; - leer.classList.remove('hidden'); - }; - bild.src = quelle; -} - // Textur einer Seite merken, damit bei einem Wechsel der Armform beide // Figuren neu gerendert werden können let skinTexturAktuell = ''; let skinTexturNeu = ''; -// Skin-Textur als Figur rendern; klappt das nicht, wird die Textur gezeigt +// laufende Viewer je Canvas -- vor einem Neuaufbau erst abräumen, sonst +// stapeln sich bei jedem Wechsel (Datei, Armform, ...) weitere WebGL-Kontexte +const skinViewers = {}; +function schliesseSkinViewer(bildId) { + if (skinViewers[bildId]) { + try { skinViewers[bildId].dispose(); } catch { /* egal */ } + delete skinViewers[bildId]; + } +} + +/* + * Skin-Textur als frei mit der Maus drehbare 3D-Figur zeigen (Ziehen dreht, + * Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht, + * wird stattdessen ein Hinweistext gezeigt. + */ async function zeigeSkinFigur(bildId, leerId, textur, leerText) { - if (!textur) { zeigeSkinBild(bildId, leerId, '', leerText); return; } - el(leerId).textContent = 'wird gerendert …'; + schliesseSkinViewer(bildId); + const canvas = el(bildId); + const leer = el(leerId); + canvas.classList.add('hidden'); + if (!textur) { leer.textContent = leerText; leer.classList.remove('hidden'); return; } + if (typeof skinview3d === 'undefined') { + leer.textContent = 'Vorschau nicht verfügbar'; + leer.classList.remove('hidden'); + return; + } + leer.textContent = 'wird gerendert …'; + leer.classList.remove('hidden'); const modell = el('skin-variante').value === 'slim' ? 'slim' : 'default'; - const figur = await renderSkin3D(textur, 'standing', { modell, breite: 260, hoehe: 420 }); - zeigeSkinBild(bildId, leerId, figur || textur, leerText); + try { + const viewer = new skinview3d.SkinViewer({ + canvas, + width: canvas.clientWidth || 260, + height: canvas.clientHeight || 288, + skin: textur, + model: modell, + }); + // Auf das Laden der Skin-Textur warten, bevor Pose/Kamera gesetzt werden + await new Promise((resolve, reject) => { + const t = setTimeout(reject, 8000); + const check = () => { + if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); } + else setTimeout(check, 60); + }; + check(); + }); + const pose = SKIN_POSES.standing; + viewer.fov = pose.fov; + viewer.zoom = pose.zoom; + viewer.autoRotate = false; + viewer.animation = null; + pose.apply(viewer.playerObject); + viewer.controls.enableRotate = true; + viewer.controls.enableZoom = true; + viewer.controls.enablePan = false; + skinViewers[bildId] = viewer; + canvas.classList.remove('hidden'); + leer.classList.add('hidden'); + } catch { + leer.textContent = 'Vorschau nicht ladbar'; + leer.classList.remove('hidden'); + } } async function openSkin() { + if (POPOUT !== 'skin') { window.api.popoutOpen('skin'); return; } gewaehlterSkin = null; skinTexturAktuell = ''; skinTexturNeu = ''; el('skin-pfad').value = ''; el('skin-apply').disabled = true; el('skin-status').textContent = ''; - zeigeSkinBild('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt'); - zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …'); + await zeigeSkinFigur('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt'); + await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …'); el('skin-konto').textContent = 'Profil wird geladen …'; show('modal-skin'); const p = await window.api.skinProfile(); if (!p.ok) { el('skin-konto').textContent = p.message || 'Profil konnte nicht geladen werden.'; - zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen'); + await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen'); return; } el('skin-konto').textContent = `Angemeldet als ${p.name}. Die Änderung gilt für dieses Konto.`; @@ -2454,6 +2499,7 @@ let aktuellesLog = ''; async function openLogs() { if (!currentDetailId) return; + if (POPOUT !== 'logs') { window.api.popoutOpen('logs', { instanceId: currentDetailId }); return; } const inst = await window.api.getInstance(currentDetailId); el('logs-title').textContent = 'Logdateien – ' + (inst ? inst.name : ''); const auswahl = el('logs-select'); @@ -2507,6 +2553,7 @@ let aktuellerCrash = null; async function zeigeAbsturzbericht(instanzId) { const id = instanzId || currentDetailId; if (!id) return; + if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id }); return; } // Minecraft schreibt den Bericht erst kurz nach dem Ende await new Promise((r) => setTimeout(r, 900)); @@ -2538,7 +2585,10 @@ async function zeigeAbsturzbericht(instanzId) { // Fortschrittsmeldungen der Download-/Start-Pipeline function onLaunchEvt(evt) { const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' }; - if (evt.phase === 'log') { appendConsole(evt.line); return; } + if (evt.phase === 'log') { + appendConsole(evt.line); + return; + } if (evt.phase === 'exit') { status('Minecraft beendet (Code ' + evt.code + ')'); el('status-right').textContent = ''; @@ -2554,13 +2604,17 @@ function onLaunchEvt(evt) { + 'internen Shutdown-Watchdog hart terminiert (z. B. durch einen Mod, dessen Threads ' + 'nicht rechtzeitig beenden – etwa JourneyMap WebMap). Kein Absturz während des Spiels.\n'); } - // Absturzbericht suchen, bevor über die Konsole entschieden wird – - // ein Bericht sagt mehr als der Java-Auswurf - if (crashed) zeigeAbsturzbericht(evt.id); - window.api.getSettings().then((s) => { - if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; } - if (s.autoCloseConsole) hide('modal-console'); - }).catch(() => {}); + // Absturzbericht suchen, bevor über die Konsole entschieden wird – ein + // Bericht sagt mehr als der Java-Auswurf. Das entscheidet nur das + // Hauptfenster – sonst würde z. B. das Konsolen-Popup-Fenster bei einem + // Absturz zusätzlich einen eigenen Absturzbericht aufmachen. + if (!POPOUT) { + if (crashed) zeigeAbsturzbericht(evt.id); + window.api.getSettings().then((s) => { + if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; } + if (s.autoCloseConsole) hide('modal-console'); + }).catch(() => {}); + } return; } // Startzeitpunkt merken: nur Berichte ab hier gehören zu diesem Lauf @@ -2603,6 +2657,7 @@ function appendConsole(text) { } } function openConsole() { + if (POPOUT !== 'console') { window.api.popoutOpen('console'); return; } const out = el('console-out'); out.innerHTML = ''; const lines = (consoleBuffer || '(noch keine Ausgabe)').split('\n'); @@ -2614,6 +2669,7 @@ function openConsole() { // ---------- Welten & Ressourcenpakete ---------- async function openContent() { + if (POPOUT !== 'content') { window.api.popoutOpen('content', { instanceId: currentDetailId }); return; } const inst = await window.api.getInstance(currentDetailId); el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete – ' + inst.name; await renderContentLists(); @@ -2645,7 +2701,637 @@ function updateScreensSelectionUi() { }); } +// ---------- Spielaufzeichnung ---------- +let mcVideoStream = null; +let mcSearchTimer = null; +let mcSearchElapsed = 0; +let mcCurrentSource = null; // zuletzt erfasste Fensterquelle - für ein Neu-Erfassen bei Auflösungswechsel + +// Auflösung/Qualität/Bildrate der Aufnahme (bleiben über mehrere Aufnahmen in +// diesem Fenster erhalten, wie schon bei den Audioeinstellungen) +let mcResolution = 'native'; +let mcQuality = 'medium'; +let mcFramerate = 30; +let mcActualSize = null; // {width,height} der zuletzt tatsächlich erfassten Auflösung +let mcMicSelectedIds = []; // gespeicherte Mikrofon-Auswahl (deviceIds) - für den Aufbau der Mikrofonliste +let mcCursorAusgeblendet = false; // true, wenn die zuletzt erfasste Vorschau ohne Mauszeiger läuft +let mcRohStream = null; // native, unskalierte Bildschirm-Erfassung, falls mcVideoStream ein daraus erzeugter Canvas-Stream ist +let mcCanvasRAF = null; // requestAnimationFrame-Kennung der Zuschnitt-/Skalierungs-Zeichenschleife +let mcCanvasVideoEl = null; // verstecktes