Upload via GUI (19 Dateien)
This commit is contained in:
+455
@@ -0,0 +1,455 @@
|
||||
'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 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;
|
||||
}
|
||||
|
||||
// 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');
|
||||
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' }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java-Auswahl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickJava(vj, preferredPath) {
|
||||
if (preferredPath && preferredPath.trim()) {
|
||||
const info = services.probeJava(preferredPath);
|
||||
if (info) return { path: preferredPath, major: info.major, forced: true };
|
||||
}
|
||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
||||
const jvms = services.scanJava();
|
||||
// exakt passende oder nächsthöhere 64-bit-Version
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 …' });
|
||||
const java = pickJava(vj, opts.javaPath);
|
||||
if (!java) return { ok: false, message: 'Keine Java-Installation gefunden. Bitte in den Einstellungen Java festlegen.' };
|
||||
|
||||
// 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;
|
||||
try { lp = (await loaders.resolveLoaderProfile(opts.loader, vj.id, opts.loaderVersion)).profile; }
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
minMemMb: opts.minMemMb || 2048,
|
||||
maxMemMb: opts.maxMemMb || 4096,
|
||||
extraArgs: opts.extraArgs || '',
|
||||
});
|
||||
|
||||
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 });
|
||||
child.stdout.on('data', (d) => emit('log', { line: d.toString() }));
|
||||
child.stderr.on('data', (d) => emit('log', { line: d.toString() }));
|
||||
child.on('exit', (code) => {
|
||||
emit('exit', { code });
|
||||
// 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 */ }
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, pid: child.pid, java };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets,
|
||||
downloadIfNeeded, extractNatives,
|
||||
};
|
||||
Reference in New Issue
Block a user