'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 }, 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' }) { const typ = inhaltsArt(art); 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, 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, size: file.size || 0, gameVersions: v.game_versions || [], loaders: v.loaders || [], }; } async function downloadBuffer(url, extraHeaders) { const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); return Buffer.from(await res.arrayBuffer()); } // extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt async function downloadFile(url, destPath, extraHeaders) { 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()); 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' }) { const typ = inhaltsArt(art); const file = await modrinthResolveFile({ projectId, gameVersion, loader, art }); 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.` }; } const dest = path.join(modsDir, file.filename); const bytes = await downloadFile(file.url, dest); return { ok: true, dependencies: file.dependencies || [], entry: { projectId, title: title || projectId, filename: file.filename, 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 }; const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552 }; 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.', }; } const classId = CF_CLASS[art] || CF_CLASS.mod; 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, versionNumber: match.displayName || match.fileName || String(match.id), dependencies: match.dependencies || [], }; } 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); // 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), 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, };