From d3838f9d300c3171cc43e36addff390e682612a0 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Mon, 2 Nov 2026 21:48:49 +0000 Subject: [PATCH] Upload via GUI (324 Dateien) --- src/main.js | 29 +- src/renderer.js | 5 +- src/services.js | 1803 ++++++++++++++++++++++++----------------------- 3 files changed, 936 insertions(+), 901 deletions(-) diff --git a/src/main.js b/src/main.js index 7ce0f06..c5a6b65 100644 --- a/src/main.js +++ b/src/main.js @@ -684,7 +684,8 @@ ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => { ipcMain.handle('recording:open', (_e, instanceName, dateiname) => { if (!istDateiname(dateiname)) return false; const ziel = path.join(aufzeichnungsOrdner(instanceName), dateiname); - if (fs.existsSync(ziel)) shell.openPath(ziel); + // nur Videos öffnen, keine beliebigen Dateien aus dem Aufnahmen-Ordner + if (/\.(mp4|webm|mkv)$/i.test(ziel) && fs.existsSync(ziel)) shell.openPath(ziel); return true; }); @@ -3672,7 +3673,8 @@ ipcMain.handle('instance:screenshotData', (_e, id, name) => { ipcMain.handle('instance:openScreenshot', (_e, id, name) => { const target = safeSub(id, 'screenshots', name); - if (target && fs.existsSync(target)) shell.openPath(target); + // nur Bilder öffnen – im Ordner abgelegte Programme (.exe, .bat …) würden sonst ausgeführt + if (target && /\.(png|jpe?g|gif|webp)$/i.test(target) && fs.existsSync(target)) shell.openPath(target); return true; }); @@ -4086,11 +4088,13 @@ async function importCurseForge(buf, zip, zugang) { const modsDir = path.join(gameDir, 'mods'); let geladen = 0; const fehlend = []; + // Dateien, die der Autor nicht über Launcher freigibt: nicht laden, nur melden + const gesperrtIds = new Set((aufgeloest.gesperrt || []).map((g) => g.fileID)); let i = 0; for (const f of dateien) { i++; const info = aufgeloest.get(Number(f.fileID)); - if (!info) { fehlend.push(f.fileID); continue; } + if (!info) { if (!gesperrtIds.has(Number(f.fileID))) fehlend.push(f.fileID); continue; } const dest = path.join(modsDir, path.basename(info.fileName)); if (!inside(dest)) continue; melde('Mods laden', i, dateien.length); @@ -4114,12 +4118,28 @@ async function importCurseForge(buf, zip, zugang) { fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name])); } + // Liste mit Links zum Selbstladen in den Spielordner legen + const gesperrt = aufgeloest.gesperrt || []; + if (gesperrt.length) { + try { + const zeilen = [ + 'Diese Mods dürfen laut ihren Autoren nicht über Launcher wie AeroMC geladen werden.', + 'Bitte von der jeweiligen CurseForge-Seite laden und in den Ordner "mods" legen:', + '', + ...gesperrt.map((g) => `${g.fileName}\n ${g.seite}`), + '', + ]; + fs.writeFileSync(path.join(gameDir, 'GESPERRTE-MODS.txt'), zeilen.join('\r\n'), 'utf8'); + } catch { /* Hinweisdatei ist nur eine Hilfe */ } + } + return { ok: true, instance: store.getInstance(created.id), fileCount: dateien.length, geladen, fehlend: fehlend.length, + gesperrt: gesperrt.length, }; } @@ -4385,6 +4405,9 @@ async function importInstanceZipFromPath(filePath) { if (!fs.existsSync(path.join(base, 'instance.json'))) { const mmc = mmcimport.scanSingle(tmp); if (mmc.ok) { + // Wie beim AeroMC-Export oben: kein Java-Pfad aus einem fremden Archiv (instance.cfg + // könnte auf ein beliebiges Programm oder einen Netzwerkpfad zeigen, das beim Start läuft) + mmc.instance.javaPath = ''; const done = mmcimport.importOne(store, mmc.instance, false); const quellen = mmcimport.kopierQuellen(mmc.instance); if (quellen.length) await verschiebeInhalt(mmc.instance.gameDir, store.gameDir(done.id), quellen.map((q) => q.name)); diff --git a/src/renderer.js b/src/renderer.js index 9c8e57c..0dcf373 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -508,13 +508,13 @@ const el = (id) => document.getElementById(id); function show(id) { el(id).classList.remove('hidden'); } function hide(id) { el(id).classList.add('hidden'); } -function toast(msg, isError = false) { +function toast(msg, isError = false, dauerMs = 2800) { const t = el('toast'); t.textContent = msg; t.classList.toggle('err', isError); t.classList.remove('hidden'); clearTimeout(toast._t); - toast._t = setTimeout(() => t.classList.add('hidden'), 2800); + toast._t = setTimeout(() => t.classList.add('hidden'), dauerMs); } function fmtDate(iso) { @@ -778,6 +778,7 @@ async function runModpackImport() { toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`); // CurseForge gibt nicht jede Datei heraus – ehrlich benennen statt verschweigen if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true); + if (res.gesperrt) toast(`${res.gesperrt} Mod(s) dürfen laut Autor nicht über Launcher geladen werden. Liste mit Links: GESPERRTE-MODS.txt im Spielordner der Instanz.`, true, 10000); return; } toast(res.message || 'Modpack-Import fehlgeschlagen.', true); diff --git a/src/services.js b/src/services.js index a1d94fa..0b9f854 100644 --- a/src/services.js +++ b/src/services.js @@ -1,896 +1,907 @@ -'use strict'; - -/* - * services.js – externe Dienste & Systemabfragen für AeroMC - * ---------------------------------------------------------------- - * Bewusst frei von Electron-Abhängigkeiten, damit headless (mit `node`) - * testbar. Nutzt globales fetch (Node 18+ / Electron 28). - * - * - scanJava() : findet installierte Java-Runtimes - * - getMcVersions() : Mojang-Versionsliste (mit Datei-Cache) - * - modrinthSearch() : Mod-Suche - * - modrinthInstall() : passende Mod-Version in einen Ordner laden - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execFileSync, spawnSync } = require('child_process'); - -const UA = 'AeroMC-Launcher/0.2 (Minecraft-Launcher; contact: viper)'; - -// --------------------------------------------------------------------------- -// Arbeitsspeicher -// --------------------------------------------------------------------------- - -/* - * Prüft die Speicherangaben einer Instanz gegen den vorhandenen Arbeitsspeicher. - * Ohne diese Prüfung startet Java mit einem kryptischen Fehler, den niemand - * mit "zu viel RAM eingetragen" in Verbindung bringt. - * - * -> { ok, message } bei Fehlern, { ok: true, warnung } bei knappen Angaben - */ -function pruefeSpeicher(minMb, maxMb, gesamtMb) { - const min = Number(minMb) || 0; - const max = Number(maxMb) || 0; - const gesamt = Number(gesamtMb) || 0; - - if (min && max && min > max) { - return { ok: false, message: `Der Mindestspeicher (${min} MB) ist größer als das Maximum (${max} MB).` }; - } - if (max && max < 512) { - return { ok: false, message: `${max} MB sind zu wenig – Minecraft braucht mindestens 512 MB.` }; - } - if (gesamt && max && max > gesamt) { - return { ok: false, message: `${max} MB übersteigen den vorhandenen Arbeitsspeicher (${gesamt} MB). Minecraft würde nicht starten.` }; - } - // Dem System muss etwas bleiben: Windows und der Launcher selbst brauchen auch Platz - if (gesamt && max && max > gesamt * 0.8) { - return { - ok: true, - warnung: `${max} MB sind mehr als 80 % des Arbeitsspeichers (${gesamt} MB). ` - + `Für Windows und den Launcher sollte etwas übrig bleiben – üblich sind höchstens ${Math.floor(gesamt * 0.7 / 256) * 256} MB.`, - }; - } - return { ok: true }; -} - -// --------------------------------------------------------------------------- -// Java-Erkennung -// --------------------------------------------------------------------------- - -// vom Launcher selbst heruntergeladene Laufzeiten (wird beim Start gesetzt) -let managedJavaDir = null; -function setManagedJavaDir(dir) { managedJavaDir = dir; } - -function javaCandidateDirs() { - const pf = process.env['ProgramFiles'] || 'C:\\Program Files'; - const pfx = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; - const appdata = process.env['APPDATA'] || ''; - const localappdata = process.env['LOCALAPPDATA'] || ''; - const vendors = ['Java', 'Eclipse Adoptium', 'Eclipse Foundation', 'Zulu', 'Amazon Corretto', - 'BellSoft', 'Microsoft', 'AdoptOpenJDK', 'Semeru', 'JetBrains']; - const roots = []; - for (const base of [pf, pfx]) { - for (const v of vendors) roots.push(path.join(base, v)); - } - // Runtimes der offiziellen Minecraft-Launcher - if (appdata) roots.push(path.join(appdata, '.minecraft', 'runtime')); - if (localappdata) roots.push(path.join(localappdata, 'Packages')); - // eigene, vom Launcher geholte Laufzeiten - if (managedJavaDir) roots.push(managedJavaDir); - return roots; -} - -// findet rekursiv (begrenzte Tiefe) alle java.exe unter einem Startordner -function findJavaExes(root, depth, acc) { - if (depth < 0 || !fs.existsSync(root)) return; - let entries; - try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; } - for (const e of entries) { - const full = path.join(root, e.name); - if (e.isFile() && /^javaw?\.exe$/i.test(e.name)) { - // bevorzugt java.exe (Konsole) statt javaw.exe - if (/^java\.exe$/i.test(e.name)) acc.add(full); - } else if (e.isDirectory()) { - // Tiefe begrenzen; typische Struktur ...//bin/java.exe - findJavaExes(full, depth - 1, acc); - } - } -} - -function probeJava(javaPath) { - // `java -version` schreibt nach stderr; spawnSync liefert stdout UND stderr. - try { - const r = spawnSync(javaPath, ['-version'], { encoding: 'utf8', timeout: 5000 }); - const out = (r.stderr || '') + (r.stdout || ''); - if (!out.trim()) return null; - return parseJavaVersion(javaPath, out); - } catch { - return null; - } -} - -function parseJavaVersion(javaPath, out) { - const m = out.match(/version "([^"]+)"/i); - const raw = m ? m[1] : ''; - let major = 0; - if (raw) { - const parts = raw.split('.'); - major = raw.startsWith('1.') ? parseInt(parts[1], 10) : parseInt(parts[0], 10); - } - const arch = /64-bit/i.test(out) ? 'x64' : (/32-bit|Client VM/i.test(out) ? 'x86' : '?'); - const vendorLine = out.split('\n').map((l) => l.trim()).find((l) => /runtime|openjdk|hotspot|jre|jdk/i.test(l)) || ''; - return { path: javaPath, version: raw || 'unbekannt', major: major || 0, arch, info: vendorLine }; -} - -// Ergebnis des Suchlaufs zwischenspeichern – die Suche kostet ~1 s -let javaCache = null; -let javaCacheFile = null; -function setCacheDir(dir) { javaCacheFile = path.join(dir, 'java-cache.json'); } - -function scanJava(useCache = true) { - const MAX_AGE = 24 * 3600 * 1000; - if (useCache) { - if (javaCache && Date.now() - javaCache.at < MAX_AGE) return javaCache.list; - // Cache von der Platte (gilt auch direkt nach dem Start) - if (javaCacheFile) { - try { - const c = JSON.parse(fs.readFileSync(javaCacheFile, 'utf8')); - if (c && Date.now() - c.at < MAX_AGE && Array.isArray(c.list) - && c.list.every((j) => fs.existsSync(j.path))) { - javaCache = c; - return c.list; - } - } catch { /* kein oder unbrauchbarer Cache */ } - } - } - const list = scanJavaUncached(); - javaCache = { at: Date.now(), list }; - if (javaCacheFile) { try { fs.writeFileSync(javaCacheFile, JSON.stringify(javaCache)); } catch { /* egal */ } } - return list; -} - -function scanJavaUncached() { - const found = new Set(); - - // 1) JAVA_HOME - if (process.env.JAVA_HOME) { - const p = path.join(process.env.JAVA_HOME, 'bin', 'java.exe'); - if (fs.existsSync(p)) found.add(p); - } - // 2) java aus PATH - try { - const where = execFileSync('where', ['java'], { encoding: 'utf8', timeout: 4000 }); - where.split(/\r?\n/).forEach((l) => { l = l.trim(); if (l && /java\.exe$/i.test(l) && fs.existsSync(l)) found.add(l); }); - } catch { /* nix */ } - // 3) bekannte Vendor-Ordner - for (const root of javaCandidateDirs()) findJavaExes(root, 4, found); - - // prüfen & dedupen nach Version+Pfad - const results = []; - for (const p of found) { - const info = probeJava(p); - if (info) results.push(info); - } - // sortieren: 64-bit vor 32-bit, höhere Major-Version zuerst - results.sort((a, b) => { - if ((b.arch === 'x64') - (a.arch === 'x64') !== 0) return (b.arch === 'x64') - (a.arch === 'x64'); - return b.major - a.major; - }); - return results; -} - -// --------------------------------------------------------------------------- -// Mojang-Versionsliste (mit Cache) -// --------------------------------------------------------------------------- - -const MC_MANIFEST_URL = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'; - -async function getMcVersions(cacheDir, maxAgeMs = 6 * 3600 * 1000) { - const cacheFile = path.join(cacheDir, 'mc-versions.json'); - // Cache frisch genug? - try { - const st = fs.statSync(cacheFile); - if (Date.now() - st.mtimeMs < maxAgeMs) { - return Object.assign({ cached: true }, JSON.parse(fs.readFileSync(cacheFile, 'utf8'))); - } - } catch { /* kein Cache */ } - - try { - const res = await fetch(MC_MANIFEST_URL, { headers: { 'User-Agent': UA } }); - if (!res.ok) throw new Error('HTTP ' + res.status); - const data = await res.json(); - const slim = { - latest: data.latest, - versions: data.versions.map((v) => ({ id: v.id, type: v.type, releaseTime: v.releaseTime, url: v.url, sha1: v.sha1 })), - fetchedAt: new Date().toISOString(), - }; - try { fs.mkdirSync(cacheDir, { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify(slim)); } catch { /* egal */ } - return Object.assign({ cached: false }, slim); - } catch (err) { - // offline: alten Cache nehmen, falls vorhanden - try { - const old = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); - return Object.assign({ cached: true, offline: true }, old); - } catch { - return { offline: true, error: err.message, latest: {}, versions: [] }; - } - } -} - -// --------------------------------------------------------------------------- -// Modrinth (Mod-Suche & Installation) -// --------------------------------------------------------------------------- - -const MODRINTH = 'https://api.modrinth.com/v2'; - -const MODRINTH_SORTS = ['relevance', 'downloads', 'follows', 'newest', 'updated']; - -/* - * Inhaltsarten, die der Launcher aus dem Modrinth-Katalog holen kann. - * ordner = Zielverzeichnis im Spielordner, loaderNoetig = braucht einen - * Mod-Loader (Ressourcenpakete laufen auch in Vanilla). - */ -const INHALTSARTEN = { - mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true }, - resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false }, - shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false }, - datapack: { projectType: 'datapack', ordner: 'datapacks', label: 'Datenpakete', loaderNoetig: false }, - modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false }, -}; - -function inhaltsArt(art) { - return INHALTSARTEN[art] || INHALTSARTEN.mod; -} - -async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0, art = 'mod' } = {}) { - const typ = inhaltsArt(art); - const facets = [['project_type:' + typ.projectType]]; - if (gameVersion) facets.push(['versions:' + gameVersion]); - // Der Loader-Filter gilt nur für Mods. Ressourcenpakete und Shader werden - // bei Modrinth nicht nach Mod-Loader eingeordnet – der Filter würde die - // Trefferliste leeren. - if (typ.loaderNoetig && loader && loader !== 'vanilla') facets.push(['categories:' + loader]); - if (category) facets.push(['categories:' + category]); - const url = new URL(MODRINTH + '/search'); - url.searchParams.set('query', query); - url.searchParams.set('limit', String(limit)); - url.searchParams.set('offset', String(offset)); - url.searchParams.set('index', MODRINTH_SORTS.includes(sort) ? sort : 'relevance'); - url.searchParams.set('facets', JSON.stringify(facets)); - - const res = await fetch(url, { headers: { 'User-Agent': UA } }); - if (!res.ok) throw new Error('Modrinth-Suche fehlgeschlagen: HTTP ' + res.status); - const data = await res.json(); - return { - total: data.total_hits, - hits: (data.hits || []).map((h) => ({ - projectId: h.project_id, - slug: h.slug, - title: h.title, - description: h.description, - author: h.author, - downloads: h.downloads, - follows: h.follows || 0, - iconUrl: h.icon_url || '', - categories: h.categories || [], - // bei Modpacks hilfreich: welche Spielversionen/Loader das Projekt angibt - versions: h.versions || [], - displayCategories: h.display_categories || h.categories || [], - })), - }; -} - -// findet die passende Datei-Version für Spielversion + Loader -async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod', versionId = '' }) { - const typ = inhaltsArt(art); - // bestimmte Version gewünscht (z. B. geteilte Instanz) – sonst neueste passende - if (versionId) { - try { - const res = await fetch(MODRINTH + '/version/' + encodeURIComponent(versionId), { headers: { 'User-Agent': UA } }); - if (res.ok) { - const v = await res.json(); - const passt = !gameVersion || (v.game_versions || []).includes(gameVersion); - const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0]; - if (passt && file && v.project_id === projectId) { - return { - versionId: v.id, - versionNumber: v.version_number, - filename: file.filename, - url: file.url, - hashes: mrHashes(file), - sha1: file.hashes && file.hashes.sha1, - size: file.size, - dependencies: v.dependencies || [], - }; - } - } - } catch { /* dann eben die neueste passende */ } - } - const url = new URL(MODRINTH + '/project/' + projectId + '/version'); - if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion])); - // Nur Mods werden nach Loader eingeordnet; bei Ressourcenpaketen und - // Shadern würde der Filter alle Dateien ausschließen. - if (typ.loaderNoetig && loader && loader !== 'vanilla') url.searchParams.set('loaders', JSON.stringify([loader])); - const res = await fetch(url, { headers: { 'User-Agent': UA } }); - if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status); - const versions = await res.json(); - if (!versions.length) return null; - const v = versions[0]; // neueste passende - const file = v.files.find((f) => f.primary) || v.files[0]; - if (!file) return null; - return { - versionId: v.id, - versionNumber: v.version_number, - filename: file.filename, - url: file.url, - hashes: mrHashes(file), - sha1: file.hashes && file.hashes.sha1, - size: file.size, - dependencies: v.dependencies || [], - }; -} - -/* - * Neueste passende Modpack-Version von Modrinth. - * gameVersion optional – ohne Filter kommt die neueste Version. - */ -async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) { - const url = new URL(MODRINTH + '/project/' + projectId + '/version'); - if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion])); - const res = await fetch(url, { headers: { 'User-Agent': UA } }); - if (!res.ok) throw new Error('Modpack-Versionen fehlgeschlagen: HTTP ' + res.status); - const versions = await res.json(); - if (!Array.isArray(versions) || !versions.length) return null; - const v = versions[0]; - const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0]; - if (!file || !file.url) return null; - return { - versionId: v.id, - versionNumber: v.version_number, - name: v.name || v.version_number, - filename: file.filename, - url: file.url, - hashes: mrHashes(file), - size: file.size || 0, - gameVersions: v.game_versions || [], - loaders: v.loaders || [], - }; -} - -/* - * Prüfsumme eines Downloads kontrollieren. erwartet: { sha512?, sha1? } – die - * Werte stammen aus der Modrinth- bzw. CurseForge-API. Ohne Angabe wird nichts - * geprüft (z. B. Dateien, zu denen die Quelle keine Prüfsumme nennt). - */ -function pruefeDownload(buf, erwartet, name) { - if (!erwartet) return; - for (const algo of ['sha512', 'sha256', 'sha1']) { - const soll = erwartet[algo] && String(erwartet[algo]).toLowerCase(); - if (!soll) continue; - const ist = require('crypto').createHash(algo).update(buf).digest('hex'); - if (ist !== soll) { - throw new Error(`Prüfsumme von ${name || 'Download'} stimmt nicht – Datei verworfen (beschädigt oder manipuliert).`); - } - return; - } -} - -// CurseForge liefert Prüfsummen als Liste: algo 1 = SHA-1, 2 = MD5 -function cfHashes(datei) { - const sha1 = ((datei && datei.hashes) || []).find((h) => h.algo === 1 && h.value); - return sha1 ? { sha1: sha1.value } : null; -} - -// Modrinth: { sha512, sha1 } -function mrHashes(datei) { - const h = (datei && datei.hashes) || {}; - return h.sha512 || h.sha1 ? { sha512: h.sha512, sha1: h.sha1 } : null; -} - -async function downloadBuffer(url, extraHeaders, erwartet) { - const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); - if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); - const buf = Buffer.from(await res.arrayBuffer()); - pruefeDownload(buf, erwartet, decodeURIComponent(String(url).split('/').pop() || '')); - return buf; -} - -// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt -// erwartet: Prüfsummen (siehe pruefeDownload) – geschrieben wird nur, was passt -async function downloadFile(url, destPath, extraHeaders, erwartet) { - const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); - if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); - const buf = Buffer.from(await res.arrayBuffer()); - pruefeDownload(buf, erwartet, path.basename(destPath)); - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - fs.writeFileSync(destPath, buf); - return buf.length; -} - -// lädt eine Mod in ; gibt Metadaten für die Instanz zurück -async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir, art = 'mod', versionId = '' }) { - const typ = inhaltsArt(art); - const file = await modrinthResolveFile({ projectId, gameVersion, loader, art, versionId }); - if (!file) { - return { ok: false, reason: 'no-match', - message: typ.loaderNoetig - ? `Keine passende Version für ${gameVersion} / ${loader} gefunden.` - : `Keine passende Version für ${gameVersion} gefunden.` }; - } - // Dateiname aus der Antwort der Quelle: nur der Name, nie ein Pfad - const dateiname = path.basename(String(file.filename || '')); - if (!dateiname || dateiname === '.' || dateiname === '..') return { ok: false, reason: 'bad-name', message: 'Ungültiger Dateiname in der Antwort der Quelle.' }; - const dest = path.join(modsDir, dateiname); - const bytes = await downloadFile(file.url, dest, null, file.hashes); - return { - ok: true, - dependencies: file.dependencies || [], - entry: { - projectId, - title: title || projectId, - filename: dateiname, - versionId: file.versionId, - versionNumber: file.versionNumber, - gameVersion, - loader, - installedAt: new Date().toISOString(), - bytes, - }, - }; -} - -/* - * Prüft die installierten Mods auf neuere Dateien. - * Von Hand abgelegte .jar-Dateien (ohne projectId) bleiben unangetastet – - * für die weiß niemand, woher sie stammen. - * -> [{ projectId, title, alt: {...}, neu: {...} }] - */ -async function modrinthFindUpdates(mods, gameVersion, loader, onProgress, art = 'mod') { - const kandidaten = (mods || []).filter((m) => m && m.projectId); - const treffer = []; - let geprueft = 0; - - // in kleinen Gruppen abfragen: schneller als nacheinander, ohne die API zu fluten - const GRUPPE = 4; - for (let i = 0; i < kandidaten.length; i += GRUPPE) { - await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => { - let file = null; - try { - file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader, art }); - } catch { /* Netzfehler -> diesen Eintrag überspringen */ } - geprueft++; - if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId }); - if (!file || !file.versionId) return; - // schon aktuell? - if (m.versionId && file.versionId === m.versionId) return; - if (file.filename === m.filename) return; - treffer.push({ - projectId: m.projectId, - title: m.title || m.projectId, - alt: { filename: m.filename, versionNumber: m.versionNumber || '?' }, - neu: { filename: file.filename, versionNumber: file.versionNumber, versionId: file.versionId, size: file.size }, - }); - })); - } - treffer.sort((a, b) => String(a.title).localeCompare(String(b.title))); - return { geprueft: kandidaten.length, uebersprungen: (mods || []).length - kandidaten.length, treffer }; -} - -// --------------------------------------------------------------------------- -// CurseForge (Modpack-Dateien auflösen) -// --------------------------------------------------------------------------- - -const CURSEFORGE = 'https://api.curseforge.com/v1'; - -/* - * Vorgaben, die beim Bauen gesetzt wurden (scripts/write-build-config.js). - * Fehlt die Datei – etwa beim Start aus dem Quelltext –, gilt: keine Vorgabe. - */ -let BUILD_VORGABEN = {}; -try { BUILD_VORGABEN = require('./build-config.json'); } catch { /* nicht erzeugt */ } - -/* - * Adresse der eigenen CurseForge-Weiterleitung. Die darf fest hier stehen – - * sie ist kein Geheimnis, der API-Schlüssel liegt auf dem Server. - * Über AEROMC_CF_PROXY_URL beim Bauen überschreibbar. - */ -const STANDARD_WEITERLEITUNG = String(BUILD_VORGABEN.curseforgeProxyUrl || 'https://curse.viper.ipv64.net'); - -// Das Token dagegen kommt ausschließlich aus dem Build (AEROMC_CF_PROXY_TOKEN) -// und steht bewusst nicht im Quelltext. -const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || ''); - -/* - * Entscheidet, wie CurseForge angesprochen wird. - * Eigener Schlüssel hat Vorrang: wer einen hat, geht direkt und belastet - * kein fremdes Kontingent. - * -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null - */ -/* - * CurseForge kennt zwei Schlüsselarten: - * - Klassisch (console.curseforge.com): Header x-api-key, oft $2a$10$… - * - Studios / Profil-PAT: Header "Authorization: Key cfc_pat_…" - */ -function cfAuthHeaders(apiKey) { - let key = String(apiKey || '').trim(); - if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { - key = key.slice(1, -1).trim(); - } - key = key.replace(/[\r\n\t]/g, '').trim(); - // Komplette Kopfzeile eingefügt? - const m = key.match(/^authorization\s*:\s*key\s+(.+)$/i); - if (m) key = m[1].trim(); - if (key.toLowerCase().startsWith('key ')) key = key.slice(4).trim(); - - if (!key) return null; - - // Neues Studios-Format - if (/^cfc_pat_/i.test(key) || /^cf[a-z]_/i.test(key)) { - return { key, headers: { Authorization: 'Key ' + key } }; - } - // Klassisch - return { key, headers: { 'x-api-key': key } }; -} - -function curseforgeZugang(settings) { - const s = settings || {}; - const auth = cfAuthHeaders(s.curseforgeApiKey); - if (auth) { - return { - modus: 'direkt', - basis: CURSEFORGE, - kopfzeilen: auth.headers, - downloadAdresse: (url) => url, - downloadKopfzeilen: auth.headers, - }; - } - const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, ''); - const weiter = eigeneAdresse || STANDARD_WEITERLEITUNG; - if (weiter) { - // Zur eigenen Adresse gehört auch das eigene Token – sonst schickte der - // Launcher das Token unseres Servers an einen fremden. - const token = eigeneAdresse - ? String(s.curseforgeProxyToken || '').trim() - : (String(s.curseforgeProxyToken || '').trim() || STANDARD_TOKEN); - const kopf = token ? { 'x-aeromc-token': token } : {}; - return { - modus: 'weiterleitung', - basis: weiter + '/v1', - kopfzeilen: kopf, - downloadAdresse: (url) => `${weiter}/v1/download?url=${encodeURIComponent(url)}`, - downloadKopfzeilen: kopf, - }; - } - return null; -} - -/* - * Ausweichadresse auf dem CurseForge-CDN. Manche Autoren untersagen den - * Download über die API; dann liefert downloadUrl null, die Datei liegt aber - * unter diesem berechenbaren Pfad. - */ -function forgeCdnUrl(fileId, fileName) { - const s = String(fileId || ''); - if (s.length < 5 || !fileName) return null; - return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`; -} - -const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 }; -// classId je Inhaltsart bei gameId 432 (Minecraft). modpack=4471 ist die auf -// CurseForge selbst verwendete Kategorie-ID (siehe curseforge.com/minecraft/modpacks). -const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552, modpack: 4471 }; - -function cfLoaderType(loader) { - return CF_LOADER[String(loader || '').toLowerCase()] || 0; -} - -/* - * CurseForge-Katalogsuche. Braucht direkten API-Key (modus === 'direkt'). - * Die Weiterleitung deckt absichtlich nur Dateiauflösung und Download ab. - */ - -/* - * Prüft einen API-Key direkt gegen CurseForge (ohne den gespeicherten Stand). - * Drei Proben: games, Minecraft, Modsuche – damit man sieht, ob der Key - * grundsätzlich gilt oder nur für bestimmte Bereiche fehlt. - */ -async function curseforgeTestKey(apiKey) { - const auth = cfAuthHeaders(apiKey); - if (!auth) { - return { ok: false, message: 'Kein API-Key angegeben.' }; - } - const key = auth.key; - const headers = Object.assign({ Accept: 'application/json', 'User-Agent': UA }, auth.headers); - const proben = [ - { name: 'games', url: CURSEFORGE + '/games' }, - { name: 'minecraft', url: CURSEFORGE + '/games/432' }, - { name: 'suche', url: CURSEFORGE + '/mods/search?gameId=432&classId=6&pageSize=1&index=0' }, - ]; - const ergebnisse = []; - for (const p of proben) { - try { - const res = await fetch(p.url, { headers }); - let text = ''; - try { text = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 120); } catch { /* egal */ } - ergebnisse.push({ name: p.name, status: res.status, ok: res.ok, text: res.ok ? 'ok' : text }); - } catch (err) { - ergebnisse.push({ name: p.name, status: 0, ok: false, text: err.message }); - } - } - - const alleOk = ergebnisse.every((e) => e.ok); - const alle403 = ergebnisse.every((e) => e.status === 401 || e.status === 403); - let message; - if (alleOk) { - message = 'Schlüssel wird akzeptiert – Katalog und Downloads sollten funktionieren.'; - } else if (alle403) { - message = 'CurseForge lehnt diesen Schlüssel ab (HTTP 403/401). Entweder ist er falsch kopiert, ' - + 'wurde neu erzeugt („Regenerate“ macht den alten ungültig), oder das Organisationskonto ' - + 'ist bei CurseForge noch nicht für die API freigeschaltet.'; - } else { - const suche = ergebnisse.find((e) => e.name === 'suche'); - if (suche && !suche.ok && ergebnisse.filter((e) => e.name !== 'suche').every((e) => e.ok)) { - message = 'Der Schlüssel gilt grundsätzlich, aber die Modsuche wird abgelehnt. ' - + 'Für den Katalog wird der Organisationsschlüssel benötigt (nicht nur ein per-game Key).'; - } else { - message = 'Gemischtes Ergebnis: ' + ergebnisse.map((e) => e.name + '=' + e.status).join(', '); - } - } - - return { - ok: alleOk, - message, - laenge: key.length, - beginntMit: key.slice(0, 7), - siehtAusWieCurseForge: /^\$2[aby]\$\d{2}\$/.test(key) || /^cfc_pat_/i.test(key), - format: /^cfc_pat_/i.test(key) ? 'studios-pat' : (/^\$2[aby]\$/.test(key) ? 'klassisch' : 'unbekannt'), - proben: ergebnisse, - }; -} - -async function curseforgeSearch(zugang, { - query = '', gameVersion = '', loader = '', sort = 'popularity', - limit = 30, offset = 0, art = 'mod', -} = {}) { - if (!zugang || zugang.modus !== 'direkt') { - return { - total: 0, - hits: [], - needKey: true, - error: 'Für die CurseForge-Suche brauchst du einen eigenen API-Key in den Netzwerk-Einstellungen. ' - + 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.', - }; - } - if (!CF_CLASS[art]) { - // Kein zugeordneter CurseForge-Klassencode für diese Inhaltsart -> lieber gar - // keine Treffer als falsch einsortierte (z. B. Mods unter "Datenpakete"). - return { total: 0, hits: [], error: 'CurseForge unterstützt diese Inhaltsart hier nicht – bitte Modrinth nutzen.' }; - } - const classId = CF_CLASS[art]; - const url = new URL(CURSEFORGE + '/mods/search'); - url.searchParams.set('gameId', '432'); - url.searchParams.set('classId', String(classId)); - if (query) url.searchParams.set('searchFilter', query); - if (gameVersion) url.searchParams.set('gameVersion', gameVersion); - const lt = cfLoaderType(loader); - // Loader-Filter nur bei Mods sinnvoll - if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt)); - // sortField: 1=Featured 2=Popularity 3=LastUpdated 4=Name 5=Author 6=TotalDownloads - const sortMap = { popularity: 2, relevance: 2, downloads: 6, updated: 3, newest: 3, follows: 2 }; - url.searchParams.set('sortField', String(sortMap[sort] || 2)); - url.searchParams.set('sortOrder', 'desc'); - url.searchParams.set('pageSize', String(Math.min(50, limit || 30))); - url.searchParams.set('index', String(offset || 0)); - - const res = await fetch(url, { - headers: Object.assign({ - Accept: 'application/json', - 'User-Agent': UA, - }, zugang.kopfzeilen), - }); - if (!res.ok) { - let detail = ''; - try { detail = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ } - if (res.status === 401 || res.status === 403) { - return { - total: 0, - hits: [], - needKey: true, - error: 'CurseForge lehnt den API-Key ab (HTTP ' + res.status + '). ' - + 'Bitte unter Optionen → Netzwerk einen gültigen Schlüssel von console.curseforge.com eintragen. ' - + 'Das Proxy-Token ist kein API-Key.' - + (detail ? ' (' + detail + ')' : ''), - }; - } - throw new Error('CurseForge-Suche fehlgeschlagen: HTTP ' + res.status + (detail ? ' – ' + detail : '')); - } - const data = await res.json(); - const hits = (data.data || []).map((m) => { - const author = (m.authors && m.authors[0] && m.authors[0].name) || ''; - const logo = (m.logo && (m.logo.thumbnailUrl || m.logo.url)) || ''; - return { - projectId: 'cf-' + m.id, - cfId: m.id, - slug: m.slug, - title: m.name, - description: m.summary || '', - author, - downloads: m.downloadCount || 0, - iconUrl: logo, - categories: (m.categories || []).map((c) => c.name).filter(Boolean), - source: 'curseforge', - }; - }); - const total = (data.pagination && data.pagination.totalCount) || hits.length; - return { total, hits }; -} - -function cfFileMatches(file, gameVersion, loader, art) { - if (!file || file.isAvailable === false) return false; - const gvs = file.gameVersions || []; - if (gameVersion && !gvs.some((v) => String(v).toLowerCase() === String(gameVersion).toLowerCase())) { - // manche Einträge trennen Version und Loader in gameVersions - const hasVer = gvs.some((v) => String(v) === gameVersion); - if (!hasVer) return false; - } - if (art === 'mod' && loader && loader !== 'vanilla') { - const want = String(loader).toLowerCase(); - const ok = gvs.some((v) => String(v).toLowerCase() === want) - || (file.sortableGameVersions || []).some((s) => { - const name = String(s.gameVersionName || s.gameVersion || '').toLowerCase(); - return name === want; - }); - // Wenn gar kein Loader in den Tags steht, trotzdem erlauben (manche Dateien) - const anyLoader = gvs.some((v) => /fabric|forge|quilt|neoforge/i.test(String(v))) - || (file.sortableGameVersions || []).some((s) => /fabric|forge|quilt|neoforge/i.test(String(s.gameVersionName || ''))); - if (anyLoader && !ok) return false; - } - return true; -} - -async function curseforgePickFile(zugang, modId, gameVersion, loader, art) { - if (!zugang || zugang.modus !== 'direkt') { - const err = new Error('CurseForge-Dateiauswahl braucht einen eigenen API-Key.'); - err.code = 'need-key'; - throw err; - } - const url = new URL(CURSEFORGE + '/mods/' + modId + '/files'); - url.searchParams.set('pageSize', '50'); - url.searchParams.set('index', '0'); - if (gameVersion) url.searchParams.set('gameVersion', gameVersion); - const lt = cfLoaderType(loader); - if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt)); - - const res = await fetch(url, { - headers: Object.assign({ Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen), - }); - if (!res.ok) throw new Error('CurseForge-Dateien fehlgeschlagen: HTTP ' + res.status); - const data = await res.json(); - const files = data.data || []; - const match = files.find((f) => cfFileMatches(f, gameVersion, loader, art)) || files[0]; - if (!match) return null; - const name = match.fileName || ('file-' + match.id + '.jar'); - let dl = match.downloadUrl || null; - if (!dl) dl = forgeCdnUrl(match.id, name); - return { - fileId: match.id, - filename: name, - url: dl, - hashes: cfHashes(match), - versionNumber: match.displayName || match.fileName || String(match.id), - dependencies: match.dependencies || [], - }; -} - -// Beste Modpack-Datei eines CurseForge-Projekts auflösen (für den Katalog-Import, -// analog zu modrinthResolveModpack). -async function curseforgeResolveModpackFile(zugang, modId, gameVersion) { - return curseforgePickFile(zugang, modId, gameVersion, '', 'modpack'); -} - -async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) { - if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' }; - const rawId = String(projectId || '').replace(/^cf-/, ''); - const modId = Number(rawId); - if (!modId) return { ok: false, message: 'Ungültige CurseForge-Projekt-ID.' }; - - const file = await curseforgePickFile(zugang, modId, gameVersion, loader, art); - if (!file || !file.url) { - return { - ok: false, - reason: 'no-match', - message: `Keine passende Datei für ${gameVersion || '?'} / ${loader || 'any'} gefunden.`, - }; - } - - const dest = path.join(modsDir, file.filename); - // Download: direkt mit Key oder über Weiterleitung - const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url; - const headers = Object.assign({}, zugang.downloadKopfzeilen || {}); - const bytes = await downloadFile(url, dest, headers, file.hashes); - - // Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency) - const depIds = (file.dependencies || []) - .filter((d) => d.relationType === 3 && d.modId) - .map((d) => d.modId); - - return { - ok: true, - dependencies: depIds.map((id) => ({ projectId: 'cf-' + id, required: true })), - entry: { - projectId: 'cf-' + modId, - title: title || file.filename, - filename: file.filename, - versionId: String(file.fileId), - versionNumber: file.versionNumber, - gameVersion, - loader, - source: 'curseforge', - bytes, - }, - }; -} - - - -/* - * Löst die Dateien eines CurseForge-Modpacks auf. - * zugang: aus curseforgeZugang(); files: [{ projectID, fileID }] - * -> Map fileID -> { fileName, url, kopfzeilen } - */ -async function curseforgeResolveFiles(zugang, files, onProgress) { - if (!zugang) throw new Error('Kein Zugang zu CurseForge eingerichtet.'); - const ids = (files || []).map((f) => Number(f.fileID)).filter(Boolean); - const map = new Map(); - const PAKET = 100; - - for (let i = 0; i < ids.length; i += PAKET) { - const teil = ids.slice(i, i + PAKET); - const res = await fetch(zugang.basis + '/mods/files', { - method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen), - body: JSON.stringify({ fileIds: teil }), - }); - if (res.status === 401 || res.status === 403) { - throw new Error(zugang.modus === 'weiterleitung' - ? 'Der CurseForge-Server weist die Anfrage zurück (HTTP ' + res.status + ').' - : 'CurseForge weist den API-Key zurück (HTTP ' + res.status + ').'); - } - if (res.status === 429) throw new Error('Zu viele Anfragen – bitte einen Moment warten.'); - if (!res.ok) throw new Error('CurseForge-Abfrage fehlgeschlagen: HTTP ' + res.status); - const daten = await res.json(); - for (const d of (daten && daten.data) || []) { - const roh = d.downloadUrl || forgeCdnUrl(d.id, d.fileName); - if (!d.fileName || !roh) continue; - map.set(Number(d.id), { - fileName: d.fileName, - url: zugang.downloadAdresse(roh), - hashes: cfHashes(d), - kopfzeilen: zugang.downloadKopfzeilen, - }); - } - if (onProgress) onProgress({ aufgeloest: Math.min(i + PAKET, ids.length), gesamt: ids.length }); - } - return map; -} - -// Projekttitel nachschlagen (für Abhängigkeiten, die nur eine ID liefern) -async function modrinthProjectTitle(projectId) { - try { - const res = await fetch(`${MODRINTH}/project/${projectId}`, { headers: { 'User-Agent': UA } }); - if (!res.ok) return projectId; - const data = await res.json(); - return data.title || projectId; - } catch { return projectId; } -} - -module.exports = { - scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher, - getMcVersions, - modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle, - modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN, - curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang, - curseforgeResolveModpackFile, -}; +'use strict'; + +/* + * services.js – externe Dienste & Systemabfragen für AeroMC + * ---------------------------------------------------------------- + * Bewusst frei von Electron-Abhängigkeiten, damit headless (mit `node`) + * testbar. Nutzt globales fetch (Node 18+ / Electron 28). + * + * - scanJava() : findet installierte Java-Runtimes + * - getMcVersions() : Mojang-Versionsliste (mit Datei-Cache) + * - modrinthSearch() : Mod-Suche + * - modrinthInstall() : passende Mod-Version in einen Ordner laden + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync, spawnSync } = require('child_process'); + +const UA = 'AeroMC-Launcher/0.2 (Minecraft-Launcher; contact: viper)'; + +// --------------------------------------------------------------------------- +// Arbeitsspeicher +// --------------------------------------------------------------------------- + +/* + * Prüft die Speicherangaben einer Instanz gegen den vorhandenen Arbeitsspeicher. + * Ohne diese Prüfung startet Java mit einem kryptischen Fehler, den niemand + * mit "zu viel RAM eingetragen" in Verbindung bringt. + * + * -> { ok, message } bei Fehlern, { ok: true, warnung } bei knappen Angaben + */ +function pruefeSpeicher(minMb, maxMb, gesamtMb) { + const min = Number(minMb) || 0; + const max = Number(maxMb) || 0; + const gesamt = Number(gesamtMb) || 0; + + if (min && max && min > max) { + return { ok: false, message: `Der Mindestspeicher (${min} MB) ist größer als das Maximum (${max} MB).` }; + } + if (max && max < 512) { + return { ok: false, message: `${max} MB sind zu wenig – Minecraft braucht mindestens 512 MB.` }; + } + if (gesamt && max && max > gesamt) { + return { ok: false, message: `${max} MB übersteigen den vorhandenen Arbeitsspeicher (${gesamt} MB). Minecraft würde nicht starten.` }; + } + // Dem System muss etwas bleiben: Windows und der Launcher selbst brauchen auch Platz + if (gesamt && max && max > gesamt * 0.8) { + return { + ok: true, + warnung: `${max} MB sind mehr als 80 % des Arbeitsspeichers (${gesamt} MB). ` + + `Für Windows und den Launcher sollte etwas übrig bleiben – üblich sind höchstens ${Math.floor(gesamt * 0.7 / 256) * 256} MB.`, + }; + } + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Java-Erkennung +// --------------------------------------------------------------------------- + +// vom Launcher selbst heruntergeladene Laufzeiten (wird beim Start gesetzt) +let managedJavaDir = null; +function setManagedJavaDir(dir) { managedJavaDir = dir; } + +function javaCandidateDirs() { + const pf = process.env['ProgramFiles'] || 'C:\\Program Files'; + const pfx = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + const appdata = process.env['APPDATA'] || ''; + const localappdata = process.env['LOCALAPPDATA'] || ''; + const vendors = ['Java', 'Eclipse Adoptium', 'Eclipse Foundation', 'Zulu', 'Amazon Corretto', + 'BellSoft', 'Microsoft', 'AdoptOpenJDK', 'Semeru', 'JetBrains']; + const roots = []; + for (const base of [pf, pfx]) { + for (const v of vendors) roots.push(path.join(base, v)); + } + // Runtimes der offiziellen Minecraft-Launcher + if (appdata) roots.push(path.join(appdata, '.minecraft', 'runtime')); + if (localappdata) roots.push(path.join(localappdata, 'Packages')); + // eigene, vom Launcher geholte Laufzeiten + if (managedJavaDir) roots.push(managedJavaDir); + return roots; +} + +// findet rekursiv (begrenzte Tiefe) alle java.exe unter einem Startordner +function findJavaExes(root, depth, acc) { + if (depth < 0 || !fs.existsSync(root)) return; + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const full = path.join(root, e.name); + if (e.isFile() && /^javaw?\.exe$/i.test(e.name)) { + // bevorzugt java.exe (Konsole) statt javaw.exe + if (/^java\.exe$/i.test(e.name)) acc.add(full); + } else if (e.isDirectory()) { + // Tiefe begrenzen; typische Struktur ...//bin/java.exe + findJavaExes(full, depth - 1, acc); + } + } +} + +function probeJava(javaPath) { + // `java -version` schreibt nach stderr; spawnSync liefert stdout UND stderr. + try { + const r = spawnSync(javaPath, ['-version'], { encoding: 'utf8', timeout: 5000 }); + const out = (r.stderr || '') + (r.stdout || ''); + if (!out.trim()) return null; + return parseJavaVersion(javaPath, out); + } catch { + return null; + } +} + +function parseJavaVersion(javaPath, out) { + const m = out.match(/version "([^"]+)"/i); + const raw = m ? m[1] : ''; + let major = 0; + if (raw) { + const parts = raw.split('.'); + major = raw.startsWith('1.') ? parseInt(parts[1], 10) : parseInt(parts[0], 10); + } + const arch = /64-bit/i.test(out) ? 'x64' : (/32-bit|Client VM/i.test(out) ? 'x86' : '?'); + const vendorLine = out.split('\n').map((l) => l.trim()).find((l) => /runtime|openjdk|hotspot|jre|jdk/i.test(l)) || ''; + return { path: javaPath, version: raw || 'unbekannt', major: major || 0, arch, info: vendorLine }; +} + +// Ergebnis des Suchlaufs zwischenspeichern – die Suche kostet ~1 s +let javaCache = null; +let javaCacheFile = null; +function setCacheDir(dir) { javaCacheFile = path.join(dir, 'java-cache.json'); } + +function scanJava(useCache = true) { + const MAX_AGE = 24 * 3600 * 1000; + if (useCache) { + if (javaCache && Date.now() - javaCache.at < MAX_AGE) return javaCache.list; + // Cache von der Platte (gilt auch direkt nach dem Start) + if (javaCacheFile) { + try { + const c = JSON.parse(fs.readFileSync(javaCacheFile, 'utf8')); + if (c && Date.now() - c.at < MAX_AGE && Array.isArray(c.list) + && c.list.every((j) => fs.existsSync(j.path))) { + javaCache = c; + return c.list; + } + } catch { /* kein oder unbrauchbarer Cache */ } + } + } + const list = scanJavaUncached(); + javaCache = { at: Date.now(), list }; + if (javaCacheFile) { try { fs.writeFileSync(javaCacheFile, JSON.stringify(javaCache)); } catch { /* egal */ } } + return list; +} + +function scanJavaUncached() { + const found = new Set(); + + // 1) JAVA_HOME + if (process.env.JAVA_HOME) { + const p = path.join(process.env.JAVA_HOME, 'bin', 'java.exe'); + if (fs.existsSync(p)) found.add(p); + } + // 2) java aus PATH + try { + const where = execFileSync('where', ['java'], { encoding: 'utf8', timeout: 4000 }); + where.split(/\r?\n/).forEach((l) => { l = l.trim(); if (l && /java\.exe$/i.test(l) && fs.existsSync(l)) found.add(l); }); + } catch { /* nix */ } + // 3) bekannte Vendor-Ordner + for (const root of javaCandidateDirs()) findJavaExes(root, 4, found); + + // prüfen & dedupen nach Version+Pfad + const results = []; + for (const p of found) { + const info = probeJava(p); + if (info) results.push(info); + } + // sortieren: 64-bit vor 32-bit, höhere Major-Version zuerst + results.sort((a, b) => { + if ((b.arch === 'x64') - (a.arch === 'x64') !== 0) return (b.arch === 'x64') - (a.arch === 'x64'); + return b.major - a.major; + }); + return results; +} + +// --------------------------------------------------------------------------- +// Mojang-Versionsliste (mit Cache) +// --------------------------------------------------------------------------- + +const MC_MANIFEST_URL = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'; + +async function getMcVersions(cacheDir, maxAgeMs = 6 * 3600 * 1000) { + const cacheFile = path.join(cacheDir, 'mc-versions.json'); + // Cache frisch genug? + try { + const st = fs.statSync(cacheFile); + if (Date.now() - st.mtimeMs < maxAgeMs) { + return Object.assign({ cached: true }, JSON.parse(fs.readFileSync(cacheFile, 'utf8'))); + } + } catch { /* kein Cache */ } + + try { + const res = await fetch(MC_MANIFEST_URL, { headers: { 'User-Agent': UA } }); + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + const slim = { + latest: data.latest, + versions: data.versions.map((v) => ({ id: v.id, type: v.type, releaseTime: v.releaseTime, url: v.url, sha1: v.sha1 })), + fetchedAt: new Date().toISOString(), + }; + try { fs.mkdirSync(cacheDir, { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify(slim)); } catch { /* egal */ } + return Object.assign({ cached: false }, slim); + } catch (err) { + // offline: alten Cache nehmen, falls vorhanden + try { + const old = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + return Object.assign({ cached: true, offline: true }, old); + } catch { + return { offline: true, error: err.message, latest: {}, versions: [] }; + } + } +} + +// --------------------------------------------------------------------------- +// Modrinth (Mod-Suche & Installation) +// --------------------------------------------------------------------------- + +const MODRINTH = 'https://api.modrinth.com/v2'; + +const MODRINTH_SORTS = ['relevance', 'downloads', 'follows', 'newest', 'updated']; + +/* + * Inhaltsarten, die der Launcher aus dem Modrinth-Katalog holen kann. + * ordner = Zielverzeichnis im Spielordner, loaderNoetig = braucht einen + * Mod-Loader (Ressourcenpakete laufen auch in Vanilla). + */ +const INHALTSARTEN = { + mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true }, + resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false }, + shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false }, + datapack: { projectType: 'datapack', ordner: 'datapacks', label: 'Datenpakete', loaderNoetig: false }, + modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false }, +}; + +function inhaltsArt(art) { + return INHALTSARTEN[art] || INHALTSARTEN.mod; +} + +async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0, art = 'mod' } = {}) { + const typ = inhaltsArt(art); + const facets = [['project_type:' + typ.projectType]]; + if (gameVersion) facets.push(['versions:' + gameVersion]); + // Der Loader-Filter gilt nur für Mods. Ressourcenpakete und Shader werden + // bei Modrinth nicht nach Mod-Loader eingeordnet – der Filter würde die + // Trefferliste leeren. + if (typ.loaderNoetig && loader && loader !== 'vanilla') facets.push(['categories:' + loader]); + if (category) facets.push(['categories:' + category]); + const url = new URL(MODRINTH + '/search'); + url.searchParams.set('query', query); + url.searchParams.set('limit', String(limit)); + url.searchParams.set('offset', String(offset)); + url.searchParams.set('index', MODRINTH_SORTS.includes(sort) ? sort : 'relevance'); + url.searchParams.set('facets', JSON.stringify(facets)); + + const res = await fetch(url, { headers: { 'User-Agent': UA } }); + if (!res.ok) throw new Error('Modrinth-Suche fehlgeschlagen: HTTP ' + res.status); + const data = await res.json(); + return { + total: data.total_hits, + hits: (data.hits || []).map((h) => ({ + projectId: h.project_id, + slug: h.slug, + title: h.title, + description: h.description, + author: h.author, + downloads: h.downloads, + follows: h.follows || 0, + iconUrl: h.icon_url || '', + categories: h.categories || [], + // bei Modpacks hilfreich: welche Spielversionen/Loader das Projekt angibt + versions: h.versions || [], + displayCategories: h.display_categories || h.categories || [], + })), + }; +} + +// findet die passende Datei-Version für Spielversion + Loader +async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod', versionId = '' }) { + const typ = inhaltsArt(art); + // bestimmte Version gewünscht (z. B. geteilte Instanz) – sonst neueste passende + if (versionId) { + try { + const res = await fetch(MODRINTH + '/version/' + encodeURIComponent(versionId), { headers: { 'User-Agent': UA } }); + if (res.ok) { + const v = await res.json(); + const passt = !gameVersion || (v.game_versions || []).includes(gameVersion); + const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0]; + if (passt && file && v.project_id === projectId) { + return { + versionId: v.id, + versionNumber: v.version_number, + filename: file.filename, + url: file.url, + hashes: mrHashes(file), + sha1: file.hashes && file.hashes.sha1, + size: file.size, + dependencies: v.dependencies || [], + }; + } + } + } catch { /* dann eben die neueste passende */ } + } + const url = new URL(MODRINTH + '/project/' + projectId + '/version'); + if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion])); + // Nur Mods werden nach Loader eingeordnet; bei Ressourcenpaketen und + // Shadern würde der Filter alle Dateien ausschließen. + if (typ.loaderNoetig && loader && loader !== 'vanilla') url.searchParams.set('loaders', JSON.stringify([loader])); + const res = await fetch(url, { headers: { 'User-Agent': UA } }); + if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status); + const versions = await res.json(); + if (!versions.length) return null; + const v = versions[0]; // neueste passende + const file = v.files.find((f) => f.primary) || v.files[0]; + if (!file) return null; + return { + versionId: v.id, + versionNumber: v.version_number, + filename: file.filename, + url: file.url, + hashes: mrHashes(file), + sha1: file.hashes && file.hashes.sha1, + size: file.size, + dependencies: v.dependencies || [], + }; +} + +/* + * Neueste passende Modpack-Version von Modrinth. + * gameVersion optional – ohne Filter kommt die neueste Version. + */ +async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) { + const url = new URL(MODRINTH + '/project/' + projectId + '/version'); + if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion])); + const res = await fetch(url, { headers: { 'User-Agent': UA } }); + if (!res.ok) throw new Error('Modpack-Versionen fehlgeschlagen: HTTP ' + res.status); + const versions = await res.json(); + if (!Array.isArray(versions) || !versions.length) return null; + const v = versions[0]; + const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0]; + if (!file || !file.url) return null; + return { + versionId: v.id, + versionNumber: v.version_number, + name: v.name || v.version_number, + filename: file.filename, + url: file.url, + hashes: mrHashes(file), + size: file.size || 0, + gameVersions: v.game_versions || [], + loaders: v.loaders || [], + }; +} + +/* + * Prüfsumme eines Downloads kontrollieren. erwartet: { sha512?, sha1? } – die + * Werte stammen aus der Modrinth- bzw. CurseForge-API. Ohne Angabe wird nichts + * geprüft (z. B. Dateien, zu denen die Quelle keine Prüfsumme nennt). + */ +function pruefeDownload(buf, erwartet, name) { + if (!erwartet) return; + for (const algo of ['sha512', 'sha256', 'sha1']) { + const soll = erwartet[algo] && String(erwartet[algo]).toLowerCase(); + if (!soll) continue; + const ist = require('crypto').createHash(algo).update(buf).digest('hex'); + if (ist !== soll) { + throw new Error(`Prüfsumme von ${name || 'Download'} stimmt nicht – Datei verworfen (beschädigt oder manipuliert).`); + } + return; + } +} + +// CurseForge liefert Prüfsummen als Liste: algo 1 = SHA-1, 2 = MD5 +function cfHashes(datei) { + const sha1 = ((datei && datei.hashes) || []).find((h) => h.algo === 1 && h.value); + return sha1 ? { sha1: sha1.value } : null; +} + +// Modrinth: { sha512, sha1 } +function mrHashes(datei) { + const h = (datei && datei.hashes) || {}; + return h.sha512 || h.sha1 ? { sha512: h.sha512, sha1: h.sha1 } : null; +} + +async function downloadBuffer(url, extraHeaders, erwartet) { + const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); + if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); + const buf = Buffer.from(await res.arrayBuffer()); + pruefeDownload(buf, erwartet, decodeURIComponent(String(url).split('/').pop() || '')); + return buf; +} + +// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt +// erwartet: Prüfsummen (siehe pruefeDownload) – geschrieben wird nur, was passt +async function downloadFile(url, destPath, extraHeaders, erwartet) { + const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); + if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); + const buf = Buffer.from(await res.arrayBuffer()); + pruefeDownload(buf, erwartet, path.basename(destPath)); + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, buf); + return buf.length; +} + +// lädt eine Mod in ; gibt Metadaten für die Instanz zurück +async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir, art = 'mod', versionId = '' }) { + const typ = inhaltsArt(art); + const file = await modrinthResolveFile({ projectId, gameVersion, loader, art, versionId }); + if (!file) { + return { ok: false, reason: 'no-match', + message: typ.loaderNoetig + ? `Keine passende Version für ${gameVersion} / ${loader} gefunden.` + : `Keine passende Version für ${gameVersion} gefunden.` }; + } + // Dateiname aus der Antwort der Quelle: nur der Name, nie ein Pfad + const dateiname = path.basename(String(file.filename || '')); + if (!dateiname || dateiname === '.' || dateiname === '..') return { ok: false, reason: 'bad-name', message: 'Ungültiger Dateiname in der Antwort der Quelle.' }; + const dest = path.join(modsDir, dateiname); + const bytes = await downloadFile(file.url, dest, null, file.hashes); + return { + ok: true, + dependencies: file.dependencies || [], + entry: { + projectId, + title: title || projectId, + filename: dateiname, + versionId: file.versionId, + versionNumber: file.versionNumber, + gameVersion, + loader, + installedAt: new Date().toISOString(), + bytes, + }, + }; +} + +/* + * Prüft die installierten Mods auf neuere Dateien. + * Von Hand abgelegte .jar-Dateien (ohne projectId) bleiben unangetastet – + * für die weiß niemand, woher sie stammen. + * -> [{ projectId, title, alt: {...}, neu: {...} }] + */ +async function modrinthFindUpdates(mods, gameVersion, loader, onProgress, art = 'mod') { + const kandidaten = (mods || []).filter((m) => m && m.projectId); + const treffer = []; + let geprueft = 0; + + // in kleinen Gruppen abfragen: schneller als nacheinander, ohne die API zu fluten + const GRUPPE = 4; + for (let i = 0; i < kandidaten.length; i += GRUPPE) { + await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => { + let file = null; + try { + file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader, art }); + } catch { /* Netzfehler -> diesen Eintrag überspringen */ } + geprueft++; + if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId }); + if (!file || !file.versionId) return; + // schon aktuell? + if (m.versionId && file.versionId === m.versionId) return; + if (file.filename === m.filename) return; + treffer.push({ + projectId: m.projectId, + title: m.title || m.projectId, + alt: { filename: m.filename, versionNumber: m.versionNumber || '?' }, + neu: { filename: file.filename, versionNumber: file.versionNumber, versionId: file.versionId, size: file.size }, + }); + })); + } + treffer.sort((a, b) => String(a.title).localeCompare(String(b.title))); + return { geprueft: kandidaten.length, uebersprungen: (mods || []).length - kandidaten.length, treffer }; +} + +// --------------------------------------------------------------------------- +// CurseForge (Modpack-Dateien auflösen) +// --------------------------------------------------------------------------- + +const CURSEFORGE = 'https://api.curseforge.com/v1'; + +/* + * Vorgaben, die beim Bauen gesetzt wurden (scripts/write-build-config.js). + * Fehlt die Datei – etwa beim Start aus dem Quelltext –, gilt: keine Vorgabe. + */ +let BUILD_VORGABEN = {}; +try { BUILD_VORGABEN = require('./build-config.json'); } catch { /* nicht erzeugt */ } + +/* + * Adresse der eigenen CurseForge-Weiterleitung. Die darf fest hier stehen – + * sie ist kein Geheimnis, der API-Schlüssel liegt auf dem Server. + * Über AEROMC_CF_PROXY_URL beim Bauen überschreibbar. + */ +const STANDARD_WEITERLEITUNG = String(BUILD_VORGABEN.curseforgeProxyUrl || 'https://curse.viper.ipv64.net'); + +// Das Token dagegen kommt ausschließlich aus dem Build (AEROMC_CF_PROXY_TOKEN) +// und steht bewusst nicht im Quelltext. +const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || ''); + +/* + * Entscheidet, wie CurseForge angesprochen wird. + * Eigener Schlüssel hat Vorrang: wer einen hat, geht direkt und belastet + * kein fremdes Kontingent. + * -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null + */ +/* + * CurseForge kennt zwei Schlüsselarten: + * - Klassisch (console.curseforge.com): Header x-api-key, oft $2a$10$… + * - Studios / Profil-PAT: Header "Authorization: Key cfc_pat_…" + */ +function cfAuthHeaders(apiKey) { + let key = String(apiKey || '').trim(); + if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { + key = key.slice(1, -1).trim(); + } + key = key.replace(/[\r\n\t]/g, '').trim(); + // Komplette Kopfzeile eingefügt? + const m = key.match(/^authorization\s*:\s*key\s+(.+)$/i); + if (m) key = m[1].trim(); + if (key.toLowerCase().startsWith('key ')) key = key.slice(4).trim(); + + if (!key) return null; + + // Neues Studios-Format + if (/^cfc_pat_/i.test(key) || /^cf[a-z]_/i.test(key)) { + return { key, headers: { Authorization: 'Key ' + key } }; + } + // Klassisch + return { key, headers: { 'x-api-key': key } }; +} + +function curseforgeZugang(settings) { + const s = settings || {}; + const auth = cfAuthHeaders(s.curseforgeApiKey); + if (auth) { + return { + modus: 'direkt', + basis: CURSEFORGE, + kopfzeilen: auth.headers, + downloadAdresse: (url) => url, + downloadKopfzeilen: auth.headers, + }; + } + const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, ''); + const weiter = eigeneAdresse || STANDARD_WEITERLEITUNG; + if (weiter) { + // Zur eigenen Adresse gehört auch das eigene Token – sonst schickte der + // Launcher das Token unseres Servers an einen fremden. + const token = eigeneAdresse + ? String(s.curseforgeProxyToken || '').trim() + : (String(s.curseforgeProxyToken || '').trim() || STANDARD_TOKEN); + const kopf = token ? { 'x-aeromc-token': token } : {}; + return { + modus: 'weiterleitung', + basis: weiter + '/v1', + kopfzeilen: kopf, + downloadAdresse: (url) => `${weiter}/v1/download?url=${encodeURIComponent(url)}`, + downloadKopfzeilen: kopf, + }; + } + return null; +} + +/* + * Manche Autoren untersagen den Download über Drittanbieter: CurseForge liefert dann + * downloadUrl = null. Das wird respektiert – es gibt bewusst keine selbst gebaute + * CDN-Adresse als Umweg. Stattdessen bekommt der Nutzer einen Link auf die Projektseite, + * von der er die Datei selbst laden kann. + */ +function cfProjektSeite(modId) { + return `https://www.curseforge.com/projects/${Number(modId) || 0}`; +} + +const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 }; +// classId je Inhaltsart bei gameId 432 (Minecraft). modpack=4471 ist die auf +// CurseForge selbst verwendete Kategorie-ID (siehe curseforge.com/minecraft/modpacks). +const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552, modpack: 4471 }; + +function cfLoaderType(loader) { + return CF_LOADER[String(loader || '').toLowerCase()] || 0; +} + +/* + * CurseForge-Katalogsuche. Braucht direkten API-Key (modus === 'direkt'). + * Die Weiterleitung deckt absichtlich nur Dateiauflösung und Download ab. + */ + +/* + * Prüft einen API-Key direkt gegen CurseForge (ohne den gespeicherten Stand). + * Drei Proben: games, Minecraft, Modsuche – damit man sieht, ob der Key + * grundsätzlich gilt oder nur für bestimmte Bereiche fehlt. + */ +async function curseforgeTestKey(apiKey) { + const auth = cfAuthHeaders(apiKey); + if (!auth) { + return { ok: false, message: 'Kein API-Key angegeben.' }; + } + const key = auth.key; + const headers = Object.assign({ Accept: 'application/json', 'User-Agent': UA }, auth.headers); + const proben = [ + { name: 'games', url: CURSEFORGE + '/games' }, + { name: 'minecraft', url: CURSEFORGE + '/games/432' }, + { name: 'suche', url: CURSEFORGE + '/mods/search?gameId=432&classId=6&pageSize=1&index=0' }, + ]; + const ergebnisse = []; + for (const p of proben) { + try { + const res = await fetch(p.url, { headers }); + let text = ''; + try { text = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 120); } catch { /* egal */ } + ergebnisse.push({ name: p.name, status: res.status, ok: res.ok, text: res.ok ? 'ok' : text }); + } catch (err) { + ergebnisse.push({ name: p.name, status: 0, ok: false, text: err.message }); + } + } + + const alleOk = ergebnisse.every((e) => e.ok); + const alle403 = ergebnisse.every((e) => e.status === 401 || e.status === 403); + let message; + if (alleOk) { + message = 'Schlüssel wird akzeptiert – Katalog und Downloads sollten funktionieren.'; + } else if (alle403) { + message = 'CurseForge lehnt diesen Schlüssel ab (HTTP 403/401). Entweder ist er falsch kopiert, ' + + 'wurde neu erzeugt („Regenerate“ macht den alten ungültig), oder das Organisationskonto ' + + 'ist bei CurseForge noch nicht für die API freigeschaltet.'; + } else { + const suche = ergebnisse.find((e) => e.name === 'suche'); + if (suche && !suche.ok && ergebnisse.filter((e) => e.name !== 'suche').every((e) => e.ok)) { + message = 'Der Schlüssel gilt grundsätzlich, aber die Modsuche wird abgelehnt. ' + + 'Für den Katalog wird der Organisationsschlüssel benötigt (nicht nur ein per-game Key).'; + } else { + message = 'Gemischtes Ergebnis: ' + ergebnisse.map((e) => e.name + '=' + e.status).join(', '); + } + } + + return { + ok: alleOk, + message, + laenge: key.length, + beginntMit: key.slice(0, 7), + siehtAusWieCurseForge: /^\$2[aby]\$\d{2}\$/.test(key) || /^cfc_pat_/i.test(key), + format: /^cfc_pat_/i.test(key) ? 'studios-pat' : (/^\$2[aby]\$/.test(key) ? 'klassisch' : 'unbekannt'), + proben: ergebnisse, + }; +} + +async function curseforgeSearch(zugang, { + query = '', gameVersion = '', loader = '', sort = 'popularity', + limit = 30, offset = 0, art = 'mod', +} = {}) { + if (!zugang || zugang.modus !== 'direkt') { + return { + total: 0, + hits: [], + needKey: true, + error: 'Für die CurseForge-Suche brauchst du einen eigenen API-Key in den Netzwerk-Einstellungen. ' + + 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.', + }; + } + if (!CF_CLASS[art]) { + // Kein zugeordneter CurseForge-Klassencode für diese Inhaltsart -> lieber gar + // keine Treffer als falsch einsortierte (z. B. Mods unter "Datenpakete"). + return { total: 0, hits: [], error: 'CurseForge unterstützt diese Inhaltsart hier nicht – bitte Modrinth nutzen.' }; + } + const classId = CF_CLASS[art]; + const url = new URL(CURSEFORGE + '/mods/search'); + url.searchParams.set('gameId', '432'); + url.searchParams.set('classId', String(classId)); + if (query) url.searchParams.set('searchFilter', query); + if (gameVersion) url.searchParams.set('gameVersion', gameVersion); + const lt = cfLoaderType(loader); + // Loader-Filter nur bei Mods sinnvoll + if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt)); + // sortField: 1=Featured 2=Popularity 3=LastUpdated 4=Name 5=Author 6=TotalDownloads + const sortMap = { popularity: 2, relevance: 2, downloads: 6, updated: 3, newest: 3, follows: 2 }; + url.searchParams.set('sortField', String(sortMap[sort] || 2)); + url.searchParams.set('sortOrder', 'desc'); + url.searchParams.set('pageSize', String(Math.min(50, limit || 30))); + url.searchParams.set('index', String(offset || 0)); + + const res = await fetch(url, { + headers: Object.assign({ + Accept: 'application/json', + 'User-Agent': UA, + }, zugang.kopfzeilen), + }); + if (!res.ok) { + let detail = ''; + try { detail = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ } + if (res.status === 401 || res.status === 403) { + return { + total: 0, + hits: [], + needKey: true, + error: 'CurseForge lehnt den API-Key ab (HTTP ' + res.status + '). ' + + 'Bitte unter Optionen → Netzwerk einen gültigen Schlüssel von console.curseforge.com eintragen. ' + + 'Das Proxy-Token ist kein API-Key.' + + (detail ? ' (' + detail + ')' : ''), + }; + } + throw new Error('CurseForge-Suche fehlgeschlagen: HTTP ' + res.status + (detail ? ' – ' + detail : '')); + } + const data = await res.json(); + const hits = (data.data || []).map((m) => { + const author = (m.authors && m.authors[0] && m.authors[0].name) || ''; + const logo = (m.logo && (m.logo.thumbnailUrl || m.logo.url)) || ''; + return { + projectId: 'cf-' + m.id, + cfId: m.id, + slug: m.slug, + title: m.name, + description: m.summary || '', + author, + downloads: m.downloadCount || 0, + iconUrl: logo, + categories: (m.categories || []).map((c) => c.name).filter(Boolean), + source: 'curseforge', + }; + }); + const total = (data.pagination && data.pagination.totalCount) || hits.length; + return { total, hits }; +} + +function cfFileMatches(file, gameVersion, loader, art) { + if (!file || file.isAvailable === false) return false; + const gvs = file.gameVersions || []; + if (gameVersion && !gvs.some((v) => String(v).toLowerCase() === String(gameVersion).toLowerCase())) { + // manche Einträge trennen Version und Loader in gameVersions + const hasVer = gvs.some((v) => String(v) === gameVersion); + if (!hasVer) return false; + } + if (art === 'mod' && loader && loader !== 'vanilla') { + const want = String(loader).toLowerCase(); + const ok = gvs.some((v) => String(v).toLowerCase() === want) + || (file.sortableGameVersions || []).some((s) => { + const name = String(s.gameVersionName || s.gameVersion || '').toLowerCase(); + return name === want; + }); + // Wenn gar kein Loader in den Tags steht, trotzdem erlauben (manche Dateien) + const anyLoader = gvs.some((v) => /fabric|forge|quilt|neoforge/i.test(String(v))) + || (file.sortableGameVersions || []).some((s) => /fabric|forge|quilt|neoforge/i.test(String(s.gameVersionName || ''))); + if (anyLoader && !ok) return false; + } + return true; +} + +async function curseforgePickFile(zugang, modId, gameVersion, loader, art) { + if (!zugang || zugang.modus !== 'direkt') { + const err = new Error('CurseForge-Dateiauswahl braucht einen eigenen API-Key.'); + err.code = 'need-key'; + throw err; + } + const url = new URL(CURSEFORGE + '/mods/' + modId + '/files'); + url.searchParams.set('pageSize', '50'); + url.searchParams.set('index', '0'); + if (gameVersion) url.searchParams.set('gameVersion', gameVersion); + const lt = cfLoaderType(loader); + if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt)); + + const res = await fetch(url, { + headers: Object.assign({ Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen), + }); + if (!res.ok) throw new Error('CurseForge-Dateien fehlgeschlagen: HTTP ' + res.status); + const data = await res.json(); + const files = data.data || []; + const match = files.find((f) => cfFileMatches(f, gameVersion, loader, art)) || files[0]; + if (!match) return null; + const name = match.fileName || ('file-' + match.id + '.jar'); + // keine Download-Adresse = der Autor erlaubt keine Downloads über Drittanbieter + if (!match.downloadUrl) return { gesperrt: true, modId, fileId: match.id, filename: name }; + return { + fileId: match.id, + filename: name, + url: match.downloadUrl, + hashes: cfHashes(match), + versionNumber: match.displayName || match.fileName || String(match.id), + dependencies: match.dependencies || [], + }; +} + +// Beste Modpack-Datei eines CurseForge-Projekts auflösen (für den Katalog-Import, +// analog zu modrinthResolveModpack). +async function curseforgeResolveModpackFile(zugang, modId, gameVersion) { + return curseforgePickFile(zugang, modId, gameVersion, '', 'modpack'); +} + +async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) { + if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' }; + const rawId = String(projectId || '').replace(/^cf-/, ''); + const modId = Number(rawId); + if (!modId) return { ok: false, message: 'Ungültige CurseForge-Projekt-ID.' }; + + const file = await curseforgePickFile(zugang, modId, gameVersion, loader, art); + if (file && file.gesperrt) { + return { + ok: false, + reason: 'gesperrt', + message: `„${title || file.filename}“ darf laut Autor nicht über Launcher geladen werden. Bitte von Hand laden: ${cfProjektSeite(modId)}`, + }; + } + if (!file || !file.url) { + return { + ok: false, + reason: 'no-match', + message: `Keine passende Datei für ${gameVersion || '?'} / ${loader || 'any'} gefunden.`, + }; + } + + const dest = path.join(modsDir, file.filename); + // Download: direkt mit Key oder über Weiterleitung + const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url; + const headers = Object.assign({}, zugang.downloadKopfzeilen || {}); + const bytes = await downloadFile(url, dest, headers, file.hashes); + + // Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency) + const depIds = (file.dependencies || []) + .filter((d) => d.relationType === 3 && d.modId) + .map((d) => d.modId); + + return { + ok: true, + dependencies: depIds.map((id) => ({ projectId: 'cf-' + id, required: true })), + entry: { + projectId: 'cf-' + modId, + title: title || file.filename, + filename: file.filename, + versionId: String(file.fileId), + versionNumber: file.versionNumber, + gameVersion, + loader, + source: 'curseforge', + bytes, + }, + }; +} + + + +/* + * Löst die Dateien eines CurseForge-Modpacks auf. + * zugang: aus curseforgeZugang(); files: [{ projectID, fileID }] + * -> Map fileID -> { fileName, url, kopfzeilen } + */ +async function curseforgeResolveFiles(zugang, files, onProgress) { + if (!zugang) throw new Error('Kein Zugang zu CurseForge eingerichtet.'); + const ids = (files || []).map((f) => Number(f.fileID)).filter(Boolean); + const map = new Map(); + // Dateien, die der Autor für Drittanbieter gesperrt hat – werden nicht geladen, nur gemeldet + map.gesperrt = []; + const PAKET = 100; + + for (let i = 0; i < ids.length; i += PAKET) { + const teil = ids.slice(i, i + PAKET); + const res = await fetch(zugang.basis + '/mods/files', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen), + body: JSON.stringify({ fileIds: teil }), + }); + if (res.status === 401 || res.status === 403) { + throw new Error(zugang.modus === 'weiterleitung' + ? 'Der CurseForge-Server weist die Anfrage zurück (HTTP ' + res.status + ').' + : 'CurseForge weist den API-Key zurück (HTTP ' + res.status + ').'); + } + if (res.status === 429) throw new Error('Zu viele Anfragen – bitte einen Moment warten.'); + if (!res.ok) throw new Error('CurseForge-Abfrage fehlgeschlagen: HTTP ' + res.status); + const daten = await res.json(); + for (const d of (daten && daten.data) || []) { + if (!d.fileName) continue; + if (!d.downloadUrl) { + map.gesperrt.push({ fileID: Number(d.id), projectID: Number(d.modId) || 0, fileName: d.fileName, seite: cfProjektSeite(d.modId) }); + continue; + } + map.set(Number(d.id), { + fileName: d.fileName, + url: zugang.downloadAdresse(d.downloadUrl), + hashes: cfHashes(d), + kopfzeilen: zugang.downloadKopfzeilen, + }); + } + if (onProgress) onProgress({ aufgeloest: Math.min(i + PAKET, ids.length), gesamt: ids.length }); + } + return map; +} + +// Projekttitel nachschlagen (für Abhängigkeiten, die nur eine ID liefern) +async function modrinthProjectTitle(projectId) { + try { + const res = await fetch(`${MODRINTH}/project/${projectId}`, { headers: { 'User-Agent': UA } }); + if (!res.ok) return projectId; + const data = await res.json(); + return data.title || projectId; + } catch { return projectId; } +} + +module.exports = { + scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher, + getMcVersions, + modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle, + modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN, + curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, cfProjektSeite, curseforgeZugang, + curseforgeResolveModpackFile, +};