Files
AeroMc-Launcher/src/launcher.js
T

613 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/*
* launcher.js Vanilla-Start-Pipeline (Phase 2)
* ----------------------------------------------
* Lädt Version-JSON, Client-JAR, Libraries, Natives und Assets herunter,
* wählt eine passende Java-Version und baut das Start-Kommando.
*
* Der eigentliche Prozess-Start ist an ein Konto gebunden (opts.auth):
* Ohne gültigen Microsoft-Login (Phase 3) wird alles vorbereitet, aber
* NICHT gestartet wir bauen bewusst keinen Offline-/Crack-Bypass.
*
* Gemeinsame Spieldateien (versions/libraries/assets) liegen geteilt unter
* <sharedDir>, damit mehrere Instanzen sie sich teilen (spart Platz).
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
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';
// ---------------------------------------------------------------------------
// kleine Helfer
// ---------------------------------------------------------------------------
function ensureDir(d) { fs.mkdirSync(d, { recursive: true }); }
function sha1File(file) {
try {
const h = crypto.createHash('sha1');
h.update(fs.readFileSync(file));
return h.digest('hex');
} catch { return null; }
}
async function fetchJson(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
return res.json();
}
// lädt eine Datei, überspringt sie bei passender sha1/Existenz
async function downloadIfNeeded(url, dest, sha1) {
if (fs.existsSync(dest)) {
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
}
ensureDir(path.dirname(dest));
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(dest, buf);
return { skipped: false, bytes: buf.length };
}
// begrenzte Parallelität für viele kleine Downloads (Assets)
async function runPool(items, worker, concurrency, onEach) {
let idx = 0, done = 0;
const total = items.length;
async function next() {
while (idx < total) {
const i = idx++;
await worker(items[i]);
done++;
if (onEach) onEach(done, total);
}
}
const runners = [];
for (let c = 0; c < Math.min(concurrency, total); c++) runners.push(next());
await Promise.all(runners);
}
// ---------------------------------------------------------------------------
// Regeln (Libraries & Argumente)
// ---------------------------------------------------------------------------
const OS_NAME = 'windows';
function rulesAllow(rules) {
if (!rules || !rules.length) return true;
let action = 'disallow';
for (const r of rules) {
let matches = true;
if (r.os && r.os.name && r.os.name !== OS_NAME) matches = false;
if (r.features) matches = false; // Demo-/Auflösungs-Features aus
if (matches) action = r.action;
}
return action === 'allow';
}
// ---------------------------------------------------------------------------
// Version-JSON
// ---------------------------------------------------------------------------
async function getVersionJson(versionId, userDataDir, sharedDir) {
const dest = path.join(sharedDir, 'versions', versionId, versionId + '.json');
if (fs.existsSync(dest)) {
try { return JSON.parse(fs.readFileSync(dest, 'utf8')); } catch { /* neu laden */ }
}
// URL aus dem Manifest suchen (bei Bedarf frisch laden, falls url fehlt)
let manifest = await services.getMcVersions(userDataDir);
let entry = (manifest.versions || []).find((v) => v.id === versionId);
if (!entry || !entry.url) {
manifest = await services.getMcVersions(userDataDir, 0); // erzwinge Refresh
entry = (manifest.versions || []).find((v) => v.id === versionId);
}
if (!entry || !entry.url) throw new Error('Version nicht im Mojang-Manifest gefunden: ' + versionId);
const vj = await fetchJson(entry.url);
ensureDir(path.dirname(dest));
fs.writeFileSync(dest, JSON.stringify(vj));
return vj;
}
// ---------------------------------------------------------------------------
// Libraries + Natives -> Classpath
// ---------------------------------------------------------------------------
async function prepareLibraries(vj, sharedDir, nativesDir, onProgress) {
const libDir = path.join(sharedDir, 'libraries');
const classpath = [];
const libs = (vj.libraries || []).filter((l) => rulesAllow(l.rules));
let i = 0;
for (const lib of libs) {
i++;
onProgress && onProgress({ phase: 'libraries', current: i, total: libs.length, detail: lib.name });
const dl = lib.downloads || {};
// normales Artefakt -> Classpath
if (dl.artifact && dl.artifact.path) {
const dest = path.join(libDir, dl.artifact.path.replace(/\//g, path.sep));
await downloadIfNeeded(dl.artifact.url, dest, dl.artifact.sha1);
classpath.push(dest);
}
// klassische Natives (vor 1.19) -> in nativesDir extrahieren
if (lib.natives && lib.natives[OS_NAME] && dl.classifiers) {
const key = lib.natives[OS_NAME].replace('${arch}', '64');
const nat = dl.classifiers[key];
if (nat) {
const dest = path.join(libDir, nat.path.replace(/\//g, path.sep));
await downloadIfNeeded(nat.url, dest, nat.sha1);
await extractNatives(dest, nativesDir, lib.extract);
}
}
}
return classpath;
}
function classpathArtifactKey(filePath, sharedDir) {
const libDir = path.join(sharedDir, 'libraries');
const rel = path.relative(libDir, filePath);
if (rel.startsWith('..') || path.isAbsolute(rel)) return path.normalize(filePath).toLowerCase();
const parts = rel.split(path.sep);
if (parts.length < 4) return rel.replace(/\\/g, '/').toLowerCase();
const fileName = parts[parts.length - 1];
const version = parts[parts.length - 2];
const artifact = parts[parts.length - 3];
const group = parts.slice(0, -3).join('/').toLowerCase();
const base = fileName.replace(/\.[^.]+$/, '');
const prefix = `${artifact}-${version}`;
const classifier = base.startsWith(prefix + '-') ? base.slice(prefix.length + 1).toLowerCase() : '';
return `${group}:${artifact.toLowerCase()}:${classifier}`;
}
function dedupeClasspath(classpath, sharedDir) {
const seen = new Set();
const out = [];
for (const filePath of classpath) {
const key = classpathArtifactKey(filePath, sharedDir);
if (seen.has(key)) continue;
seen.add(key);
out.push(filePath);
}
return out;
}
// entpackt eine Natives-JAR (ZIP) nach nativesDir (ohne externe Abhängigkeit)
async function extractNatives(jarPath, nativesDir, extractRule) {
ensureDir(nativesDir);
const exclude = (extractRule && extractRule.exclude) || ['META-INF/'];
const data = fs.readFileSync(jarPath);
// ZIP End-of-Central-Directory finden
let eocd = -1;
for (let i = data.length - 22; i >= 0 && i > data.length - 22 - 65536; i--) {
if (data.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) return;
const count = data.readUInt16LE(eocd + 10);
let off = data.readUInt32LE(eocd + 16);
const zlib = require('zlib');
for (let n = 0; n < count; n++) {
if (data.readUInt32LE(off) !== 0x02014b50) break;
const method = data.readUInt16LE(off + 10);
const compSize = data.readUInt32LE(off + 20);
const nameLen = data.readUInt16LE(off + 28);
const extraLen = data.readUInt16LE(off + 30);
const commentLen = data.readUInt16LE(off + 32);
const localOff = data.readUInt32LE(off + 42);
const name = data.toString('utf8', off + 46, off + 46 + nameLen);
off += 46 + nameLen + extraLen + commentLen;
if (name.endsWith('/')) continue;
if (exclude.some((ex) => name.startsWith(ex))) continue;
// lokalen Header lesen, um Datenoffset zu bestimmen
const lNameLen = data.readUInt16LE(localOff + 26);
const lExtraLen = data.readUInt16LE(localOff + 28);
const dataStart = localOff + 30 + lNameLen + lExtraLen;
const raw = data.subarray(dataStart, dataStart + compSize);
let out;
try { out = method === 0 ? raw : zlib.inflateRawSync(raw); } catch { continue; }
const target = path.join(nativesDir, path.basename(name));
fs.writeFileSync(target, out);
}
}
// ---------------------------------------------------------------------------
// Assets
// ---------------------------------------------------------------------------
async function prepareAssets(vj, sharedDir, onProgress) {
const ai = vj.assetIndex;
if (!ai) return;
const indexFile = path.join(sharedDir, 'assets', 'indexes', ai.id + '.json');
// Merker: einmal vollständig geprüfte Assets nicht erneut durchgehen (spart ~1,5 s pro Start)
const doneMark = path.join(sharedDir, 'assets', 'indexes', ai.id + '.complete');
if (fs.existsSync(doneMark) && fs.existsSync(indexFile)) return;
await downloadIfNeeded(ai.url, indexFile, ai.sha1);
const index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
const objects = index.objects || {};
const entries = Object.values(objects);
const objDir = path.join(sharedDir, 'assets', 'objects');
await runPool(entries, async (o) => {
const sub = o.hash.slice(0, 2);
const dest = path.join(objDir, sub, o.hash);
if (fs.existsSync(dest)) return;
await downloadIfNeeded(`${RESOURCES}/${sub}/${o.hash}`, dest, o.hash);
}, 16, (done, total) => onProgress && onProgress({ phase: 'assets', current: done, total, detail: 'Assets' }));
try { fs.writeFileSync(doneMark, new Date().toISOString()); } catch { /* egal */ }
}
// ---------------------------------------------------------------------------
// Java-Auswahl
// ---------------------------------------------------------------------------
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) {
preferred = { path: preferredPath, major: info.major, forced: true };
if (info.major === required) return preferred;
}
}
const jvms = services.scanJava();
// 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 fallback = preferred || x64[0] || jvms[0];
return fallback ? {
path: fallback.path,
major: fallback.major,
forced: !!fallback.forced,
mismatch: required,
} : null;
}
// ---------------------------------------------------------------------------
// Argumente
// ---------------------------------------------------------------------------
function substitute(str, map) {
return str.replace(/\$\{([^}]+)\}/g, (m, k) => (k in map ? map[k] : m));
}
function collectArgs(argList, map) {
const out = [];
for (const a of argList || []) {
if (typeof a === 'string') { out.push(substitute(a, map)); continue; }
if (a && a.rules && !rulesAllow(a.rules)) continue;
const val = a && a.value;
if (typeof val === 'string') out.push(substitute(val, map));
else if (Array.isArray(val)) val.forEach((v) => out.push(substitute(v, map)));
}
return out;
}
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
function mergeLoader(vj, lp) {
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
merged.arguments = {
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
};
return merged;
}
function buildArgs(vj, ctx) {
const map = {
auth_player_name: ctx.auth.name,
version_name: vj.id,
library_directory: path.join(ctx.sharedDir, 'libraries'),
classpath_separator: path.delimiter,
game_directory: ctx.gameDir,
assets_root: path.join(ctx.sharedDir, 'assets'),
assets_index_name: vj.assets || (vj.assetIndex && vj.assetIndex.id) || 'legacy',
auth_uuid: ctx.auth.uuid,
auth_access_token: ctx.auth.accessToken,
clientid: ctx.auth.clientId || '',
auth_xuid: ctx.auth.xuid || '',
user_type: ctx.auth.userType || 'msa',
version_type: vj.type || 'release',
natives_directory: ctx.nativesDir,
launcher_name: 'AeroMC',
launcher_version: '0.2',
classpath: ctx.classpath.join(path.delimiter),
game_assets: path.join(ctx.sharedDir, 'assets', 'virtual', 'legacy'),
auth_session: 'token:' + ctx.auth.accessToken,
user_properties: '{}',
};
let jvm, game;
if (vj.arguments) {
jvm = collectArgs(vj.arguments.jvm, map);
game = collectArgs(vj.arguments.game, map);
} else {
// altes Format (< 1.13)
jvm = ['-Djava.library.path=' + ctx.nativesDir, '-cp', ctx.classpath.join(path.delimiter)];
game = (vj.minecraftArguments || '').split(' ').filter(Boolean).map((a) => substitute(a, map));
}
// RAM
jvm = ['-Xms' + ctx.minMemMb + 'M', '-Xmx' + ctx.maxMemMb + 'M', ...jvm];
if (ctx.extraArgs) jvm = [...ctx.extraArgs.split(' ').filter(Boolean), ...jvm];
return [...jvm, vj.mainClass, ...game];
}
// ---------------------------------------------------------------------------
// Hauptfunktion
// ---------------------------------------------------------------------------
/*
* opts: { versionId, gameDir, sharedDir, userDataDir, javaPath, minMemMb, maxMemMb, extraArgs, auth }
* auth: null (kein Login) ODER { name, uuid, accessToken, userType }
* onProgress: (evt) => void
*/
async function prepareAndLaunch(opts, onProgress) {
const emit = (phase, extra) => onProgress && onProgress(Object.assign({ phase }, extra));
emit('version', { detail: 'Version-Daten laden …' });
const vj = await getVersionJson(opts.versionId, opts.userDataDir, opts.sharedDir);
const nativesDir = path.join(opts.sharedDir, 'versions', vj.id, 'natives');
ensureDir(nativesDir);
// Client-JAR
emit('client', { detail: 'Client herunterladen …' });
const clientJar = path.join(opts.sharedDir, 'versions', vj.id, vj.id + '.jar');
if (vj.downloads && vj.downloads.client) {
await downloadIfNeeded(vj.downloads.client.url, clientJar, vj.downloads.client.sha1);
}
// Libraries + Natives
const classpath = await prepareLibraries(vj, opts.sharedDir, nativesDir, onProgress);
classpath.push(clientJar);
// Assets
await prepareAssets(vj, opts.sharedDir, onProgress);
// Java (VOR dem Loader Forge-Prozessoren brauchen Java)
emit('java', { detail: 'Java wählen …' });
let java = pickJava(vj, opts.javaPath);
// Nichts Passendes da? Dann darf der Launcher die richtige Fassung selbst
// holen opts.ensureJava wird vom Hauptprozess gestellt.
if ((!java || java.mismatch) && opts.ensureJava) {
const benoetigt = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
emit('java', { detail: `Java ${benoetigt} wird geladen …` });
try {
const geholt = await opts.ensureJava(benoetigt, (p) => emit('java', p));
if (geholt && geholt.path) java = { path: geholt.path, major: geholt.major };
} catch (err) {
emit('log', { line: '[Java] Automatischer Download fehlgeschlagen: ' + err.message + '\n' });
}
}
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;
const libDir = path.join(opts.sharedDir, 'libraries');
if (opts.loader === 'fabric' || opts.loader === 'quilt') {
emit('loader', { detail: opts.loader + ' vorbereiten …' });
let lp;
// Profil zwischenspeichern: spart bei festgelegter Loader-Version die Netzabfrage
const cacheFile = opts.loaderVersion
? path.join(opts.sharedDir, 'loaders', `${opts.loader}-${vj.id}-${opts.loaderVersion}.json`)
: null;
if (cacheFile && fs.existsSync(cacheFile)) {
try { lp = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch { lp = null; }
}
if (!lp) {
try {
const r = await loaders.resolveLoaderProfile(opts.loader, vj.id, opts.loaderVersion);
lp = r.profile;
const target = cacheFile
|| path.join(opts.sharedDir, 'loaders', `${opts.loader}-${vj.id}-${r.loaderVersion}.json`);
try { ensureDir(path.dirname(target)); fs.writeFileSync(target, JSON.stringify(lp)); } catch { /* egal */ }
} catch (err) { return { ok: false, message: err.message }; }
}
const loaderLibs = lp.libraries || [];
let li = 0;
for (const lib of loaderLibs) {
li++;
onProgress && onProgress({ phase: 'loader', current: li, total: loaderLibs.length, detail: lib.name });
const { relPath, url } = loaders.mavenToUrl(lib.url, lib.name);
const dest = path.join(libDir, relPath.replace(/\//g, path.sep));
await downloadIfNeeded(url, dest, lib.sha1 || null);
classpath.unshift(dest);
}
effectiveVj = mergeLoader(vj, lp);
} else if (opts.loader === 'forge' || opts.loader === 'neoforge') {
let lv = opts.loaderVersion;
if (!lv) {
const list = await loaders.listLoaderVersions(opts.loader, vj.id);
if (!list.length) return { ok: false, message: `Keine ${opts.loader}-Version für ${vj.id} gefunden.` };
lv = list[0].version;
}
let fvj;
try {
fvj = await forge.installForge({
loader: opts.loader, mcVersion: vj.id, loaderVersion: lv,
sharedDir: opts.sharedDir, javaPath: java.path, clientJar,
clientMappingsUrl: vj.downloads && vj.downloads.client_mappings && vj.downloads.client_mappings.url,
onProgress,
});
} catch (err) {
return { ok: false, message: (opts.loader === 'forge' ? 'Forge' : 'NeoForge') + '-Installation fehlgeschlagen: ' + err.message };
}
for (const lib of fvj.libraries || []) {
const art = lib.downloads && lib.downloads.artifact;
const rel = (art && art.path) || forge.mavenPath(lib.name);
classpath.unshift(path.join(libDir, rel.replace(/\//g, path.sep)));
}
effectiveVj = mergeLoader(vj, fvj);
} else if (opts.loader === 'optifine') {
/*
* OptiFine hat keine öffentliche Installations-API (Downloads laufen bewusst
* über eine Werbeseite). AeroMC lädt hier nichts selbst herunter der Nutzer
* hat den offiziellen Installer bereits einmal sichtbar laufen lassen (siehe
* optifine:setup/optifine:confirm in main.js), der dabei ein normales
* Versions-Profil unter <sharedDir>/versions/<loaderVersion>/ abgelegt hat,
* genau wie Fabric/Forge es tun. Wir lesen es nur noch ein und mergen es.
*/
const ofId = opts.loaderVersion;
if (!ofId) {
return { ok: false, message: 'Für diese Instanz ist keine OptiFine-Version hinterlegt. Bitte im Bearbeiten-Dialog einrichten.' };
}
const ofJsonPath = path.join(opts.sharedDir, 'versions', ofId, ofId + '.json');
if (!fs.existsSync(ofJsonPath)) {
return { ok: false, message: `OptiFine-Profil "${ofId}" wurde nicht gefunden. Bitte die Einrichtung im Bearbeiten-Dialog wiederholen.` };
}
let ofvj;
try { ofvj = JSON.parse(fs.readFileSync(ofJsonPath, 'utf8')); }
catch (err) { return { ok: false, message: 'OptiFine-Profil ist beschädigt: ' + err.message }; }
for (const lib of ofvj.libraries || []) {
const art = lib.downloads && lib.downloads.artifact;
const rel = (art && art.path) || forge.mavenPath(lib.name);
const dest = path.join(libDir, rel.replace(/\//g, path.sep));
if (!fs.existsSync(dest) && art && art.url) {
await downloadIfNeeded(art.url, dest, art.sha1 || null);
}
classpath.unshift(dest);
}
effectiveVj = mergeLoader(vj, ofvj);
}
const finalClasspath = dedupeClasspath(classpath, opts.sharedDir);
// Ohne Login: alles vorbereitet, aber kein Start (kein Crack-Bypass)
if (!opts.auth) {
return {
ok: false,
needAuth: true,
prepared: true,
java,
requiredJava: (vj.javaVersion && vj.javaVersion.majorVersion) || 8,
message: 'Alles heruntergeladen und startklar ✔ bitte oben rechts über „👤 Anmelden" mit deinem Microsoft-Konto einloggen, dann startet Minecraft.',
};
}
// Start
const args = buildArgs(effectiveVj, {
auth: opts.auth,
gameDir: opts.gameDir,
sharedDir: opts.sharedDir,
nativesDir,
classpath: finalClasspath,
minMemMb: opts.minMemMb || 2048,
maxMemMb: opts.maxMemMb || 4096,
extraArgs: opts.extraArgs || '',
});
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
if (opts.mcMaximized) args.push('--fullscreen');
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
// Ab 1.20: direkt einem Server beitreten (Quick Play)
if (opts.quickPlayServer) {
args.push('--quickPlayMultiplayer', String(opts.quickPlayServer));
}
ensureDir(opts.gameDir);
// Pre-Launch-Befehl (bricht bei Fehler ab)
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
const pr = spawnSync(opts.preLaunchCommand, { cwd: opts.gameDir, shell: true, encoding: 'utf8' });
if (pr.stdout) emit('log', { line: pr.stdout });
if (pr.stderr) emit('log', { line: pr.stderr });
if (pr.status !== 0) return { ok: false, message: 'Pre-Launch-Befehl fehlgeschlagen (Exit ' + pr.status + ').' };
}
emit('launch', { detail: 'Starte Minecraft …' });
let cmd = java.path;
let spawnArgs = args;
if (opts.wrapperCommand && opts.wrapperCommand.trim()) {
const w = opts.wrapperCommand.trim().split(/\s+/);
cmd = w[0];
spawnArgs = [...w.slice(1), java.path, ...args];
}
const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false });
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, cleanShutdown) => {
if (beendetGemeldet) return;
beendetGemeldet = true;
emit('exit', { code, cleanShutdown: !!cleanShutdown });
// Post-Exit-Befehl
if (opts.postExitCommand && opts.postExitCommand.trim()) {
emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' });
try {
const po = spawnSync(opts.postExitCommand, { cwd: opts.gameDir, shell: true, encoding: 'utf8' });
if (po.stdout) emit('log', { line: po.stdout });
if (po.stderr) emit('log', { line: po.stderr });
} catch { /* egal */ }
}
};
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, false);
});
return { ok: true, pid: child.pid, java };
}
module.exports = {
prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets,
downloadIfNeeded, extractNatives, mergeLoader, dedupeClasspath, classpathArtifactKey,
};