176 lines
7.3 KiB
JavaScript
176 lines
7.3 KiB
JavaScript
'use strict';
|
||
|
||
/*
|
||
* technicdiscover.js – Technic-Modpacks direkt über die Technic-Plattform
|
||
* durchsuchen und installieren, ohne vorher manuell eine Datei herunterladen
|
||
* zu müssen. Genutzt wird dieselbe öffentliche API wie bei Prism Launcher:
|
||
*
|
||
* Suche: GET https://api.technicpack.net/search?build=multimc&q=<term>
|
||
* Beliebt: GET https://api.technicpack.net/trending?build=multimc
|
||
* Pack-Infos: GET https://api.technicpack.net/modpack/<slug>?build=multimc
|
||
* -> entweder "url" (ein fertiges ZIP) oder "solder" (Solder-Server)
|
||
* Solder: GET <solder>modpack/<slug> – Liste der Builds
|
||
* GET <solder>modpack/<slug>/<build> – Mods dieser Fassung
|
||
* { minecraft, forge, mods: [{ name, url, ... }] }
|
||
*
|
||
* "build=multimc" ist die Kennung, mit der sich auch andere Drittlauncher
|
||
* bei der Technic-Plattform melden – kein echter Minecraft-Build, sondern
|
||
* nur ein Client-Identifier, den die API erwartet.
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const forge = require('./forge');
|
||
const technicimport = require('./technicimport');
|
||
|
||
const API_BASE = 'https://api.technicpack.net/';
|
||
const API_BUILD = 'multimc';
|
||
const UA = 'AeroMC-Launcher/1.0';
|
||
|
||
async function getJson(url) {
|
||
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
return res.json();
|
||
}
|
||
|
||
async function getBuffer(url) {
|
||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
return Buffer.from(await res.arrayBuffer());
|
||
}
|
||
|
||
/*
|
||
* query leer -> beliebte Packs (trending), sonst Suchtreffer.
|
||
* -> [{ slug, name, iconUrl }]
|
||
*/
|
||
async function search(query) {
|
||
const q = String(query || '').trim();
|
||
const url = q
|
||
? `${API_BASE}search?build=${API_BUILD}&q=${encodeURIComponent(q)}`
|
||
: `${API_BASE}trending?build=${API_BUILD}`;
|
||
const data = await getJson(url);
|
||
const packs = Array.isArray(data.modpacks) ? data.modpacks : [];
|
||
return packs
|
||
.filter((p) => p && p.slug && p.slug !== 'vanilla')
|
||
.map((p) => ({ slug: p.slug, name: p.name || p.slug, iconUrl: p.iconUrl || '' }));
|
||
}
|
||
|
||
/*
|
||
* Entpackt einen ZIP-Buffer nach destDir.
|
||
* Enthält der ZIP-Inhalt bereits eine erkennbare Ordnerstruktur (mods/,
|
||
* config/, …), wird sie 1:1 übernommen. Liegen nur einzelne Dateien direkt
|
||
* im ZIP-Wurzelverzeichnis (typisch für ältere, einzeln gepackte Solder-
|
||
* Mods), landen sie stattdessen im mods-Ordner.
|
||
*/
|
||
function extractZipBuffer(buf, destDir) {
|
||
const idx = forge.readZipIndex(buf);
|
||
const namen = Object.keys(idx);
|
||
const hatOrdnerStruktur = namen.some((n) => /^(mods|config|resources|resourcepacks|scripts|structures|bin)\//i.test(n));
|
||
for (const name of namen) {
|
||
if (name.endsWith('/')) continue; // reines Verzeichnis – wird beim Schreiben automatisch angelegt
|
||
const relPfad = hatOrdnerStruktur ? name : path.posix.join('mods', name);
|
||
const zielPfad = path.join(destDir, ...relPfad.split('/'));
|
||
if (!zielPfad.startsWith(destDir + path.sep)) continue; // Pfad-Ausbruch verhindern
|
||
fs.mkdirSync(path.dirname(zielPfad), { recursive: true });
|
||
fs.writeFileSync(zielPfad, forge.readZipEntry(buf, idx[name]));
|
||
}
|
||
}
|
||
|
||
// Solder-Basis-URLs enden mal mit "/api/", mal ohne abschließenden Slash
|
||
function solderUrl(basis, pfad) {
|
||
const b = String(basis || '').replace(/\/+$/, '');
|
||
return `${b}/${pfad}`;
|
||
}
|
||
|
||
async function installSolderBuild(solderBasis, slug, gameDir, onProgress) {
|
||
const info = await getJson(solderUrl(solderBasis, 'modpack/' + encodeURIComponent(slug)));
|
||
const build = info.recommended || info.latest || (info.builds || [])[0];
|
||
if (!build) throw new Error('Keine Fassung (Build) für dieses Modpack gefunden.');
|
||
const daten = await getJson(solderUrl(solderBasis, `modpack/${encodeURIComponent(slug)}/${encodeURIComponent(build)}`));
|
||
const mods = Array.isArray(daten.mods) ? daten.mods : [];
|
||
|
||
for (let i = 0; i < mods.length; i++) {
|
||
const mod = mods[i];
|
||
if (onProgress) onProgress({ current: i + 1, total: mods.length, name: mod.name || '' });
|
||
if (!mod || !mod.url) continue;
|
||
try {
|
||
const buf = await getBuffer(mod.url);
|
||
extractZipBuffer(buf, gameDir);
|
||
} catch { /* einzelner Mod fehlgeschlagen – Rest trotzdem weiter versuchen */ }
|
||
}
|
||
|
||
let loader = 'vanilla';
|
||
const loaderVersion = daten.forge || '';
|
||
if (loaderVersion) loader = 'forge';
|
||
return { version: daten.minecraft || '', loader, loaderVersion };
|
||
}
|
||
|
||
async function installSingleZip(url, gameDir) {
|
||
const buf = await getBuffer(url);
|
||
// in einen Temp-Ordner entpacken, damit wir wie beim Ordner-Import
|
||
// (technicimport.js) Version/Loader aus bin/modpack.jar erkennen können,
|
||
// bevor die Dateien an ihren endgültigen Platz kommen
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'technic-dl-'));
|
||
try {
|
||
extractZipBuffer(buf, tmp);
|
||
let base = tmp;
|
||
if (!fs.existsSync(path.join(base, 'bin')) && !fs.existsSync(path.join(base, 'mods'))) {
|
||
// manche Packs haben einen umschließenden Ordner im ZIP
|
||
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
|
||
.find((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
|
||
if (sub) base = sub;
|
||
}
|
||
const { version, loader, loaderVersion } = technicimport.detectVersion(base);
|
||
fs.cpSync(base, gameDir, { recursive: true });
|
||
return { version, loader, loaderVersion };
|
||
} finally {
|
||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Lädt ein Modpack von der Technic-Plattform und legt daraus eine neue
|
||
* Instanz an. onProgress(info) wird bei Solder-Packs pro Mod aufgerufen.
|
||
* -> { ok, instance } oder { ok: false, message }
|
||
*/
|
||
async function installFromPlatform(store, slug, onProgress) {
|
||
let created = null;
|
||
try {
|
||
const detail = await getJson(`${API_BASE}modpack/${encodeURIComponent(slug)}?build=${API_BUILD}`);
|
||
// die Technic-API antwortet bei unbekanntem Slug mit HTTP 200 und
|
||
// {"error": "..."} statt einem Fehlerstatus – daher explizit prüfen
|
||
if (detail.error) throw new Error(detail.error);
|
||
|
||
created = store.createOrReplaceImportedInstance({
|
||
name: detail.displayName || detail.name || slug,
|
||
notes: detail.description || '',
|
||
});
|
||
const gameDir = store.gameDir(created.id);
|
||
fs.mkdirSync(gameDir, { recursive: true });
|
||
|
||
let ergebnis;
|
||
if (detail.solder) {
|
||
ergebnis = await installSolderBuild(detail.solder, slug, gameDir, onProgress);
|
||
} else if (detail.url) {
|
||
ergebnis = await installSingleZip(detail.url, gameDir);
|
||
} else {
|
||
throw new Error('Für dieses Modpack ist kein Download hinterlegt.');
|
||
}
|
||
store.updateInstance(created.id, {
|
||
minecraft: {
|
||
version: ergebnis.version || detail.minecraft || '',
|
||
loader: ergebnis.loader || 'vanilla',
|
||
loaderVersion: ergebnis.loaderVersion || '',
|
||
},
|
||
});
|
||
return { ok: true, instance: store.getInstance(created.id) };
|
||
} catch (err) {
|
||
// fehlgeschlagene Installation nicht als leere Karteileiche stehen lassen
|
||
if (created) { try { store.deleteInstance(created.id); } catch { /* egal */ } }
|
||
return { ok: false, message: 'Installation fehlgeschlagen: ' + err.message };
|
||
}
|
||
}
|
||
|
||
module.exports = { search, installFromPlatform };
|