diff --git a/package-lock.json b/package-lock.json index fa94c49..5e2b9a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aeromc", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aeromc", - "version": "1.0.5", + "version": "1.0.6", "license": "MIT", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index 2e10e05..a3f3bd9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aeromc", "productName": "AeroMC", - "version": "1.0.5", + "version": "1.0.6", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "homepage": "https://m-viper.de", "main": "src/main.js", diff --git a/src/bedrock.js b/src/bedrock.js index 6939aa6..eaca930 100644 --- a/src/bedrock.js +++ b/src/bedrock.js @@ -14,7 +14,12 @@ const path = require('path'); const { execFile, spawn } = require('child_process'); const PKG_FAMILY = 'Microsoft.MinecraftUWP_8wekyb3d8bbwe'; -const APP_ID = PKG_FAMILY + '!App'; +// Der Teil nach "!" (Application Id) ist NICHT stabil – Microsoft hat ihn +// schon mindestens einmal geändert (früher "App", inzwischen "Game"). +// Deshalb zur Laufzeit über Get-StartApps auflösen statt fest zu verdrahten; +// dieser Wert dient nur als letzter Rückfallwert, falls die Auflösung +// fehlschlägt (z. B. PowerShell blockiert). +const FALLBACK_APP_ID = PKG_FAMILY + '!Game'; function comMojangDir() { return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY, @@ -49,30 +54,87 @@ async function detect() { // Start // --------------------------------------------------------------------------- +let cachedAppId = null; + +/* + * Ermittelt die aktuell gültige AUMID über Get-StartApps – das ist derselbe + * Weg, den auch das Startmenü nutzt, und bildet daher immer die Application + * Id ab, die Windows gerade tatsächlich zum Starten akzeptiert. + */ +async function resolveAppId() { + if (cachedAppId) return cachedAppId; + const out = await runPwsh( + `(Get-StartApps | Where-Object { $_.AppID -like '${PKG_FAMILY}*' } ` + + `| Select-Object -First 1 -ExpandProperty AppID)`); + cachedAppId = out || FALLBACK_APP_ID; + return cachedAppId; +} + /* * Startet Bedrock direkt. * Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen – sonst * öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments. */ -function launch() { - const target = 'shell:appsFolder\\' + APP_ID; +async function launch() { + const appId = await resolveAppId(); + const target = 'shell:appsFolder\\' + appId; + // Wichtig: Es darf pro Klick nur EINE Aktivierung tatsächlich lostreten. + // Die App-Aktivierung lässt sich nicht abbrechen – wenn wir bei einer + // Verzögerung (Xbox-Dienste, Kaltstart) automatisch einen zweiten Weg + // probieren, laufen im Zweifel beide an und Bedrock öffnet sich doppelt. + // Die anderen Wege dienen daher nur als Rückfall, falls das Spawnen + // selbst scheitert (Programm nicht gefunden o. Ä.), nicht als + // Reaktion auf einen bloß langsamen Start. const versuche = [ - // 1) genau der Aufruf, der sich unter Windows als zuverlässig erwiesen hat + // 1) explorer direkt, ohne zusätzliche Anführungszeichen – entspricht + // exakt dem manuellen "explorer.exe shell:appsFolder\...", der + // nachweislich zuverlässig funktioniert + ['explorer.exe', [target], { windowsVerbatimArguments: true }], + // 2) über die Eingabeaufforderung + ['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }], + // 3) Rückfallweg über eine versteckte PowerShell – aus einem + // windowless/detached Kindprozess heraus offenbar nicht überall + // zuverlässig, daher nur letzter Versuch ['powershell', ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', `Start-Process explorer.exe -ArgumentList '${target}'`], {}], - // 2) explorer direkt, ohne zusätzliche Anführungszeichen - ['explorer.exe', [target], { windowsVerbatimArguments: true }], - // 3) über die Eingabeaufforderung - ['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }], ]; + let methode = null; for (const [cmd, args, extra] of versuche) { try { const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra)); p.unref(); - return { ok: true, method: cmd }; - } catch { /* nächsten Weg versuchen */ } + methode = cmd; + break; // nur diesen einen Weg tatsächlich auslösen + } catch { /* Spawnen selbst fehlgeschlagen – nächsten Weg versuchen */ } + } + if (!methode) return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' }; + // Aktivierung kann dauern (v. a. beim ersten Start nach längerer Pause) – + // hier nur zur ehrlichen Rückmeldung abwarten, OHNE bei Zeitüberschreitung + // einen weiteren Versuch zu starten (siehe Hinweis oben). + const bestaetigt = await waitForBedrockProcess(15000); + return { ok: true, method: methode, confirmed: bestaetigt }; +} + +function isBedrockRunning() { + return new Promise((resolve) => { + execFile('tasklist', ['/FI', 'IMAGENAME eq Minecraft.Windows.exe', '/FO', 'CSV', '/NH'], + { windowsHide: true, timeout: 5000 }, + (err, stdout) => resolve(!err && /Minecraft\.Windows\.exe/i.test(String(stdout || '')))); + }); +} + +function warten(ms) { + return new Promise((resolve) => { setTimeout(resolve, ms); }); +} + +async function waitForBedrockProcess(timeoutMs) { + const start = Date.now(); + // sofort prüfen (falls schon ein Fenster lief) und danach im Kurztakt + for (;;) { + if (await isBedrockRunning()) return true; + if (Date.now() - start >= timeoutMs) return false; + await warten(500); } - return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' }; } // --------------------------------------------------------------------------- diff --git a/src/index.html b/src/index.html index df9d50b..fd7cd5d 100644 --- a/src/index.html +++ b/src/index.html @@ -10,7 +10,7 @@
- + @@ -19,7 +19,7 @@
- +
@@ -61,7 +61,7 @@
- + @@ -171,7 +171,11 @@ Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist. + +
+
Benachrichtigungen
+ + Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist. +
@@ -294,6 +306,14 @@ + + @@ -659,6 +679,7 @@ + + diff --git a/src/main.js b/src/main.js index 19da8ac..2cad665 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ * (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.) */ -const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -25,10 +25,13 @@ const backup = require('./backup'); const mmcimport = require('./mmcimport'); const cfimport = require('./cfimport'); const mcimport = require('./mcimport'); +const technicimport = require('./technicimport'); +const technicdiscover = require('./technicdiscover'); const javadl = require('./javadl'); const crashreports = require('./crashreports'); const skins = require('./skins'); const servers = require('./servers'); +const mcping = require('./mcping'); // undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher const undici = require('undici'); @@ -217,10 +220,18 @@ app.whenReady().then(() => { setTimeout(async () => { try { const info = await updater.checkForUpdate(app.getVersion()); - if (info.available && mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update:available', info); + if (mainWindow && !mainWindow.isDestroyed()) { + // Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen + // kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt + mainWindow.webContents.send('update:checked', info); + if (info.available) mainWindow.webContents.send('update:available', info); } - } catch { /* offline o. Ä. – still ignorieren */ } + } catch (err) { + // offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) }); + } + } }, 4000); // Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten) @@ -348,6 +359,10 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => { if (!isDep && !firstError) firstError = res.message; return; } + // merken, welche Pflicht-Abhängigkeiten dieser Mod mitbringt – damit + // wir beim Entfernen warnen können, wenn ein anderer Mod ihn braucht + res.entry.requiredDeps = (res.dependencies || []) + .filter((d) => d.required && d.projectId).map((d) => d.projectId); store.addMod(id, res.entry, art || 'mod'); added.push(res.entry); for (const dep of res.dependencies || []) { @@ -363,6 +378,8 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => { if (!isDep && !firstError) firstError = res.message; return; } + res.entry.requiredDeps = (res.dependencies || []) + .filter((d) => d.dependency_type === 'required' && d.project_id).map((d) => d.project_id); store.addMod(id, res.entry, art || 'mod'); added.push(res.entry); for (const dep of res.dependencies || []) { @@ -382,6 +399,52 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => { return { ok: true, entry: added[0], added, depCount: added.length - 1 }; }); +/* + * Lokale Datei(en) direkt in den Inhaltsordner kopieren – für Mods, die + * nicht über Modrinth/CurseForge zu finden sind (Dev-Build, privater Mod, + * per Hand zugeschickt). Die Datei taucht danach ganz normal in der + * "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt. + */ +ipcMain.handle('mods:addFile', async (_e, id, art) => { + const inst = store.getInstance(id); + if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; + const typ = services.inhaltsArt(art || 'mod'); + const filters = (art || 'mod') === 'mod' + ? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }] + : [{ name: typ.label || 'Datei', extensions: ['zip'] }]; + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Datei hinzufügen', + properties: ['openFile', 'multiSelections'], + filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]), + }); + if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true }; + + const dir = store.inhaltsDir(id, art || 'mod'); + fs.mkdirSync(dir, { recursive: true }); + const added = []; + const failed = []; + for (const src of res.filePaths) { + try { + if (!/\.(jar|zip)$/i.test(src)) { + failed.push({ name: path.basename(src), message: 'Nur .jar/.zip-Dateien werden unterstützt.' }); + continue; + } + const name = path.basename(src); + const ext = path.extname(name); + const base = name.slice(0, -ext.length); + let dest = path.join(dir, name); + // Namenskollision -> durchnummerieren statt eine vorhandene Datei zu überschreiben + let n = 2; + while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext}`); n++; } + fs.copyFileSync(src, dest); + added.push(path.basename(dest)); + } catch (err) { + failed.push({ name: path.basename(src), message: err.message }); + } + } + return { ok: true, added, failed }; +}); + // verfügbare Loader-Versionen (Fabric/Quilt) für eine MC-Version ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => { try { return await loaders.listLoaderVersions(loader, mcVersion); } @@ -389,6 +452,8 @@ ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => { }); ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod')); +// prüft, ob andere installierte Mods diesen hier als Pflicht-Abhängigkeit brauchen +ipcMain.handle('mods:dependents', (_e, id, filename, art) => store.findDependents(id, filename, art || 'mod')); // Vorhandener Arbeitsspeicher – Grundlage für die Prüfung der RAM-Angaben function gesamtSpeicherMb() { @@ -432,6 +497,18 @@ ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => { return servers.benenneUm(store.gameDir(id), ip, neuerName); }); +// Live-Status eines Servers abfragen (MOTD/Spielerzahl/Version) – wie der +// Vanilla-Mehrspieler-Bildschirm, ohne sich wirklich zu verbinden +ipcMain.handle('servers:ping', async (_e, ip) => { + const s = String(ip || '').trim(); + const m = s.match(/^([^:]+)(?::(\d+))?$/); + if (!m) return { ok: false, message: 'Ungültige Adresse.' }; + const host = m[1]; + const port = m[2] ? Number(m[2]) : 25565; + try { return await mcping.pingServer(host, port); } + catch (err) { return { ok: false, message: err.message }; } +}); + // denselben Server in weitere Instanzen übertragen ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => { const erledigt = []; @@ -737,6 +814,36 @@ ipcMain.handle('instances:export', async (_e, id) => { } }); +/* + * Schnellsicherung EINER Welt als ZIP – unabhängig vom globalen + * Backup-System, landet in einem eigenen Unterordner der Dokumente. + */ +ipcMain.handle('world:backup', async (_e, id, worldName) => { + const inst = store.getInstance(id); + if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; + const worldDir = path.join(store.gameDir(id), 'saves', String(worldName || '')); + if (path.dirname(worldDir) !== path.join(store.gameDir(id), 'saves') || !fs.existsSync(worldDir)) { + return { ok: false, message: 'Welt nicht gefunden.' }; + } + + try { + const zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen', + (inst.name || 'Instanz').replace(/[^\w.-]+/g, '_')); + fs.mkdirSync(zielOrdner, { recursive: true }); + const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16); + const safeWorld = String(worldName).replace(/[^\w.-]+/g, '_'); + const zielDatei = path.join(zielOrdner, `${safeWorld}_${zeitstempel}.zip`); + if (fs.existsSync(zielDatei)) fs.unlinkSync(zielDatei); + + await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force", + { VN_SRC: worldDir, VN_DST: zielDatei }); + notify('Welt gesichert', `${worldName} (${inst.name}) wurde gesichert.`); + return { ok: true, path: zielDatei }; + } catch (err) { + return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message }; + } +}); + // ---- Modpack-Import (Modrinth .mrpack) ---- async function importMrpack(buf, zip) { @@ -947,6 +1054,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => { }), }); + notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`); return { ok: true, neueNummer: version.version_number, @@ -1091,12 +1199,47 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => { store.updateInstance(inst.id, { modpack: mp }); result.instance = store.getInstance(inst.id); } + if (result && result.ok && result.instance) { + notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`); + } return result; } catch (err) { return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message }; } }); +/* + * Kern des Modpack-Datei-Imports, unabhängig davon ob der Pfad aus einem + * Dateidialog oder per Drag&Drop aufs Fenster kommt. + * notModpack: true heißt "kein Modpack-Format erkannt" – der Aufrufer kann + * dann noch andere ZIP-Interpretationen probieren (z. B. Instanz-Export). + */ +async function importModpackFromPath(filePath) { + try { + const buf = fs.readFileSync(filePath); + const zip = forge.readZipIndex(buf); + let result = null; + if (zip['modrinth.index.json']) { + result = await importMrpack(buf, zip); + } else if (zip['manifest.json']) { + const zugang = services.curseforgeZugang(store.getSettings()); + if (!zugang) { + return { ok: false, needsKey: true, + message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' }; + } + result = await importCurseForge(buf, zip, zugang); + } else { + return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' }; + } + if (result && result.ok && result.instance) { + notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`); + } + return result; + } catch (err) { + return { ok: false, notModpack: true, message: 'Modpack-Import fehlgeschlagen: ' + err.message }; + } +} + ipcMain.handle('modpack:import', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'Modpack importieren', @@ -1104,22 +1247,7 @@ ipcMain.handle('modpack:import', async () => { filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }], }); if (res.canceled) return { ok: false, canceled: true }; - try { - const buf = fs.readFileSync(res.filePaths[0]); - const zip = forge.readZipIndex(buf); - if (zip['modrinth.index.json']) return await importMrpack(buf, zip); - if (zip['manifest.json']) { - const zugang = services.curseforgeZugang(store.getSettings()); - if (!zugang) { - return { ok: false, needsKey: true, - message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' }; - } - return await importCurseForge(buf, zip, zugang); - } - return { ok: false, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' }; - } catch (err) { - return { ok: false, message: 'Modpack-Import fehlgeschlagen: ' + err.message }; - } + return importModpackFromPath(res.filePaths[0]); }); // Verknüpfung auf dem Desktop, die genau diese Instanz startet @@ -1150,18 +1278,17 @@ ipcMain.handle('instances:shortcut', (_e, id) => { } }); -ipcMain.handle('instances:import', async () => { - const res = await dialog.showOpenDialog(mainWindow, { - title: 'Instanz importieren', - properties: ['openFile'], - filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }], - }); - if (res.canceled) return { ok: false, canceled: true }; +/* + * Kern des ZIP-Instanz-Imports (AeroMC-Export oder einzelne MultiMC/Prism- + * Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per + * Drag&Drop aufs Fenster kommt. + */ +async function importInstanceZipFromPath(filePath) { const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now()); try { fs.mkdirSync(tmp, { recursive: true }); await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force', - { VN_SRC: res.filePaths[0], VN_DST: tmp }); + { VN_SRC: filePath, VN_DST: tmp }); // instance.json finden (auch in Unterordner) let base = tmp; @@ -1203,8 +1330,88 @@ ipcMain.handle('instances:import', async () => { } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ } } +} + +ipcMain.handle('instances:import', async () => { + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Instanz importieren', + properties: ['openFile'], + filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }], + }); + if (res.canceled) return { ok: false, canceled: true }; + return importInstanceZipFromPath(res.filePaths[0]); }); +/* + * Datei per Drag&Drop aufs Fenster gezogen. Anhand der Endung/des Inhalts + * wird entschieden, was damit passieren soll: + * .jar -> als Mod/Ressourcenpaket/Shader in die übergebene + * Instanz kopieren (targetId/targetArt vom Renderer, + * je nachdem was gerade offen bzw. ausgewählt ist) + * .mrpack / .zip -> zuerst als Modpack versuchen (Modrinth/CurseForge), + * sonst als AeroMC-/MultiMC-Instanz-Export + */ +ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => { + if (!filePath || !fs.existsSync(filePath)) return { ok: false, message: 'Datei nicht gefunden.' }; + const ext = path.extname(filePath).toLowerCase(); + + if (ext === '.jar') { + if (!targetId) { + return { ok: false, message: 'Bitte zuerst eine Instanz öffnen (Mods-Dialog), dann die Datei dort hineinziehen.' }; + } + const inst = store.getInstance(targetId); + if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; + const art = targetArt || 'mod'; + const dir = store.inhaltsDir(targetId, art); + fs.mkdirSync(dir, { recursive: true }); + try { + const name = path.basename(filePath); + const ext2 = path.extname(name); + const base = name.slice(0, -ext2.length); + let dest = path.join(dir, name); + let n = 2; + while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext2}`); n++; } + fs.copyFileSync(filePath, dest); + return { ok: true, kind: 'mod', added: [path.basename(dest)] }; + } catch (err) { + return { ok: false, message: err.message }; + } + } + + if (ext === '.mrpack' || ext === '.zip') { + const modpack = await importModpackFromPath(filePath); + // .mrpack ist immer ein Modpack – bei Fehlschlag direkt die Meldung + // zeigen, statt es zusätzlich noch als Instanz-Export zu probieren + if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack); + // .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen + const instanceRes = await importInstanceZipFromPath(filePath); + return Object.assign({ kind: 'instance' }, instanceRes); + } + + return { ok: false, message: 'Nicht unterstützter Dateityp: ' + (ext || '(ohne Endung)') }; +}); + +/* + * Windows-Benachrichtigung für abgeschlossene Hintergrund-Aktionen (Backup, + * größere Downloads) – nur wenn die Einstellung an ist UND das Fenster + * gerade nicht im Vordergrund ist (sonst sieht man's eh schon live). + */ +function notify(title, body) { + try { + if (store.getSettings().desktopNotifications === false) return; + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return; + if (!Notification.isSupported()) return; + const n = new Notification({ title, body, silent: false }); + n.on('click', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); + n.show(); + } catch { /* rein informativ – darf nie einen Ablauf stören */ } +} + // --------------------------------------------------------------------------- // Sicherungen: von Hand und nach Zeitplan // --------------------------------------------------------------------------- @@ -1232,6 +1439,7 @@ async function erstelleBackup({ still } = {}) { if (!still && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' }); } + if (result && result.ok) notify('Backup abgeschlossen', 'Die Sicherung aller Instanzen ist fertig.'); return result; } catch (err) { return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message }; @@ -1590,6 +1798,54 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => { return { ok: true, imported: done, failed }; }); +ipcMain.handle('technic:pickFolder', async () => { + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)', + properties: ['openDirectory'], + }); + return res.canceled ? null : res.filePaths[0]; +}); + +ipcMain.handle('technic:scan', (_e, folder) => { + try { return technicimport.scan(folder); } + catch (err) { return { ok: false, reason: err.message }; } +}); + +ipcMain.handle('technic:import', (_e, entries, copyData) => { + const done = []; + const failed = []; + for (const entry of entries || []) { + try { + const imported = technicimport.importOne(store, entry, copyData !== false); + done.push(imported); + } + catch (err) { failed.push({ name: entry.name, message: err.message }); } + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length }); + } + } + return { ok: true, imported: done, failed }; +}); + +// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ---- + +ipcMain.handle('technicPlatform:search', async (_e, query) => { + try { return { ok: true, hits: await technicdiscover.search(query) }; } + catch (err) { return { ok: false, message: err.message }; } +}); + +ipcMain.handle('technicPlatform:install', async (_e, slug) => { + try { + const res = await technicdiscover.installFromPlatform(store, slug, (p) => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p); + }); + if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`); + return res; + } catch (err) { + return { ok: false, message: err.message }; + } +}); + // --------------------------------------------------------------------------- // IPC – Minecraft Bedrock (Windows-Store-App) // --------------------------------------------------------------------------- diff --git a/src/mcping.js b/src/mcping.js new file mode 100644 index 0000000..55fbb10 --- /dev/null +++ b/src/mcping.js @@ -0,0 +1,150 @@ +'use strict'; + +/* + * mcping.js – "Server List Ping" (SLP), das Protokoll, das auch der + * Vanilla-Mehrspieler-Bildschirm nutzt, um MOTD/Spielerzahl/Version + * abzufragen, ohne sich wirklich zu verbinden. + * + * Ablauf (siehe minecraft.wiki/w/Server_List_Ping): + * 1) Handshake-Paket senden (Protokollversion, Adresse, Port, next state=1) + * 2) Status-Request-Paket senden (leer) + * 3) Status-Response-Paket empfangen: enthält einen JSON-String + * Alle Pakete sind mit ihrer Länge als VarInt vorangestellt. + */ + +const net = require('net'); + +function encodeVarInt(value) { + const bytes = []; + let v = value; + do { + let temp = v & 0b01111111; + v = v >>> 7; + if (v !== 0) temp |= 0b10000000; + bytes.push(temp); + } while (v !== 0); + return Buffer.from(bytes); +} + +function encodeString(str) { + const strBuf = Buffer.from(str, 'utf8'); + return Buffer.concat([encodeVarInt(strBuf.length), strBuf]); +} + +function encodeUShort(n) { + const b = Buffer.alloc(2); + b.writeUInt16BE(n & 0xffff, 0); + return b; +} + +// liest einen VarInt aus buf ab offset -> { value, next } oder null, +// wenn noch nicht genug Bytes angekommen sind +function decodeVarInt(buf, offset) { + let numRead = 0; + let result = 0; + let read; + do { + if (offset + numRead >= buf.length) return null; + read = buf[offset + numRead]; + result |= (read & 0b01111111) << (7 * numRead); + numRead++; + if (numRead > 5) throw new Error('VarInt zu groß'); + } while ((read & 0b10000000) !== 0); + return { value: result >>> 0, next: offset + numRead }; +} + +// "description" kann ein reiner String oder eine Chat-Komponente +// ({text, extra:[...]}) sein – hier auf reinen Text reduzieren +function extractMotdText(desc) { + if (!desc) return ''; + if (typeof desc === 'string') return desc; + let text = String(desc.text || ''); + if (Array.isArray(desc.extra)) { + for (const part of desc.extra) text += extractMotdText(part); + } + return text; +} + +// Formatierungscodes (§a, §l, …) sind fürs Spiel gedacht, nicht für unsere Liste +function stripFormatting(text) { + return String(text || '').replace(/§./g, '').replace(/\s+/g, ' ').trim(); +} + +/* + * Fragt einen Server ab. + * -> { ok, motd, version, protocol, online, max, sample, favicon, latencyMs } + * oder { ok: false, message } + */ +function pingServer(host, port = 25565, timeoutMs = 4000) { + return new Promise((resolve) => { + const start = Date.now(); + const socket = new net.Socket(); + let buffer = Buffer.alloc(0); + let finished = false; + + const done = (result) => { + if (finished) return; + finished = true; + try { socket.destroy(); } catch { /* egal */ } + resolve(result); + }; + + socket.setTimeout(timeoutMs); + socket.on('timeout', () => done({ ok: false, message: 'Zeitüberschreitung.' })); + socket.on('error', (err) => done({ ok: false, message: err.code === 'ENOTFOUND' ? 'Adresse nicht gefunden.' : err.message })); + + socket.connect(port, host, () => { + const handshakeBody = Buffer.concat([ + encodeVarInt(0x00), + encodeVarInt(-1), // Protokollversion "unbekannt" – für den Status-Abfrage-Zweck egal + encodeString(host), + encodeUShort(port), + encodeVarInt(1), // next state: 1 = status + ]); + const handshakePacket = Buffer.concat([encodeVarInt(handshakeBody.length), handshakeBody]); + + const requestBody = encodeVarInt(0x00); + const requestPacket = Buffer.concat([encodeVarInt(requestBody.length), requestBody]); + + socket.write(Buffer.concat([handshakePacket, requestPacket])); + }); + + socket.on('data', (chunk) => { + if (finished) return; + buffer = Buffer.concat([buffer, chunk]); + try { + const lenInfo = decodeVarInt(buffer, 0); + if (!lenInfo) return; // Länge noch nicht vollständig angekommen + const packetEnd = lenInfo.next + lenInfo.value; + if (buffer.length < packetEnd) return; // Paket noch nicht vollständig da + + const idInfo = decodeVarInt(buffer, lenInfo.next); + if (!idInfo) return; + const jsonLenInfo = decodeVarInt(buffer, idInfo.next); + if (!jsonLenInfo) return; + const jsonStart = jsonLenInfo.next; + const jsonEnd = jsonStart + jsonLenInfo.value; + if (buffer.length < jsonEnd) return; + + const data = JSON.parse(buffer.toString('utf8', jsonStart, jsonEnd)); + done({ + ok: true, + latencyMs: Date.now() - start, + motd: stripFormatting(extractMotdText(data.description)), + version: (data.version && data.version.name) || '', + protocol: data.version ? data.version.protocol : null, + online: data.players ? data.players.online : null, + max: data.players ? data.players.max : null, + sample: (data.players && Array.isArray(data.players.sample)) + ? data.players.sample.map((p) => String(p.name || '')).filter(Boolean) + : [], + favicon: typeof data.favicon === 'string' ? data.favicon : '', + }); + } catch (err) { + done({ ok: false, message: 'Ungültige Antwort: ' + err.message }); + } + }); + }); +} + +module.exports = { pingServer }; diff --git a/src/preload.js b/src/preload.js index 2efe83a..d13abed 100644 --- a/src/preload.js +++ b/src/preload.js @@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld('api', { modList: (id, art) => ipcRenderer.invoke('mods:list', id, art), modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art), modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art), + modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art), + modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art), modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art), systemMemory: () => ipcRenderer.invoke('system:memory'), checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb), @@ -42,6 +44,7 @@ contextBridge.exposeInMainWorld('api', { serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip), serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName), serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag), + serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip), logsList: (id) => ipcRenderer.invoke('logs:list', id), logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name), logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id), @@ -68,6 +71,7 @@ contextBridge.exposeInMainWorld('api', { resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id), datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id), deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name), + backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName), openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub), screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id), screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name), @@ -91,6 +95,8 @@ contextBridge.exposeInMainWorld('api', { return () => ipcRenderer.removeListener('instance:launch-request', l); }, importModpack: () => ipcRenderer.invoke('modpack:import'), + // Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip) + filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt), modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts), modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project), modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id), @@ -169,6 +175,21 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('mc:progress', l); return () => ipcRenderer.removeListener('mc:progress', l); }, + technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'), + technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder), + technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData), + onTechnicProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('technic:progress', l); + return () => ipcRenderer.removeListener('technic:progress', l); + }, + technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query), + technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug), + onTechnicPlatformProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('technicPlatform:progress', l); + return () => ipcRenderer.removeListener('technicPlatform:progress', l); + }, // Bedrock (Windows-Store-Ausgabe) bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'), @@ -192,6 +213,14 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('update:available', l); return () => ipcRenderer.removeListener('update:available', l); }, + // Ergebnis des automatischen Checks beim Start – auch wenn kein Update + // verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei + // verfügbarem Update feuert) + onUpdateChecked: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('update:checked', l); + return () => ipcRenderer.removeListener('update:checked', l); + }, onUpdateProgress: (cb) => { const l = (_e, d) => cb(d); ipcRenderer.on('update:progress', l); diff --git a/src/renderer.js b/src/renderer.js index b864f24..6dcc8b2 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -470,6 +470,17 @@ const LOADER_LABEL = { neoforge: 'NeoForge', quilt: 'Quilt', }; +// Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren +// bei größeren Modpacks spürbar GC-bedingte Ruckler. Wirkt erst ab genug RAM +// sinnvoll, schadet aber auch bei wenig RAM nicht. +const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 ' + + '-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch ' + + '-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M ' + + '-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 ' + + '-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 ' + + '-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem ' + + '-XX:MaxTenuringThreshold=1'; + // ---------- Zustand ---------- let instances = []; let currentDetailId = null; @@ -528,6 +539,7 @@ function applyTheme(theme) { let mmcFound = []; let cfFound = []; let mcFound = []; +let technicFound = []; let importKind = 'zip'; async function ensureDefaultMcScan() { @@ -538,8 +550,8 @@ async function ensureDefaultMcScan() { } function setImportKind(kind) { - importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc'].includes(kind) ? kind : 'zip'; - ['zip', 'modpack', 'mc', 'cf', 'mmc'].forEach((entryKind) => { + importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip'; + ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => { el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind); el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind); }); @@ -548,9 +560,11 @@ function setImportKind(kind) { el('mc-do').classList.toggle('hidden', importKind !== 'mc'); el('cf-do').classList.toggle('hidden', importKind !== 'cf'); el('mmc-do').classList.toggle('hidden', importKind !== 'mmc'); + el('technic-do').classList.toggle('hidden', importKind !== 'technic'); el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent); el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent); el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent); + el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent); } function openImportDialog(kind = 'zip') { @@ -923,6 +937,132 @@ async function runMmcImport() { if (instances.length) await selectInstance(instances[0].id); } +// ---------- Technic-Plattform: Modpack-Katalog ---------- + +async function searchTechnicPlatform() { + const query = el('tp-query').value.trim(); + const box = el('tp-results'); + const st = el('tp-status'); + st.textContent = 'Suche …'; + box.innerHTML = '
Suche läuft …
'; + const res = await window.api.technicPlatformSearch(query); + if (!res.ok) { + st.textContent = 'Fehler: ' + (res.message || 'Suche fehlgeschlagen.'); + box.innerHTML = '
Suche fehlgeschlagen.
'; + return; + } + const hits = res.hits || []; + st.textContent = hits.length ? `${hits.length} Treffer` : 'Keine Treffer.'; + box.innerHTML = ''; + if (!hits.length) { box.innerHTML = '
Keine Modpacks gefunden.
'; return; } + for (const h of hits) { + const item = document.createElement('div'); + item.className = 'mod-item'; + item.innerHTML = + `
${h.iconUrl ? `` : '📦'}
+
${escapeHtml(h.name)}
+
`; + const btn = document.createElement('button'); + btn.className = 'btn btn-primary btn-mini'; + btn.textContent = 'Installieren'; + btn.addEventListener('click', () => installTechnicFromSearch(h, btn)); + item.querySelector('.mod-action').appendChild(btn); + box.appendChild(item); + } +} + +async function installTechnicFromSearch(hit, btn) { + if (!hit || !hit.slug) return; + const prev = btn ? btn.textContent : ''; + if (btn) { btn.disabled = true; btn.textContent = '…'; } + const st = el('tp-status'); + st.textContent = `Installiere „${hit.name}" …`; + try { + const res = await window.api.technicPlatformInstall(hit.slug); + if (res.ok) { + await reload(); + hide('modal-import'); + toast(`Modpack installiert: ${res.instance.name}`); + await selectInstance(res.instance.id); + } else { + st.textContent = '✖ ' + (res.message || 'Installation fehlgeschlagen.'); + toast(res.message || 'Installation fehlgeschlagen.', true); + } + } finally { + if (btn) { btn.disabled = false; btn.textContent = prev; } + } +} + +async function scanTechnic() { + const folder = el('technic-path').value.trim(); + el('technic-status').textContent = 'Suche Modpacks …'; + const res = await window.api.technicScan(folder); + if (!res.ok) { + el('technic-status').textContent = '✖ Dort wurde kein modpacks-Ordner gefunden.'; + el('technic-result').classList.add('hidden'); + el('technic-do').disabled = true; + return; + } + technicFound = res.instances; + if (!folder && res.instancesDir) el('technic-path').value = res.instancesDir; + el('technic-status').textContent = `${res.count} Modpack${res.count === 1 ? '' : 's'} gefunden.`; + el('technic-result').classList.remove('hidden'); + el('technic-all').checked = true; + renderTechnicList(); +} + +function renderTechnicList() { + const box = el('technic-list'); + box.innerHTML = ''; + if (!technicFound.length) { box.innerHTML = '
Keine Modpacks gefunden.
'; return; } + technicFound.forEach((inst, idx) => { + const row = document.createElement('label'); + row.className = 'mmc-item'; + const loaderTxt = inst.loader === 'vanilla' + ? 'Vanilla' + : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); + const meta = [inst.version || 'Version unbekannt', loaderTxt] + .filter(Boolean).join(' · '); + row.innerHTML = + ` +
+
${escapeHtml(inst.name)}
+
${escapeHtml(meta)}
+
`; + box.appendChild(row); + }); + updateTechnicButton(); +} + +function selectedTechnic() { + return [...document.querySelectorAll('#technic-list input[type=checkbox]')] + .filter((c) => c.checked) + .map((c) => technicFound[Number(c.dataset.idx)]); +} + +function updateTechnicButton() { + const n = selectedTechnic().length; + el('technic-do').disabled = n === 0; + el('technic-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; +} + +async function runTechnicImport() { + const entries = selectedTechnic(); + if (!entries.length) return; + const copy = el('technic-copy').checked; + el('technic-do').disabled = true; + el('technic-progress').textContent = 'Übernehme …'; + el('technic-progress').classList.remove('hidden'); + + const res = await window.api.technicImport(entries, copy); + await reload(); + hide('modal-import'); + const n = res.imported.length; + const f = res.failed.length; + toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); + if (instances.length) await selectInstance(instances[0].id); +} + // ---------- Bedrock (Windows-Store-Ausgabe) ---------- let bedrockKind = 'worlds'; @@ -1121,6 +1261,27 @@ function onUpdateProgress(p) { } } +// Aktualisiert die Statuszeile im Info-Bereich – genutzt sowohl vom manuellen +// "Nach Updates suchen"-Knopf als auch vom automatischen Check beim Start. +function renderUpdateStatus(info) { + const st = el('s-update-status'); + if (!st) return; + st.classList.remove('status-ok', 'status-bad'); + if (info.checking) { + st.textContent = 'Suche nach Updates …'; + } else if (info.error) { + st.classList.add('status-bad'); + st.textContent = '✖ ' + info.error; + } else if (info.noReleases) { + st.textContent = 'Noch keine Veröffentlichung vorhanden.'; + } else if (info.available) { + st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`; + } else { + st.classList.add('status-ok'); + st.textContent = `✓ Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`; + } +} + // ---------- Zustand der Anmelde-Dienste in der Statusleiste ---------- function showAuthHealth(h) { const box = el('status-auth'); @@ -1135,11 +1296,15 @@ function showAuthHealth(h) { const AUTH_SERVICE_COUNT = 3; // ---------- Skin-Hintergrund (wie MultiMCs Katze, nur mit eigenem Skin) ---------- -async function applySkinBackground() { +// uuidOverride: wird vom Konten-Wechsler (siehe unten) genutzt, um gezielt +// einen bestimmten Skin zu zeigen, ohne die eigentliche Einstellung anzufassen +async function applySkinBackground(uuidOverride) { const s = await window.api.getSettings(); const root = document.documentElement; let uuid = ''; - if (s.bgSkin === 'active') { + if (uuidOverride) { + uuid = uuidOverride; + } else if (s.bgSkin === 'active') { const acc = await window.api.authStatus(); uuid = acc && acc.profile ? acc.profile.id : ''; } else if (s.bgSkin) { @@ -1172,6 +1337,44 @@ async function applySkinBackground() { root.style.setProperty('--bg-skin', 'none'); } +// ---------- Skin-Hintergrund: automatischer Kontenwechsel ---------- +let skinCycleTimer = null; +let skinCycleIndex = 0; + +function stopSkinCycle() { + if (skinCycleTimer) { clearInterval(skinCycleTimer); skinCycleTimer = null; } +} + +async function skinCycleTick() { + const accs = await window.api.authAccounts(); + if (accs.length < 2) { stopSkinCycle(); await applySkinBackground(); return; } + skinCycleIndex = (skinCycleIndex + 1) % accs.length; // am Ende wieder von vorn + await applySkinBackground(accs[skinCycleIndex].id); +} + +/* + * Einstiegspunkt für alles, was den Skin-Hintergrund neu anzeigen soll. + * Ersetzt die frühere direkte applySkinBackground()-Aufrufe: kümmert sich + * zusätzlich darum, den Kontenwechsel-Timer je nach Einstellung zu + * starten/stoppen, statt dass jede Aufrufstelle das selbst tun müsste. + */ +async function refreshSkinBackground() { + stopSkinCycle(); + const s = await window.api.getSettings(); + if (s.bgSkinCycle) { + const accs = await window.api.authAccounts(); + if (accs.length >= 2) { + skinCycleIndex = 0; + await applySkinBackground(accs[0].id); + const seconds = Math.max(5, Number(s.bgSkinCycleSeconds) || 30); + skinCycleTimer = setInterval(skinCycleTick, seconds * 1000); + return; + } + // weniger als 2 Konten angemeldet -> normal weiter wie ohne Wechsel + } + await applySkinBackground(); +} + // Pose-Vorlagen für den lokalen 3D-Renderer (eigene Nachbildungen bekannter Motive) const SKIN_POSES = { walking: { label: 'Laufend', fov: 36, zoom: 0.62, apply: (p) => { @@ -1936,9 +2139,11 @@ async function renderServers() {
${escapeHtml(s.name || s.ip)}
${escapeHtml(s.ip)}
+
Wird geprüft …
`; const aktionen = item.querySelector('.mod-action'); + pingServerRow(s.ip, item.querySelector('.srv-status')); const starten = document.createElement('button'); starten.className = 'btn btn-primary btn-mini'; @@ -1992,6 +2197,24 @@ async function renderServers() { } } +// Fragt Live-Status (MOTD/Spieler/Version) ab und trägt ihn in die Zeile ein. +// Läuft unabhängig für jede Zeile, ohne die Liste selbst zu blockieren. +async function pingServerRow(ip, statusEl) { + if (!statusEl) return; + const res = await window.api.serversPing(ip); + // Zeile kann inzwischen aus dem DOM entfernt worden sein (Liste neu aufgebaut) + if (!statusEl.isConnected) return; + if (!res.ok) { + statusEl.textContent = '⚠ ' + (res.message || 'nicht erreichbar'); + statusEl.classList.add('status-bad'); + return; + } + const spieler = res.max != null ? `${res.online ?? '?'}/${res.max} Spieler` : ''; + statusEl.textContent = [res.motd, spieler, res.version, res.latencyMs + ' ms'] + .filter(Boolean).join(' · '); + statusEl.classList.add('status-ok'); +} + async function addServer() { const eintrag = { name: el('srv-name').value.trim(), ip: el('srv-ip').value.trim() }; const res = await window.api.serversAdd(currentDetailId, eintrag); @@ -2130,7 +2353,7 @@ async function setzeSkin() { el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.'; toast('Skin geändert.'); await openSkin(); // Vorschau mit dem neuen Stand auffrischen - await applySkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin + await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin } else { el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); btn.disabled = false; @@ -2145,7 +2368,7 @@ async function setzeSkinZurueck() { if (res.ok) { toast('Skin auf Standard zurückgesetzt.'); await openSkin(); - await applySkinBackground(); + await refreshSkinBackground(); } else { el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); } @@ -2431,6 +2654,24 @@ function renderContentList(boxId, items, sub, emoji) { `
${emoji}
${escapeHtml(it.name)}
`; + const aktionen = item.querySelector('.mod-action'); + if (sub === 'saves') { + const sichern = document.createElement('button'); + sichern.className = 'btn btn-mini'; + sichern.textContent = 'Sichern'; + sichern.title = 'Nur diese Welt jetzt als ZIP sichern (Dokumente\\AeroMC Launcher\\Welten-Sicherungen)'; + sichern.addEventListener('click', async () => { + sichern.disabled = true; + const orig = sichern.textContent; + sichern.textContent = 'Sichere …'; + const res = await window.api.backupWorld(currentDetailId, it.name); + sichern.disabled = false; + sichern.textContent = orig; + if (res.ok) toast(`„${it.name}" gesichert.`); + else toast(res.message || 'Sicherung fehlgeschlagen.', true); + }); + aktionen.appendChild(sichern); + } const rm = document.createElement('button'); rm.className = 'btn btn-danger btn-mini'; rm.textContent = 'Löschen'; @@ -2438,7 +2679,7 @@ function renderContentList(boxId, items, sub, emoji) { 'Löschen', `„${it.name}" wird unwiderruflich gelöscht. Fortfahren?`, '🗑 Löschen', async () => { await window.api.deleteContent(currentDetailId, sub, it.name); await renderContentLists(); toast('Gelöscht: ' + it.name); }, )); - item.querySelector('.mod-action').appendChild(rm); + aktionen.appendChild(rm); box.appendChild(item); } } @@ -2586,9 +2827,24 @@ async function refreshInstalled() { btn.className = 'btn btn-danger btn-mini'; btn.textContent = 'Entfernen'; btn.addEventListener('click', async () => { - await window.api.modRemove(currentDetailId, m.filename, modArt); - toast('Entfernt: ' + m.title); - await refreshInstalled(); + const tatsaechlichEntfernen = async () => { + await window.api.modRemove(currentDetailId, m.filename, modArt); + toast('Entfernt: ' + m.title); + await refreshInstalled(); + }; + const abhaenger = await window.api.modDependents(currentDetailId, m.filename, modArt); + if (abhaenger.length) { + const namen = abhaenger.map((n) => `„${n}"`).join(', '); + askConfirm( + 'Wird noch gebraucht', + `${namen} ${abhaenger.length === 1 ? 'benötigt' : 'benötigen'} „${m.title}" als Pflicht-Abhängigkeit. ` + + 'Wird sie trotzdem entfernt, funktionieren diese Mods vermutlich nicht mehr richtig. Trotzdem entfernen?', + 'Trotzdem entfernen', + tatsaechlichEntfernen, + ); + } else { + await tatsaechlichEntfernen(); + } }); aktionen.appendChild(btn); box.appendChild(item); @@ -2791,9 +3047,13 @@ async function openSettings(pane) { } sel.value = s.bgSkin || ''; el('s-bgskin-pose').value = s.bgSkinPose || 'walking'; + el('s-bgskin-cycle').checked = !!s.bgSkinCycle; + el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30; + sel.disabled = !!s.bgSkinCycle; el('s-sortby').value = s.sortBy || 'lastPlayed'; el('s-iso3d').checked = s.iso3dIcons !== false; el('s-bedrock').checked = s.bedrockEnabled !== false; + el('s-notify').checked = s.desktopNotifications !== false; el('s-auto-java').checked = s.autoJava !== false; el('s-autobackup').checked = !!s.autoBackupEnabled; el('s-autobackup-every').value = s.autoBackupEvery || 'daily'; @@ -2908,9 +3168,12 @@ async function saveSettings() { globalPostExit: el('s-global-post').value.trim(), bgSkin: el('s-bgskin').value, bgSkinPose: el('s-bgskin-pose').value, + bgSkinCycle: el('s-bgskin-cycle').checked, + bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30), sortBy: el('s-sortby').value, iso3dIcons: el('s-iso3d').checked, bedrockEnabled: el('s-bedrock').checked, + desktopNotifications: el('s-notify').checked, autoJava: el('s-auto-java').checked, autoBackupEnabled: el('s-autobackup').checked, autoBackupEvery: el('s-autobackup-every').value, @@ -2942,7 +3205,7 @@ async function saveSettings() { applyTheme(patch.theme); iso3dEnabled = patch.iso3dIcons; await applyBedrockVisibility(); - await applySkinBackground(); + await refreshSkinBackground(); hide('modal-settings'); toast('Einstellungen gespeichert.'); await reload(); @@ -3180,7 +3443,7 @@ async function runGlobalRestore() { applyTheme(s.theme || 'dark'); iso3dEnabled = s.iso3dIcons !== false; await applyBedrockVisibility(); - await applySkinBackground(); + await refreshSkinBackground(); hide('modal-settings'); await reload(); toast('Backup wiederhergestellt: ' + res.path); @@ -3267,7 +3530,7 @@ async function renderAccounts(boxId = 'login-accounts') { await window.api.authSetActive(a.id); await refreshAllAccountLists(); await refreshAccountButton(); - await applySkinBackground(); + await refreshSkinBackground(); toast('Aktiv: ' + a.name); }); actions.appendChild(use); @@ -3392,8 +3655,9 @@ function wire() { // Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf) el('btn-skin').addEventListener('click', async () => { const s = await window.api.getSettings(); - if (s.bgSkin) { - await window.api.saveSettings({ bgSkin: '' }); + if (s.bgSkin || s.bgSkinCycle) { + // auch den Kontenwechsel mit ausschalten, sonst würde er sofort wieder anspringen + await window.api.saveSettings({ bgSkin: '', bgSkinCycle: false }); toast('Skin-Hintergrund aus.'); } else { const acc = await window.api.authStatus(); @@ -3401,7 +3665,7 @@ function wire() { await window.api.saveSettings({ bgSkin: 'active' }); toast('Skin-Hintergrund an: ' + acc.profile.name); } - await applySkinBackground(); + await refreshSkinBackground(); }); // Kontextmenü: Einträge lösen die Aktionen der rechten Leiste aus @@ -3442,6 +3706,13 @@ function wire() { searchModpacks(); } } + if (tab.dataset.kind === 'technic') { + const box = el('tp-results'); + if (box && !box.dataset.loaded) { + box.dataset.loaded = '1'; + searchTechnicPlatform(); + } + } }); }); el('import-zip-do').addEventListener('click', runZipImport); @@ -3485,6 +3756,23 @@ function wire() { }); el('mmc-list').addEventListener('change', updateMmcButton); el('mmc-do').addEventListener('click', runMmcImport); + el('tp-search-btn').addEventListener('click', searchTechnicPlatform); + el('tp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchTechnicPlatform(); }); + window.api.onTechnicPlatformProgress((p) => { + el('tp-status').textContent = `Lade Mods … ${p.current}/${p.total}${p.name ? ' (' + p.name + ')' : ''}`; + }); + el('technic-pick').addEventListener('click', async () => { + const p = await window.api.technicPickFolder(); + if (p) { el('technic-path').value = p; await scanTechnic(); } + }); + el('technic-scan').addEventListener('click', scanTechnic); + el('technic-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanTechnic(); }); + el('technic-all').addEventListener('change', (e) => { + document.querySelectorAll('#technic-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; }); + updateTechnicButton(); + }); + el('technic-list').addEventListener('change', updateTechnicButton); + el('technic-do').addEventListener('click', runTechnicImport); window.api.onMcProgress((p) => { el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('mc-progress').classList.remove('hidden'); @@ -3497,13 +3785,33 @@ function wire() { el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('mmc-progress').classList.remove('hidden'); }); + window.api.onTechnicProgress((p) => { + el('technic-progress').textContent = `Übernehme … ${p.current}/${p.total}`; + el('technic-progress').classList.remove('hidden'); + }); // Bedrock el('btn-bedrock').addEventListener('click', openBedrock); el('bd-play').addEventListener('click', async () => { - const res = await window.api.bedrockLaunch(); - if (res && res.ok) toast('Bedrock wird gestartet …'); - else toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true); + const btn = el('bd-play'); + // Ein Klick löst eine nicht abbrechbare App-Aktivierung aus, die etwas + // dauern kann – Button sperren, damit ein ungeduldiger zweiter Klick + // nicht zu einem doppelt geöffneten Spiel führt. + if (btn.disabled) return; + btn.disabled = true; + toast('Bedrock wird gestartet …'); + try { + const res = await window.api.bedrockLaunch(); + if (res && res.ok) { + if (!res.confirmed) { + toast('Start ausgelöst, konnte aber nicht bestätigt werden. Falls sich nichts öffnet, bitte erneut versuchen.'); + } + } else { + toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true); + } + } finally { + btn.disabled = false; + } }); el('bd-import').addEventListener('click', async () => { const res = await window.api.bedrockInstallAddon(); @@ -3673,6 +3981,16 @@ function wire() { }); el('crash-console').addEventListener('click', () => { hide('modal-crash'); openConsole(); }); + el('m-add-file').addEventListener('click', async () => { + const res = await window.api.modAddFile(currentDetailId, modArt); + if (res.canceled) return; + if (!res.ok) { toast(res.message || 'Datei konnte nicht hinzugefügt werden.', true); return; } + const n = res.added.length; + const f = res.failed.length; + if (n) toast(`${n} Datei${n === 1 ? '' : 'en'} hinzugefügt${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0 && !n); + else if (f) toast(res.failed[0].message || 'Hinzufügen fehlgeschlagen.', true); + await refreshInstalled(); + }); el('m-check-updates').addEventListener('click', checkModUpdates); el('m-apply-updates').addEventListener('click', applyModUpdates); window.api.onModUpdateProgress((p) => setUpdateStatus(`Suche … ${p.geprueft}/${p.gesamt} (${p.titel})`)); @@ -3697,10 +4015,20 @@ function wire() { bgSkin: el('s-bgskin').value, bgSkinPose: el('s-bgskin-pose').value, }); - await applySkinBackground(); + await refreshSkinBackground(); }; el('s-bgskin').addEventListener('change', previewSkin); el('s-bgskin-pose').addEventListener('change', previewSkin); + const previewSkinCycle = async () => { + el('s-bgskin').disabled = el('s-bgskin-cycle').checked; + await window.api.saveSettings({ + bgSkinCycle: el('s-bgskin-cycle').checked, + bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30), + }); + await refreshSkinBackground(); + }; + el('s-bgskin-cycle').addEventListener('change', previewSkinCycle); + el('s-bgskin-cycle-seconds').addEventListener('change', previewSkinCycle); el('s-scan-java').addEventListener('click', scanJava); el('f-java-detected').addEventListener('change', (e) => { if (e.target.value) el('f-java').value = e.target.value; @@ -3709,6 +4037,13 @@ function wire() { const p = await window.api.pickJava(); if (p) { el('f-java').value = p; el('f-java-detected').value = ''; } }); + el('f-extra-args-aikar').addEventListener('click', () => { + const feld = el('f-extra-args'); + const vorhanden = feld.value.trim(); + if (vorhanden.includes('G1NewSizePercent')) { toast('Die GC-Flags stehen schon drin.'); return; } + feld.value = vorhanden ? vorhanden + ' ' + AIKAR_FLAGS : AIKAR_FLAGS; + toast('GC-Flags eingefügt.'); + }); el('s-java-detected').addEventListener('change', async (e) => { if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); } }); @@ -3744,18 +4079,10 @@ function wire() { el('upd-open').addEventListener('click', openReleasePage); if (el('s-check-update')) { el('s-check-update').addEventListener('click', async () => { - const st = el('s-update-status'); - st.textContent = 'Suche nach Updates …'; + renderUpdateStatus({ checking: true }); const info = await window.api.updateCheck(); - if (info.error) { st.textContent = '✖ ' + info.error; return; } - if (info.noReleases) { st.textContent = 'Noch keine Veröffentlichung vorhanden.'; return; } - if (info.available) { - st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`; - hide('modal-settings'); - showUpdateDialog(info); - } else { - st.textContent = `Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`; - } + renderUpdateStatus(info); + if (info.available) { hide('modal-settings'); showUpdateDialog(info); } }); } @@ -3818,8 +4145,93 @@ function wire() { if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; } const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop(); if (open) open.classList.add('hidden'); + return; + } + + const strg = e.ctrlKey || e.metaKey; + const modalOffen = document.querySelector('.modal:not(.hidden)'); + const inEingabe = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) + || document.activeElement.isContentEditable; + + // Strg+F: Suche fokussieren – nicht wenn gerade ein Dialog offen ist, + // der eine eigene Suche hat (Mods, Modpack-Katalog, Import …) + if (strg && e.key.toLowerCase() === 'f' && !modalOffen) { + e.preventDefault(); + el('search').focus(); + el('search').select(); + return; + } + // Strg+N: neue Instanz anlegen + if (strg && e.key.toLowerCase() === 'n' && !modalOffen) { + e.preventDefault(); + el('btn-new').click(); + return; + } + // Entf: markierte Instanz(en) löschen – nicht während man irgendwo tippt + if (e.key === 'Delete' && !modalOffen && !inEingabe) { + e.preventDefault(); + el('p-delete').click(); } }); + wireFileDrop(); +} + +// ---------- Datei-Drag&Drop aufs Fenster (.jar/.mrpack/.zip) ---------- +function wireFileDrop() { + let dragDepth = 0; // dragenter/-leave feuern auch für Kindelemente + const istDatei = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files'); + + window.addEventListener('dragenter', (e) => { + if (!istDatei(e)) return; + e.preventDefault(); + dragDepth++; + document.body.classList.add('file-drop-active'); + }); + window.addEventListener('dragover', (e) => { + if (!istDatei(e)) return; + e.preventDefault(); + }); + window.addEventListener('dragleave', (e) => { + if (!istDatei(e)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) document.body.classList.remove('file-drop-active'); + }); + window.addEventListener('drop', async (e) => { + if (!istDatei(e)) return; + e.preventDefault(); + dragDepth = 0; + document.body.classList.remove('file-drop-active'); + const files = [...(e.dataTransfer.files || [])].filter((f) => f.path); + for (const f of files) await handleDroppedFile(f.path); + }); +} + +async function handleDroppedFile(filePath) { + const ext = ('.' + filePath.split('.').pop()).toLowerCase(); + if (!['.jar', '.mrpack', '.zip'].includes(ext)) { + toast('Nicht unterstützter Dateityp: ' + filePath.split(/[\\/]/).pop(), true); + return; + } + // Für .jar: läuft der Mods-Dialog gerade, zählt die dort gewählte + // Inhaltsart (Mod/Ressourcenpaket/Shader) – sonst landet es als Mod + // direkt in der aktuell ausgewählten Instanz. + const modsOpen = !el('modal-mods').classList.contains('hidden'); + const targetArt = modsOpen ? modArt : 'mod'; + const res = await window.api.filesDrop(filePath, currentDetailId, targetArt); + if (!res.ok) { toast(res.message || 'Import fehlgeschlagen.', true); return; } + + if (res.kind === 'mod') { + toast(`Datei hinzugefügt: ${res.added[0]}`); + if (modsOpen) await refreshInstalled(); + return; + } + await reload(); + if (res.instance) { + toast(`Instanz übernommen: ${res.instance.name}`); + await selectInstance(res.instance.id); + } else { + toast('Import abgeschlossen.'); + } } window.addEventListener('DOMContentLoaded', async () => { @@ -3831,11 +4243,11 @@ window.addEventListener('DOMContentLoaded', async () => { iso3dEnabled = s.iso3dIcons !== false; consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3); } catch { applyTheme('dark'); } - await applySkinBackground(); + await refreshSkinBackground(); window.api.onLaunchProgress(onLaunchEvt); window.api.onBackupProgress((p) => applyBackupProgressEvent(p.mode, p.percent || 0, p.detail || '')); window.api.onAuthCode(onAuthCode); - window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await applySkinBackground(); }); + window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await refreshSkinBackground(); }); window.api.onAuthHealth(showAuthHealth); // Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen window.api.onAccountsChanged(() => { refreshAllAccountLists(); }); @@ -3846,6 +4258,7 @@ window.addEventListener('DOMContentLoaded', async () => { }); window.api.onUpdateAvailable(showUpdateDialog); window.api.onUpdateProgress(onUpdateProgress); + window.api.onUpdateChecked(renderUpdateStatus); try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ } await loadMcIcons(); // echte Minecraft-Icons bereitstellen await applyBedrockVisibility(); diff --git a/src/store.js b/src/store.js index 5741bd6..5bf1765 100644 --- a/src/store.js +++ b/src/store.js @@ -75,9 +75,12 @@ const DEFAULT_SETTINGS = { sortBy: 'lastPlayed', // lastPlayed | name iso3dIcons: true, // Block-Symbole räumlich darstellen bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert) + desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist) + bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln + bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk proxyHost: '', @@ -232,6 +235,24 @@ function addMod(id, entry, art = 'mod') { return list; } +/* + * Findet installierte Mods, die den per "filename" identifizierten Mod als + * Pflicht-Abhängigkeit brauchen (requiredDeps wird beim Installieren über + * AeroMC gesetzt – von Hand hinzugefügte oder vor diesem Feature installierte + * Mods haben keine requiredDeps und werden daher nicht als Ziel erkannt, + * können aber selbst weiterhin als Abhängiger auftauchen). + * -> [Titel, Titel, ...] der abhängigen Mods + */ +function findDependents(id, filename, art = 'mod') { + const list = listMods(id, art); + const target = list.find((m) => m.filename === filename); + if (!target || !target.projectId) return []; + return list + .filter((m) => m.filename !== filename + && Array.isArray(m.requiredDeps) && m.requiredDeps.includes(target.projectId)) + .map((m) => m.title || m.filename); +} + function removeMod(id, filename, art = 'mod') { const list = listMods(id, art).filter((m) => m.filename !== filename); writeJson(modsIndexFile(id, art), list); @@ -443,6 +464,7 @@ module.exports = { listMods, addMod, removeMod, + findDependents, setModEnabled, listInstances, getInstance, diff --git a/src/styles.css b/src/styles.css index 5c9e11d..1093d35 100644 --- a/src/styles.css +++ b/src/styles.css @@ -141,6 +141,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden} .group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px} .card.dragging{opacity:.4} +/* Datei von außen übers Fenster gezogen (.jar/.mrpack/.zip) */ +body.file-drop-active::after{ + content:"Datei loslassen zum Importieren"; + position:fixed;inset:0;z-index:9999; + display:flex;align-items:center;justify-content:center; + background:rgba(0,0,0,.55); + color:#fff;font-size:20px;font-weight:600; + border:3px dashed var(--accent); + pointer-events:none; +} + /* ---------- Leerer Zustand ---------- */ .empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px} .empty h2{color:#333;margin:0} @@ -364,6 +375,7 @@ img.account-av{object-fit:cover;image-rendering:auto} .info-card{max-width:760px} .info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--border2)} .info-status{font-size:12px;color:var(--dim)} +.modal-sep{border-top:1px solid var(--border2)} .info-meta{margin-top:10px} .info-meta .settings-kv-row{align-items:center} .info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px} diff --git a/src/technicdiscover.js b/src/technicdiscover.js new file mode 100644 index 0000000..89207b0 --- /dev/null +++ b/src/technicdiscover.js @@ -0,0 +1,175 @@ +'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= + * Beliebt: GET https://api.technicpack.net/trending?build=multimc + * Pack-Infos: GET https://api.technicpack.net/modpack/?build=multimc + * -> entweder "url" (ein fertiges ZIP) oder "solder" (Solder-Server) + * Solder: GET modpack/ – Liste der Builds + * GET modpack// – 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 }; diff --git a/src/technicimport.js b/src/technicimport.js new file mode 100644 index 0000000..f8b687d --- /dev/null +++ b/src/technicimport.js @@ -0,0 +1,172 @@ +'use strict'; + +/* + * technicimport.js – Instanzen aus dem Technic Launcher übernehmen + * ------------------------------------------------------------------- + * Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner + * unter "/modpacks/" an (Standard unter Windows: + * %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner + * Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten, + * Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie + * bei einem klassischen .minecraft. + * + * Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem + * schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur + * über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale, + * fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den + * anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien. + */ + +const fs = require('fs'); +const path = require('path'); +const forge = require('./forge'); + +// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim +// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen. +const SKIP_DIRS = new Set(['bin']); + +function technicRoot() { + return path.join(process.env.APPDATA || '', '.technic'); +} + +function readJson(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } + catch { return null; } +} + +// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt +// oder ein einzelner Pack-Ordner angegeben wurde +function resolveModpacksDir(inputPath) { + const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot(); + if (!fs.existsSync(root)) return null; + + const direct = path.join(root, 'modpacks'); + if (fs.existsSync(direct)) return direct; + if (path.basename(root).toLowerCase() === 'modpacks') return root; + return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern +} + +// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister +function looksLikePack(dir) { + const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt']; + return markers.some((name) => fs.existsSync(path.join(dir, name))); +} + +// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel; +// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell +// als stabile API dokumentiert ist +function readInstalledPacksMeta(root) { + const data = readJson(path.join(root, 'installedPacks')); + const packs = data && data.installedPacks; + return packs && typeof packs === 'object' ? packs : {}; +} + +// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version +// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein +// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach +// ohne Version/Loader stehen und lässt sich manuell nachtragen. +function detectVersion(packDir) { + const bin = path.join(packDir, 'bin'); + const jarFile = path.join(bin, 'modpack.jar'); + if (fs.existsSync(jarFile)) { + try { + const buf = fs.readFileSync(jarFile); + const idx = forge.readZipIndex(buf); + const entry = idx['version.json']; + if (entry) { + // manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse + const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, ''); + const profile = JSON.parse(text); + const libs = Array.isArray(profile.libraries) ? profile.libraries : []; + let loader = 'vanilla'; + let loaderVersion = ''; + for (const lib of libs) { + const name = String((lib && lib.name) || ''); + if (/net\.minecraftforge:forge:/i.test(name)) { + loader = 'forge'; + loaderVersion = (name.split(':')[2] || '').split('-').pop() || ''; + } else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') { + loader = 'neoforge'; + loaderVersion = name.split(':')[2] || ''; + } + } + const id = String(profile.id || profile.inheritsFrom || ''); + const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim(); + if (version) return { version, loader, loaderVersion }; + } + } catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ } + } + // ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version + const legacy = path.join(bin, 'version'); + try { + if (fs.existsSync(legacy)) { + const version = fs.readFileSync(legacy, 'utf8').trim(); + if (version) return { version, loader: 'vanilla', loaderVersion: '' }; + } + } catch { /* egal, bleibt leer */ } + return { version: '', loader: 'vanilla', loaderVersion: '' }; +} + +function scan(inputPath) { + const modpacksDir = resolveModpacksDir(inputPath); + if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' }; + + const root = path.basename(modpacksDir).toLowerCase() === 'modpacks' + ? path.dirname(modpacksDir) : modpacksDir; + const meta = readInstalledPacksMeta(root); + + const list = []; + for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = path.join(modpacksDir, entry.name); + if (!looksLikePack(dir)) continue; + + const { version, loader, loaderVersion } = detectVersion(dir); + const info = meta[entry.name] || {}; + list.push({ + folder: entry.name, + dir, + name: info.displayName || entry.name, + group: '', + notes: '', + version, + loader, + loaderVersion, + gameDir: dir, + hasGameData: true, + }); + } + list.sort((a, b) => a.name.localeCompare(b.name)); + return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list }; +} + +// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg +function copyInto(sourceDir, targetDir) { + fs.mkdirSync(targetDir, { recursive: true }); + for (const name of fs.readdirSync(sourceDir)) { + if (SKIP_DIRS.has(name)) continue; + fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); + } +} + +function importOne(store, entry, copyData = true) { + const created = store.createOrReplaceImportedInstance({ + name: entry.name, + group: entry.group, + notes: entry.notes, + minecraft: { + version: entry.version, + loader: entry.loader, + loaderVersion: entry.loaderVersion, + }, + }); + + let copied = false; + if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { + copyInto(entry.gameDir, store.gameDir(created.id)); + copied = true; + } + return { id: created.id, name: created.name, copied }; +} + +module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion };