Files
AeroMc-Launcher/src/services.js
T

818 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/*
* 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 .../<jdk>/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 <modsDir>; 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,
};