613 lines
25 KiB
JavaScript
613 lines
25 KiB
JavaScript
'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,
|
||
};
|