diff --git a/package-lock.json b/package-lock.json index c4b1ad1..fa94c49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aeromc", - "version": "1.0.4-beta", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aeromc", - "version": "1.0.4-beta", + "version": "1.0.5", "license": "MIT", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index 6e206fb..2e10e05 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aeromc", "productName": "AeroMC", - "version": "1.0.4", + "version": "1.0.5", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "homepage": "https://m-viper.de", "main": "src/main.js", diff --git a/src/crashreports.js b/src/crashreports.js index 823be54..9e80bf5 100644 --- a/src/crashreports.js +++ b/src/crashreports.js @@ -47,11 +47,18 @@ function neuesterBericht(gameDir, seit) { /* * Zieht die aussagekräftigen Zeilen aus einem Bericht. - * -> { beschreibung, fehler, verdaechtigeMods, verlauf: [] } + * -> { beschreibung, fehler, verdaechtigeMods, diagnose, empfehlung, verlauf: [] } */ function auswerten(text) { const zeilen = String(text || '').split(/\r?\n/); - const befund = { beschreibung: '', fehler: '', verdaechtigeMods: '', verlauf: [] }; + const befund = { + beschreibung: '', + fehler: '', + verdaechtigeMods: '', + diagnose: '', + empfehlung: '', + verlauf: [], + }; for (let i = 0; i < zeilen.length; i++) { const z = zeilen[i]; @@ -86,6 +93,22 @@ function auswerten(text) { const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim())); if (treffer) befund.fehler = treffer.trim(); } + + const volltext = zeilen.join('\n'); + const shutdownWatchdog = /Description:\s*Client shutdown from post-main/i.test(volltext) + && /ClientShutdownWatchdog/i.test(volltext); + const webMapJetty = /journeymap_webmap:\s*JourneyMap WebMap\b/i.test(volltext) + && /JettyServerThreadPool/i.test(volltext) + && /ServerConnector.*\{0\.0\.0\.0:\d+\}/i.test(volltext); + if (shutdownWatchdog && webMapJetty) { + const version = volltext.match(/journeymap_webmap:\s*JourneyMap WebMap\s+([^\s\r\n]+)/i); + const name = `JourneyMap WebMap${version ? ` ${version[1]}` : ''}`; + befund.verdaechtigeMods = name; + befund.diagnose = `${name} hat seinen eingebetteten Jetty-Webserver beim Beenden nicht gestoppt. ` + + 'Dessen nicht beendete Server-Threads haben den Minecraft-Shutdown-Watchdog ausgelöst.'; + befund.empfehlung = 'AeroMC beendet einen nach dem regulären Client-Shutdown verbliebenen Java-Prozess nun ' + + 'nach einer kurzen Schonfrist automatisch. JourneyMap WebMap kann installiert und aktiviert bleiben.'; + } return befund; } diff --git a/src/launcher.js b/src/launcher.js index bb38d8b..6b40ece 100644 --- a/src/launcher.js +++ b/src/launcher.js @@ -21,6 +21,7 @@ const { spawn, spawnSync } = require('child_process'); const services = require('./services'); const loaders = require('./loaders'); const forge = require('./forge'); +const processwatchdog = require('./processwatchdog'); const UA = 'AeroMC-Launcher/0.2'; const RESOURCES = 'https://resources.download.minecraft.net'; @@ -253,20 +254,28 @@ async function prepareAssets(vj, sharedDir, onProgress) { // --------------------------------------------------------------------------- function pickJava(vj, preferredPath) { + const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; + let preferred = null; if (preferredPath && preferredPath.trim()) { const info = services.probeJava(preferredPath); - if (info) return { path: preferredPath, major: info.major, forced: true }; + if (info) { + preferred = { path: preferredPath, major: info.major, forced: true }; + if (info.major === required) return preferred; + } } - const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; const jvms = services.scanJava(); - // exakt passende oder nächsthöhere 64-bit-Version + // Mod-Loader und Mods unterstützen neuere Java-Versionen nicht automatisch. + // Deshalb ausschließlich die von Mojang angegebene Major-Version verwenden. const x64 = jvms.filter((j) => j.arch === 'x64'); const exact = x64.find((j) => j.major === required); if (exact) return { path: exact.path, major: exact.major }; - const higher = x64.filter((j) => j.major >= required).sort((a, b) => a.major - b.major)[0]; - if (higher) return { path: higher.path, major: higher.major }; - const any = jvms[0]; - return any ? { path: any.path, major: any.major, mismatch: required } : null; + const fallback = preferred || x64[0] || jvms[0]; + return fallback ? { + path: fallback.path, + major: fallback.major, + forced: !!fallback.forced, + mismatch: required, + } : null; } // --------------------------------------------------------------------------- @@ -387,6 +396,14 @@ async function prepareAndLaunch(opts, onProgress) { } } if (!java) return { ok: false, message: 'Keine Java-Installation gefunden. Bitte in den Einstellungen Java festlegen.' }; + if (java.mismatch) { + return { + ok: false, + requiredJava: java.mismatch, + message: `Minecraft ${vj.id} benötigt Java ${java.mismatch}, gefunden wurde nur Java ${java.major || 'unbekannt'}. ` + + 'Bitte Auto-Java aktivieren oder eine passende Java-Installation auswählen.', + }; + } // Mod-Loader let effectiveVj = vj; @@ -503,14 +520,33 @@ async function prepareAndLaunch(opts, onProgress) { spawnArgs = [...w.slice(1), java.path, ...args]; } const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false }); - child.stdout.on('data', (d) => emit('log', { line: d.toString() })); - child.stderr.on('data', (d) => emit('log', { line: d.toString() })); + const shutdownWatchdog = opts.wrapperCommand && opts.wrapperCommand.trim() + ? null + : processwatchdog.createProcessWatchdog({ + isRunning: () => child.exitCode === null && child.signalCode === null, + kill: () => child.kill(), + onForce: (graceMs) => emit('log', { + line: `[Launcher] Regulärer Client-Shutdown war nach ${graceMs / 1000} Sekunden noch nicht beendet; ` + + 'verbliebenen Java-Prozess beendet.\n', + }), + onError: () => emit('log', { + line: '[Launcher] Der nach dem regulären Client-Shutdown verbliebene Java-Prozess konnte nicht beendet werden.\n', + }), + }); + child.stdout.on('data', (d) => { + emit('log', { line: d.toString() }); + if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stdout'); + }); + child.stderr.on('data', (d) => { + emit('log', { line: d.toString() }); + if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stderr'); + }); // Ende genau einmal melden – 'exit' und 'error' können beide kommen let beendetGemeldet = false; - const meldeEnde = (code) => { + const meldeEnde = (code, cleanShutdown) => { if (beendetGemeldet) return; beendetGemeldet = true; - emit('exit', { code }); + emit('exit', { code, cleanShutdown: !!cleanShutdown }); // Post-Exit-Befehl if (opts.postExitCommand && opts.postExitCommand.trim()) { emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' }); @@ -521,12 +557,19 @@ async function prepareAndLaunch(opts, onProgress) { } catch { /* egal */ } } }; - child.on('exit', (code) => meldeEnde(code)); + child.on('exit', (code) => { + // vor end() lesen: sawCleanShutdown wird zwar nicht von end()/clear() + // zurückgesetzt, aber so bleibt die Reihenfolge unabhängig von internen Details + const cleanShutdown = !!(shutdownWatchdog && shutdownWatchdog.sawCleanShutdown); + if (shutdownWatchdog) shutdownWatchdog.end(); + meldeEnde(shutdownWatchdog && shutdownWatchdog.forced ? 0 : code, cleanShutdown); + }); // ohne diesen Listener würde ein Fehlstart (z. B. falscher Java-Pfad) als // unbehandeltes 'error'-Ereignis den Hauptprozess beenden child.on('error', (err) => { + if (shutdownWatchdog) shutdownWatchdog.end(); emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' }); - meldeEnde(-1); + meldeEnde(-1, false); }); return { ok: true, pid: child.pid, java }; diff --git a/src/main.js b/src/main.js index 60c5b0c..19da8ac 100644 --- a/src/main.js +++ b/src/main.js @@ -762,7 +762,11 @@ async function importMrpack(buf, zip) { const herkunft = await modpackHerkunft(buf, index); store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) }); - return { ok: true, instance: store.getInstance(created.id), fileCount: eingespielt.dateien.length }; + return { + ok: true, + instance: store.getInstance(created.id), + fileCount: eingespielt.dateien.length, + }; } /* @@ -921,7 +925,9 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => { if (SPIELERDATEN.some((r) => r.test(alt))) continue; const p = path.join(gameDir, String(alt).replace(/\//g, path.sep)); if (!p.startsWith(gameDir + path.sep)) continue; - try { if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); entfernt.push(alt); } } catch { /* egal */ } + try { + if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); entfernt.push(alt); } + } catch { /* egal */ } } // Minecraft-Version und Loader können sich mit der neuen Fassung ändern @@ -1514,7 +1520,10 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => { const done = []; const failed = []; for (const entry of entries || []) { - try { done.push(mmcimport.importOne(store, entry, copyData !== false)); } + try { + const imported = mmcimport.importOne(store, entry, copyData !== false); + 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 }); @@ -1540,7 +1549,10 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => { const done = []; const failed = []; for (const entry of entries || []) { - try { done.push(cfimport.importOne(store, entry, copyData !== false)); } + try { + const imported = cfimport.importOne(store, entry, copyData !== false); + 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 }); @@ -1566,7 +1578,10 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => { const done = []; const failed = []; for (const entry of entries || []) { - try { done.push(mcimport.importOne(store, entry, copyData !== false)); } + try { + const imported = mcimport.importOne(store, entry, copyData !== false); + 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 }); diff --git a/src/processwatchdog.js b/src/processwatchdog.js new file mode 100644 index 0000000..bf65b25 --- /dev/null +++ b/src/processwatchdog.js @@ -0,0 +1,101 @@ +'use strict'; + +const DEFAULT_GRACE_MS = 15000; + +const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/; +const FATAL_OUTPUT = [ + /---- Minecraft Crash Report ----/i, + /\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i, + /\b(?:This crash report has been saved to|Saving crash report to)\b/i, + /\bA fatal error has been detected by the Java Runtime Environment\b/i, + /Exception in thread "(?:Render thread|Client thread|main)"/i, + /\[(?:Render thread|Client thread|main)\/FATAL\]/i, +]; + +function createProcessWatchdog(options) { + const graceMs = options.graceMs || DEFAULT_GRACE_MS; + const schedule = options.setTimer || setTimeout; + const cancel = options.clearTimer || clearTimeout; + const buffers = { stdout: '', stderr: '' }; + let timer = null; + let armed = false; + let crashed = false; + let ended = false; + let forced = false; + // bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen + // wurde – im Gegensatz zu `armed` wird das NICHT durch markCrash() + // zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt + // weiterhin als "war ein regulärer Shutdown" erkennbar bleibt + let sawCleanShutdown = false; + + function clear() { + if (timer !== null) cancel(timer); + timer = null; + armed = false; + } + + function markCrash() { + crashed = true; + clear(); + } + + function scheduleKill() { + if (timer !== null) cancel(timer); + timer = schedule(() => { + timer = null; + if (ended || crashed || !armed || !options.isRunning()) return; + try { + forced = options.kill() === true; + } catch { + forced = false; + } + if (forced && options.onForce) options.onForce(graceMs); + else if (!forced && options.isRunning() && options.onError) options.onError(); + }, graceMs); + if (timer && typeof timer.unref === 'function') timer.unref(); + } + + function inspectLine(line) { + const isCleanShutdown = CLEAN_SHUTDOWN.test(line); + if (isCleanShutdown) sawCleanShutdown = true; + if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) { + markCrash(); + return; + } + if (ended || crashed || armed || !isCleanShutdown) return; + + armed = true; + scheduleKill(); + } + + function feed(chunk, stream) { + if (ended) return; + const key = stream === 'stderr' ? 'stderr' : 'stdout'; + const text = buffers[key] + String(chunk || ''); + const lines = text.split(/\r?\n/); + buffers[key] = lines.pop() || ''; + for (const line of lines) inspectLine(line); + if (armed && !crashed && !ended && lines.length) scheduleKill(); + } + + function end() { + ended = true; + clear(); + } + + return { + feed, + end, + get armed() { return armed; }, + get crashed() { return crashed; }, + get forced() { return forced; }, + get sawCleanShutdown() { return sawCleanShutdown; }, + }; +} + +module.exports = { + DEFAULT_GRACE_MS, + CLEAN_SHUTDOWN, + FATAL_OUTPUT, + createProcessWatchdog, +}; diff --git a/src/renderer.js b/src/renderer.js index d0a43a9..b864f24 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -2227,7 +2227,8 @@ async function zeigeAbsturzbericht(instanzId) { const zeile = (titel, wert) => wert ? `
${titel}
${escapeHtml(wert)}
` : ''; - let html = zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler) + let html = zeile('Diagnose', res.diagnose) + zeile('Empfohlene Lösung', res.empfehlung) + + zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler) + zeile('Verdächtige Mods', res.verdaechtigeMods); if (res.verlauf && res.verlauf.length) { html += `
Aufrufverlauf
` @@ -2246,7 +2247,17 @@ function onLaunchEvt(evt) { status('Minecraft beendet (Code ' + evt.code + ')'); el('status-right').textContent = ''; appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n'); - const crashed = evt.code !== 0 && evt.code !== null; + // War vorher "Stopping!" im Log (regulärer Client-Shutdown), zählt ein + // nachträglicher harter Exit-Code nicht als Absturz während des Spiels – + // z. B. wenn Minecrafts interner Shutdown-Watchdog wegen hängender + // Mod-Threads (z. B. JourneyMap WebMap) die JVM nach Ablauf einer + // Wartezeit zwangsweise beendet. Der Bericht bleibt trotzdem gespeichert. + const crashed = evt.code !== 0 && evt.code !== null && !evt.cleanShutdown; + if (evt.cleanShutdown && evt.code !== 0 && evt.code !== null) { + appendConsole('[Hinweis] Der Client wurde regulär beendet (Stopping!), aber danach vom ' + + '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);