818 lines
32 KiB
JavaScript
818 lines
32 KiB
JavaScript
'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,
|
||
};
|