'use strict'; /* * main.js – Electron Hauptprozess für ViperCraft * Phase 1: Fenster, Instanz-Verwaltung, Einstellungen. * (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.) */ const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { execFile } = require('child_process'); const pkg = require('../package.json'); const store = require('./store'); const services = require('./services'); const launcher = require('./launcher'); const loaders = require('./loaders'); const forge = require('./forge'); const auth = require('./auth'); const updater = require('./updater'); const icons = require('./icons'); const bedrock = require('./bedrock'); 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'); globalThis.fetch = undici.fetch; globalThis.Headers = undici.Headers; globalThis.Request = undici.Request; globalThis.Response = undici.Response; // --------------------------------------------------------------------------- // Automatische Überwachung der Anmelde-Dienste // --------------------------------------------------------------------------- const AUTH_ENDPOINTS = [ { name: 'Microsoft-Login', url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode' }, { name: 'Xbox Live', url: 'https://user.auth.xboxlive.com/user/authenticate' }, { name: 'Minecraft-Dienste', url: 'https://api.minecraftservices.com/minecraft/profile' }, ]; let authHealth = { ok: true, checked: null, down: [] }; async function checkAuthServices() { const down = []; for (const ep of AUTH_ENDPOINTS) { try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 8000); // Antwort-Status ist egal (401/404 = Dienst lebt); nur Ausfall/5xx zählt als "down" const res = await fetch(ep.url, { method: 'GET', signal: ctrl.signal, headers: { 'User-Agent': 'AeroMC-Launcher/0.28' } }); clearTimeout(t); if (res.status >= 500) down.push(ep.name); } catch { down.push(ep.name); } } authHealth = { ok: down.length === 0, checked: new Date().toISOString(), down }; if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:health', authHealth); return authHealth; } function applyProxy(s) { try { if (s.proxyEnabled && s.proxyHost && String(s.proxyPort).trim()) { const cred = s.proxyUser ? `${encodeURIComponent(s.proxyUser)}:${encodeURIComponent(s.proxyPass || '')}@` : ''; undici.setGlobalDispatcher(new undici.ProxyAgent(`http://${cred}${s.proxyHost}:${s.proxyPort}`)); } else { undici.setGlobalDispatcher(new undici.Agent()); } } catch { /* ungültige Proxy-Konfig ignorieren */ } } let mainWindow = null; // --------------------------------------------------------------------------- // Verknüpfungen: "AeroMC.exe --launch " startet eine Instanz direkt // --------------------------------------------------------------------------- // Nur eine Instanz des Launchers. Ein zweiter Aufruf (Doppelklick auf eine // Verknüpfung) reicht seine Argumente an das laufende Fenster weiter. const hatEinzelSperre = app.requestSingleInstanceLock(); if (!hatEinzelSperre) app.quit(); /* * Instanz-Kennung aus den Startargumenten lesen. * * Wichtig: Bei einem zweiten Start sortiert Chromium die Argumente um – * alle Schalter wandern nach vorn, alles andere ans Ende. Aus * --launch 1.21.11 * wird dabei * --launch --allow-file-access-from-files 1.21.11 * und die Kennung steht nicht mehr hinter ihrem Schalter. Deshalb wird die * Form "--launch=" verwendet: die bleibt ein einziges Argument. */ function instanzAusArgumenten(argv) { const args = (argv || []).map(String); for (const a of args) { if (a.startsWith('--launch=')) return a.slice('--launch='.length); } // Notnagel für Verknüpfungen aus früheren Fassungen (und den Aufruf von // Hand): Steht "--launch" allein da, ist die Kennung durch das Umsortieren // ans Ende gerutscht – das letzte Argument, das kein Schalter ist. if (args.includes('--launch')) { const i = args.indexOf('--launch'); if (args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]; for (let j = args.length - 1; j > 0; j--) { if (!args[j].startsWith('-')) return args[j]; } } return null; } // wie instanzAusArgumenten, nur für die optionale Server-Adresse einer // Server-Verknüpfung ("--server=") – bleibt aus demselben Grund als ein // einziges "--server=..."-Argument zusammen function serverAusArgumenten(argv) { const args = (argv || []).map(String); for (const a of args) { if (a.startsWith('--server=')) return a.slice('--server='.length); } return null; } // Startwunsch an die Oberfläche geben – dort hängt die gesamte Rückmeldung dran function starteInstanzImFenster(id, serverIp) { if (!id || !mainWindow || mainWindow.isDestroyed()) return; mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null }); } app.on('second-instance', (_e, argv) => { // showMainWindow() statt nur restore()+focus(): bei aktivem Autostart kann // das Fenster echt versteckt sein (hide(), nicht nur minimiert) – das // braucht ein show(), sonst bleibt es beim zweiten Start unsichtbar showMainWindow(); starteInstanzImFenster(instanzAusArgumenten(argv), serverAusArgumenten(argv)); }); // Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf // den entpackten Temp-Ordner – eine Verknüpfung dorthin wäre morgen tot. function verknuepfungsZiel() { if (updater.detectInstallKind() === 'portable') { return updater.portableTarget() || process.execPath; } return process.execPath; } /* * Baut die Argumente für eine Verknüpfung zusammen. * Im Entwicklungsmodus (npm start) zeigt process.execPath nur auf die nackte * electron.exe – die weiß ohne Pfadangabe nicht, welche App sie laden soll, * und zeigt stattdessen ihren eigenen Startbildschirm statt AeroMC. Im * gepackten Build (Setup/Portable) ist die App fest eingebaut, daher dort * kein Zusatz nötig. */ function verknuepfungsArgs(teile) { const praefix = app.isPackaged ? '' : `"${path.join(__dirname, '..')}" `; return praefix + teile.join(' '); } /* * Icon für eine Verknüpfung. Im gepackten Build trägt die EXE selbst schon * das richtige Icon als Ressource (funktioniert auch aus dem asar heraus). * Im Entwicklungsmodus zeigt "ziel" auf die nackte electron.exe (Atom-Logo) – * dort stattdessen direkt die eigene .ico-Datei verwenden, die im * Entwicklungsordner als normale Datei vorliegt. */ function verknuepfungsIcon(ziel) { if (app.isPackaged) return ziel; return windowIcon() || ziel; } function windowIcon() { const png = path.join(__dirname, 'assets', 'logo.png'); const ico = path.join(__dirname, 'assets', 'icon.ico'); if (fs.existsSync(ico)) return ico; if (fs.existsSync(png)) return png; return undefined; // Electron-Standard, solange kein Logo hinterlegt ist } function createWindow(startHidden) { mainWindow = new BrowserWindow({ width: 1200, height: 820, minWidth: 940, minHeight: 640, backgroundColor: '#1b1e24', title: 'AeroMC', icon: windowIcon(), autoHideMenuBar: true, show: !startHidden, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, }, }); mainWindow.loadFile(path.join(__dirname, 'index.html')); // Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im // Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der // Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist. mainWindow.on('close', (e) => { if (appIsQuitting || !store.getSettings().autostart) return; e.preventDefault(); mainWindow.hide(); }); // mit "--launch " gestartet -> Instanz anwerfen, sobald die Oberfläche // ihren Empfänger dafür registriert hat. WICHTIG: nicht auf "did-finish-load" // verlassen – das feuert, sobald die Seite geladen ist, aber die Oberfläche // registriert ihren IPC-Listener erst nach eigenem asynchronem Vorlauf // (u. a. Skin-Hintergrund laden). Kommt der Startwunsch früher an als diese // Registrierung, geht er kommentarlos verloren – ein Wettlauf, der je nach // Netzwerk-/Ladegeschwindigkeit mal gewinnt, mal nicht. Deshalb wartet der // Hauptprozess hier auf ein explizites Bereit-Signal der Oberfläche. const startId = instanzAusArgumenten(process.argv); const startServer = serverAusArgumenten(process.argv); if (startId) { ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer)); } } let tray = null; let appIsQuitting = false; function showMainWindow() { if (!mainWindow || mainWindow.isDestroyed()) { createWindow(false); return; } if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } function createTray() { if (tray) return; try { const iconPfad = windowIcon(); const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty(); tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 })); tray.setToolTip('AeroMC'); tray.setContextMenu(Menu.buildFromTemplate([ { label: 'Öffnen', click: () => showMainWindow() }, { type: 'separator' }, { label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } }, ])); tray.on('click', () => showMainWindow()); } catch { /* z. B. wenn kein Icon vorhanden ist – Autostart läuft dann ohne Tray-Symbol weiter */ } } function destroyTray() { if (tray) { tray.destroy(); tray = null; } } // Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden function applyAutostart(enabled) { try { app.setLoginItemSettings({ openAtLogin: !!enabled, args: enabled ? ['--hidden'] : [] }); } catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ } if (enabled) createTray(); else destroyTray(); } app.whenReady().then(() => { if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst store.init(app.getPath('userData')); services.setCacheDir(app.getPath('userData')); // Java-Suche zwischenspeichern // selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java'))); // Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden) auth.init(app.getPath('userData'), { available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } }, encrypt: (s) => safeStorage.encryptString(s).toString('base64'), decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')), }); applyProxy(store.getSettings()); const autostartAn = !!store.getSettings().autostart; // Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B. // von Hand an der Registry gedreht wurde oder nach einem Update) applyAutostart(autostartAn); const startHidden = autostartAn && process.argv.includes('--hidden'); createWindow(startHidden); // Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800); // echte Minecraft-Icons einmalig aus den eigenen Spieldateien holen setTimeout(() => { try { const { shared, out } = iconDirs(); if (!fs.existsSync(out) || fs.readdirSync(out).length === 0) icons.extractIcons(shared, out); } catch { /* noch keine Version heruntergeladen */ } }, 1500); // Reste früherer Update-Downloads aufräumen (mehrere hundert MB im Temp) setTimeout(() => { try { updater.cleanupUpdateCache(); } catch { /* egal */ } }, 6000); // Sicherung nach Zeitplan: kurz nach dem Start prüfen, danach stündlich. // Die Prüfung ist billig – gesichert wird nur, wenn wirklich fällig. setTimeout(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 60000); setInterval(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 3600000); // automatisch auf Updates prüfen (kurz nach dem Start) setTimeout(async () => { try { const info = await updater.checkForUpdate(app.getVersion()); 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 (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) setTimeout(() => checkAuthServices(), 2500); setInterval(() => checkAuthServices(), 120000); // Anmeldung im Hintergrund auffrischen -> kein erneuter Login beim Start nötig const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID; auth.getValidAccount(cid) .then((acc) => { if (acc && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:refreshed'); }) .catch(() => { /* offline o. Ä. – Konto bleibt gespeichert */ }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('before-quit', () => { appIsQuitting = true; }); // --------------------------------------------------------------------------- // Java-Erkennung (für Anzeige in den Einstellungen) // --------------------------------------------------------------------------- function detectJava(javaPath) { return new Promise((resolve) => { const cmd = javaPath && javaPath.trim() ? javaPath : 'java'; execFile(cmd, ['-version'], (err, stdout, stderr) => { if (err) return resolve({ ok: false, path: cmd, version: null, error: err.message }); // "java -version" schreibt nach stderr const out = (stderr || stdout || '').split('\n')[0].trim(); resolve({ ok: true, path: cmd, version: out }); }); }); } // --------------------------------------------------------------------------- // IPC – Einstellungen // --------------------------------------------------------------------------- ipcMain.handle('settings:get', () => store.getSettings()); ipcMain.handle('settings:save', (_e, patch) => { const s = store.saveSettings(patch); applyProxy(s); if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart); return s; }); ipcMain.handle('settings:instancesDir', () => store.getInstancesDir()); ipcMain.handle('java:detect', (_e, javaPath) => detectJava(javaPath)); // automatische Java-Suche über das System ipcMain.handle('java:scan', () => services.scanJava(false)); // Knopf sucht immer frisch // aus dem Zwischenspeicher – für Dialoge, die nur die Liste anzeigen wollen ipcMain.handle('java:list', () => services.scanJava(true)); // --------------------------------------------------------------------------- // IPC – Minecraft-Versionen (Mojang) // --------------------------------------------------------------------------- ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData'))); // --------------------------------------------------------------------------- // IPC – Mods (Modrinth) // --------------------------------------------------------------------------- ipcMain.handle('curseforge:testKey', async (_e, apiKey) => { try { return await services.curseforgeTestKey(apiKey); } catch (err) { return { ok: false, message: err.message || String(err) }; } }); ipcMain.handle('mods:search', async (_e, opts) => { const o = opts || {}; const source = o.source || 'modrinth'; try { if (source === 'curseforge') { const zugang = services.curseforgeZugang(store.getSettings()); return await services.curseforgeSearch(zugang, o); } return await services.modrinthSearch(o); } catch (err) { return { total: 0, hits: [], error: (err && err.message) ? err.message : String(err), needKey: !!(err && err.code === 'need-key'), }; } }); ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod')); ipcMain.handle('mods:install', async (_e, id, project, art) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const typ = services.inhaltsArt(art || 'mod'); const gameVersion = inst.minecraft.version; const loader = inst.minecraft.loader; if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' }; // Nur Mods brauchen einen Loader – Ressourcenpakete und Shader laufen auch // in einer Vanilla-Instanz. if (typ.loaderNoetig && (!loader || loader === 'vanilla')) { return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' }; } const modsDir = store.inhaltsDir(id, art || 'mod'); const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId)); const added = []; let firstError = null; const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-'))); const zugang = istCf ? services.curseforgeZugang(store.getSettings()) : null; // installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten async function installOne(proj, isDep) { if (done.has(proj.projectId)) return; done.add(proj.projectId); try { let res; if (String(proj.projectId || '').startsWith('cf-') || proj.source === 'curseforge') { res = await services.curseforgeInstall({ zugang, projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod', }); if (!res.ok) { 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 || []) { if (dep.required && dep.projectId && !done.has(dep.projectId)) { await installOne({ projectId: dep.projectId, title: dep.projectId, source: 'curseforge' }, true); } } } else { res = await services.modrinthInstall({ projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod', }); if (!res.ok) { 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 || []) { if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) { const title = await services.modrinthProjectTitle(dep.project_id); await installOne({ projectId: dep.project_id, title }, true); } } } } catch (err) { if (!isDep && !firstError) firstError = err.message; } } await installOne(project, false); if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' }; 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); } catch { return []; } }); 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() { return Math.round(os.totalmem() / 1048576); } ipcMain.handle('system:memory', () => ({ totalMb: gesamtSpeicherMb(), freeMb: Math.round(os.freemem() / 1048576), })); ipcMain.handle('system:checkMemory', (_e, minMb, maxMb) => services.pruefeSpeicher(minMb, maxMb, gesamtSpeicherMb())); // Neuester Absturzbericht einer Instanz; seit = Startzeitpunkt in Millisekunden ipcMain.handle('crash:latest', (_e, id, seit) => { const inst = store.getInstance(id); if (!inst) return { ok: false, grund: 'keine-instanz' }; return crashreports.lies(store.gameDir(id), Number(seit) || 0); }); // ---- Serverliste (servers.dat) ---- ipcMain.handle('servers:list', (_e, id) => { if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' }; return servers.lies(store.gameDir(id)); }); ipcMain.handle('servers:add', (_e, id, eintrag) => { if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' }; return servers.fuegeHinzu(store.gameDir(id), eintrag || {}); }); ipcMain.handle('servers:remove', (_e, id, ip) => { if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' }; return servers.entferne(store.gameDir(id), ip); }); ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => { if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' }; 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 = []; const fehler = []; for (const zielId of zielIds || []) { const inst = store.getInstance(zielId); if (!inst) { fehler.push({ id: zielId, message: 'Instanz nicht gefunden.' }); continue; } const res = servers.uebertrage(store.gameDir(zielId), eintrag || {}); if (res.ok) erledigt.push(inst.name); else fehler.push({ name: inst.name, message: res.message }); } return { ok: true, erledigt, fehler }; }); // Logdateien der Instanz – auch nach einem Neustart des Launchers ipcMain.handle('logs:list', (_e, id) => { if (!store.getInstance(id)) return []; return crashreports.listeLogs(store.gameDir(id)); }); ipcMain.handle('logs:read', (_e, id, name) => { if (!store.getInstance(id)) return { ok: false, grund: 'keine-instanz' }; return crashreports.liesLog(store.gameDir(id), name); }); ipcMain.handle('logs:openFolder', (_e, id) => { if (!store.getInstance(id)) return false; const dir = crashreports.logOrdner(store.gameDir(id)); if (!fs.existsSync(dir)) return false; shell.openPath(dir); return true; }); // Ordner mit den Absturzberichten öffnen ipcMain.handle('crash:openFolder', (_e, id) => { const inst = store.getInstance(id); if (!inst) return false; const dir = crashreports.berichtsOrdner(store.gameDir(id)); if (!fs.existsSync(dir)) return false; shell.openPath(dir); return true; }); // Mod ab-/anschalten statt löschen (Datei wird nur umbenannt) ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => { const res = store.setModEnabled(id, filename, !!enabled, art || 'mod'); if (res.ok) return res; const ordner = services.inhaltsArt(art || 'mod').ordner; const gruende = { 'not-found': `Die Datei liegt nicht mehr im ${ordner}-Ordner.`, 'both-exist': `Es liegen beide Fassungen im ${ordner}-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.`, 'bad-name': 'Ungültiger Dateiname.', }; return { ok: false, message: gruende[res.reason] || res.reason }; }); // sucht für die installierten Mods nach neueren Dateien (lädt noch nichts) ipcMain.handle('mods:checkUpdates', async (_e, id, art) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const gameVersion = inst.minecraft.version; const loader = inst.minecraft.loader; if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' }; try { const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p); }, art || 'mod'); return { ok: true, ...res }; } catch (err) { return { ok: false, message: 'Update-Suche fehlgeschlagen: ' + err.message }; } }); // lädt die gewählten Aktualisierungen und ersetzt die alten Dateien ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const gameVersion = inst.minecraft.version; const loader = inst.minecraft.loader; const sorte = art || 'mod'; const modsDir = store.inhaltsDir(id, sorte); const gewuenscht = new Set(projectIds || []); const erneuert = []; const fehler = []; for (const mod of store.listMods(id, sorte)) { if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue; try { const res = await services.modrinthInstall({ projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir, art: sorte, }); if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; } store.addMod(id, res.entry, sorte); // alte Datei erst weg, wenn die neue liegt – und nie, wenn sie gleich heißt if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename, sorte); // war der Eintrag abgeschaltet, bleibt er es auch nach dem Update if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false, sorte); erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber }); } catch (err) { fehler.push({ title: mod.title, message: err.message }); } } return { ok: true, erneuert, fehler }; }); // --------------------------------------------------------------------------- // IPC – Microsoft-Login (Phase 3) // --------------------------------------------------------------------------- ipcMain.handle('auth:status', () => auth.currentAccount()); ipcMain.handle('auth:health', () => authHealth); ipcMain.handle('auth:recheck', () => checkAuthServices()); // "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal ipcMain.handle('auth:accounts', () => auth.listAccounts() .map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) }))); ipcMain.handle('auth:setActive', (_e, id) => auth.setActive(id)); // ---- Skin des aktiven Kontos ---- // gültiges Zugangstoken des aktiven Kontos holen async function aktivesToken() { const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID; const acc = await auth.getValidAccount(clientId); return acc && acc.mcAccessToken ? acc.mcAccessToken : null; } ipcMain.handle('skin:profile', async () => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; try { return await skins.holeProfil(token); } catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; } }); ipcMain.handle('skin:choose', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'Skin auswählen', properties: ['openFile'], filters: [{ name: 'PNG-Bild', extensions: ['png'] }], }); if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true }; const pfad = res.filePaths[0]; // sofort prüfen, damit der Nutzer nicht erst nach dem Hochladen erfährt, // dass die Datei nicht passt const geprueft = skins.pruefeDatei(pfad); if (!geprueft.ok) return geprueft; // gleich als Bilddaten mitgeben, damit die Vorschau ohne Dateizugriff // im Renderer auskommt let dataUri = ''; try { dataUri = 'data:image/png;base64,' + fs.readFileSync(pfad).toString('base64'); } catch { /* egal */ } return { ok: true, pfad, breite: geprueft.breite, hoehe: geprueft.hoehe, dataUri }; }); ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; try { return await skins.setzeSkin(token, dateiPfad, variante); } catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; } }); ipcMain.handle('skin:reset', async () => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; try { return await skins.setzeSkinZurueck(token); } catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } }); ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id)); ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; }); ipcMain.handle('auth:login', async () => { const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID; try { const account = await auth.login( clientId, (code) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:code', code); }, () => {}, ); return { ok: true, account }; } catch (err) { return { ok: false, message: err.message }; } }); // --------------------------------------------------------------------------- // IPC – Instanzen // --------------------------------------------------------------------------- ipcMain.handle('instances:list', () => store.listInstances()); ipcMain.handle('instances:get', (_e, id) => store.getInstance(id)); ipcMain.handle('instances:create', (_e, data) => store.createInstance(data)); ipcMain.handle('instances:update', (_e, id, patch) => store.updateInstance(id, patch)); ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id)); ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id)); ipcMain.handle('instances:openFolder', (_e, id) => { const p = store.instancePath(id); if (fs.existsSync(p)) shell.openPath(p); return true; }); ipcMain.handle('instances:openGameDir', (_e, id) => { const p = store.gameDir(id); if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); shell.openPath(p); return true; }); // ---- Welten & Ressourcenpakete ---- function listDir(dir, dirsOnly) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir, { withFileTypes: true }) .filter((e) => (dirsOnly ? e.isDirectory() : true) && !e.name.startsWith('.')) .map((e) => { let size = 0; try { size = fs.statSync(path.join(dir, e.name)).size; } catch { /* egal */ } return { name: e.name, isDir: e.isDirectory(), size }; }); } ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true)); ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false)); ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false)); function safeSub(id, sub, name) { const base = path.join(store.gameDir(id), sub); const target = path.join(base, name); // Pfad-Ausbruch verhindern if (!target.startsWith(base + path.sep)) return null; return target; } ipcMain.handle('instance:deleteContent', (_e, id, sub, name) => { const target = safeSub(id, sub, name); if (target && fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true }); return true; }); ipcMain.handle('instance:openSubdir', (_e, id, sub) => { const p = path.join(store.gameDir(id), sub); if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); shell.openPath(p); return true; }); // ---- Screenshots ---- ipcMain.handle('instance:screenshots', (_e, id) => listDir(path.join(store.gameDir(id), 'screenshots'), false) .filter((f) => /\.(png|jpe?g|gif|webp)$/i.test(f.name)) .sort((a, b) => b.name.localeCompare(a.name))); ipcMain.handle('instance:screenshotData', (_e, id, name) => { const target = safeSub(id, 'screenshots', name); try { if (!target || !fs.existsSync(target)) return null; if (fs.statSync(target).size > 12 * 1024 * 1024) return null; let ext = path.extname(target).slice(1).toLowerCase(); if (ext === 'jpg') ext = 'jpeg'; return `data:image/${ext};base64,` + fs.readFileSync(target).toString('base64'); } catch { return null; } }); ipcMain.handle('instance:openScreenshot', (_e, id, name) => { const target = safeSub(id, 'screenshots', name); if (target && fs.existsSync(target)) shell.openPath(target); return true; }); // ---- Instanz Import/Export (ZIP über Windows-Bordmittel) ---- function runPwsh(command, env) { return new Promise((resolve, reject) => { execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command], { env: Object.assign({}, process.env, env), windowsHide: true, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve(stdout))); }); } ipcMain.handle('instances:export', async (_e, id) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_'); const res = await dialog.showSaveDialog(mainWindow, { title: 'Instanz exportieren', defaultPath: safe + '.zip', filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }], }); if (res.canceled) return { ok: false, canceled: true }; try { if (fs.existsSync(res.filePath)) fs.unlinkSync(res.filePath); await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force", { VN_SRC: store.instancePath(id), VN_DST: res.filePath }); return { ok: true, path: res.filePath }; } catch (err) { return { ok: false, message: 'Export fehlgeschlagen: ' + err.message }; } }); /* * 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) { const raw = forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, ''); const index = JSON.parse(raw); const deps = index.dependencies || {}; let loader = 'vanilla', loaderVersion = ''; if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; } else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; } else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; } else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; } const created = store.createOrReplaceImportedInstance({ name: index.name || 'Modpack', minecraft: { version: deps.minecraft || '', loader, loaderVersion }, }); const gameDir = store.gameDir(created.id); const inside = (dest) => dest.startsWith(gameDir + path.sep); const eingespielt = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: false }); // Herkunft merken, damit sich das Paket später aktualisieren lässt const herkunft = await modpackHerkunft(buf, index); store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) }); return { ok: true, instance: store.getInstance(created.id), fileCount: eingespielt.dateien.length, }; } /* * Spielt ein .mrpack in ein Spielverzeichnis ein. * schonePlayerDaten: bei einer Aktualisierung bleiben Welten, Bildschirmfotos * und die Optionen unangetastet – sonst wären die Einstellungen des Spielers weg. * -> { dateien: [relative Pfade] } */ const SPIELERDATEN = [/^saves\//i, /^screenshots\//i, /^logs\//i, /^options\.txt$/i, /^servers\.dat$/i]; async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) { const inside = (dest) => dest.startsWith(gameDir + path.sep); const geschuetzt = (rel) => schonePlayerDaten && SPIELERDATEN.some((r) => r.test(rel)); const dateien = []; const files = (index.files || []).filter((f) => !(f.env && f.env.client === 'unsupported')); let i = 0; for (const f of files) { i++; const url = f.downloads && f.downloads[0]; if (!url || !f.path) continue; const rel = String(f.path).replace(/\\/g, '/'); if (geschuetzt(rel)) continue; const dest = path.join(gameDir, rel.replace(/\//g, path.sep)); if (!inside(dest)) continue; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' }); } try { const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (!res.ok) continue; fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); dateien.push(rel); } catch { /* einzelne Datei überspringen */ } } // overrides einspielen for (const prefix of ['overrides/', 'client-overrides/']) { for (const name of Object.keys(zip)) { if (!name.startsWith(prefix) || name.endsWith('/')) continue; const rel = name.slice(prefix.length); if (geschuetzt(rel)) continue; const dest = path.join(gameDir, rel.replace(/\//g, path.sep)); if (!inside(dest)) continue; fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name])); dateien.push(rel); } } return { dateien }; } /* * Woher stammt das Paket? Modrinth kennt jede veröffentlichte Datei an ihrer * Prüfsumme – darüber finden wir Projekt und Fassung, ohne den Nutzer zu fragen. */ async function modpackHerkunft(buf, index) { const basis = { quelle: 'modrinth', name: index.name || '', versionNummer: index.versionId || '', projectId: '', versionId: '', }; try { const sha1 = require('crypto').createHash('sha1').update(buf).digest('hex'); const res = await fetch(`https://api.modrinth.com/v2/version_file/${sha1}?algorithm=sha1`, { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } }); if (res.ok) { const v = await res.json(); basis.projectId = v.project_id || ''; basis.versionId = v.id || ''; basis.versionNummer = v.version_number || basis.versionNummer; } } catch { /* offline oder unbekanntes Paket – dann eben ohne Herkunft */ } return basis; } // ---- Modpack aktualisieren (Modrinth) ---- /* * Sucht eine neuere Fassung des installierten Modpacks. * -> { ok, verfuegbar, aktuelleNummer, neueNummer, ... } */ ipcMain.handle('modpack:check', async (_e, id) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const mp = inst.modpack; if (!mp || !mp.projectId) { return { ok: false, message: 'Für diese Instanz ist kein Modrinth-Modpack hinterlegt. ' + 'Aktualisieren geht nur bei Instanzen, die aus einem .mrpack entstanden sind.' }; } try { const res = await fetch(`https://api.modrinth.com/v2/project/${mp.projectId}/version`, { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } }); if (!res.ok) return { ok: false, message: 'Modrinth antwortet mit HTTP ' + res.status }; const versionen = await res.json(); if (!Array.isArray(versionen) || !versionen.length) return { ok: true, verfuegbar: false }; const neueste = versionen[0]; const verfuegbar = neueste.id !== mp.versionId; return { ok: true, verfuegbar, name: mp.name, aktuelleNummer: mp.versionNummer || '?', neueNummer: neueste.version_number, neueVersionId: neueste.id, hinweise: neueste.changelog || '', spielVersion: (neueste.game_versions || [])[0] || '', }; } catch (err) { return { ok: false, message: 'Suche fehlgeschlagen: ' + err.message }; } }); /* * Spielt eine neuere Fassung ein. Dateien der alten Fassung, die es nicht mehr * gibt, werden entfernt; Welten, Bildschirmfotos und Optionen bleiben. */ ipcMain.handle('modpack:update', async (_e, id, versionId) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const mp = inst.modpack; if (!mp || !mp.projectId) return { ok: false, message: 'Kein Modpack hinterlegt.' }; try { const vres = await fetch(`https://api.modrinth.com/v2/version/${versionId}`, { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } }); if (!vres.ok) return { ok: false, message: 'Fassung nicht abrufbar (HTTP ' + vres.status + ').' }; const version = await vres.json(); const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0]; if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' }; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' }); } const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (!dl.ok) return { ok: false, message: 'Download fehlgeschlagen (HTTP ' + dl.status + ').' }; const buf = Buffer.from(await dl.arrayBuffer()); const zip = forge.readZipIndex(buf); if (!zip['modrinth.index.json']) return { ok: false, message: 'Die geladene Datei ist kein .mrpack.' }; const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, '')); const gameDir = store.gameDir(id); const neu = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: true }); // Was die alte Fassung mitbrachte und die neue nicht mehr kennt, muss weg – // sonst bleiben alte Mods liegen und der Loader stolpert darüber. const behalten = new Set(neu.dateien.map((d) => d.toLowerCase())); const entfernt = []; for (const alt of mp.dateien || []) { if (behalten.has(String(alt).toLowerCase())) continue; if (SPIELERDATEN.some((r) => r.test(alt))) continue; const p = path.join(gameDir, String(alt).replace(/\//g, path.sep)); if (!p.startsWith(gameDir + path.sep)) continue; try { if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); entfernt.push(alt); } } catch { /* egal */ } } // Minecraft-Version und Loader können sich mit der neuen Fassung ändern const deps = index.dependencies || {}; let loader = 'vanilla'; let loaderVersion = ''; if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; } else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; } else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; } else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; } store.updateInstance(id, { minecraft: { version: deps.minecraft || inst.minecraft.version, loader, loaderVersion }, modpack: Object.assign({}, mp, { versionId: version.id, versionNummer: version.version_number, dateien: neu.dateien, }), }); notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`); return { ok: true, neueNummer: version.version_number, eingespielt: neu.dateien.length, entfernt: entfernt.length, spielVersion: deps.minecraft || '', }; } catch (err) { return { ok: false, message: 'Aktualisierung fehlgeschlagen: ' + err.message }; } }); // ---- Modpack-Import (CurseForge .zip mit manifest.json) ---- // "forge-47.2.0" / "fabric-0.15.7" -> { loader, loaderVersion } function loaderAusManifest(manifest) { const eintraege = (manifest.minecraft && manifest.minecraft.modLoaders) || []; const primaer = eintraege.find((l) => l && l.primary) || eintraege[0]; const id = String((primaer && primaer.id) || ''); const m = id.match(/^(forge|neoforge|fabric|quilt)-(.+)$/i); if (!m) return { loader: 'vanilla', loaderVersion: '' }; return { loader: m[1].toLowerCase(), loaderVersion: m[2] }; } async function importCurseForge(buf, zip, zugang) { const raw = forge.readZipEntry(buf, zip['manifest.json']).toString('utf8').replace(/^/, ''); const manifest = JSON.parse(raw); const { loader, loaderVersion } = loaderAusManifest(manifest); const created = store.createOrReplaceImportedInstance({ name: manifest.name || 'CurseForge-Modpack', minecraft: { version: (manifest.minecraft && manifest.minecraft.version) || '', loader, loaderVersion, }, }); const gameDir = store.gameDir(created.id); const inside = (dest) => dest.startsWith(gameDir + path.sep); const melde = (detail, current, total) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('launch:progress', { phase: 'modpack', current, total, detail }); } }; // 1) Dateiliste über die CurseForge-API auflösen const dateien = (manifest.files || []).filter((f) => f && f.fileID); melde('Dateiliste abrufen', 0, dateien.length); const aufgeloest = await services.curseforgeResolveFiles(zugang, dateien, (p) => melde('Dateiliste abrufen', p.aufgeloest, p.gesamt)); // 2) Mods herunterladen const modsDir = path.join(gameDir, 'mods'); let geladen = 0; const fehlend = []; let i = 0; for (const f of dateien) { i++; const info = aufgeloest.get(Number(f.fileID)); if (!info) { fehlend.push(f.fileID); continue; } const dest = path.join(modsDir, path.basename(info.fileName)); if (!inside(dest)) continue; melde('Mods laden', i, dateien.length); try { // Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf // einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server. await services.downloadFile(info.url, dest, info.kopfzeilen); geladen++; } catch { fehlend.push(f.fileID); } } // 3) overrides einspielen const prefix = (manifest.overrides || 'overrides').replace(/\/*$/, '') + '/'; for (const name of Object.keys(zip)) { if (!name.startsWith(prefix) || name.endsWith('/')) continue; const dest = path.join(gameDir, name.slice(prefix.length).replace(/\//g, path.sep)); if (!inside(dest)) continue; fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name])); } return { ok: true, instance: store.getInstance(created.id), fileCount: dateien.length, geladen, fehlend: fehlend.length, }; } ipcMain.handle('modpack:search', async (_e, opts) => { try { const q = Object.assign({}, opts || {}, { art: 'modpack' }); return await services.modrinthSearch(q); } catch (err) { return { total: 0, hits: [], error: err.message }; } }); ipcMain.handle('modpack:installFromModrinth', async (_e, project) => { try { const projectId = project && (project.projectId || project.id); if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' }; const gameVersion = (project && project.gameVersion) || ''; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …', }); } const file = await services.modrinthResolveModpack({ projectId, gameVersion }); if (!file) { return { ok: false, message: gameVersion ? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.') : 'Keine Modpack-Version gefunden.', }; } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …', }); } const buf = await services.downloadBuffer(file.url); const zip = forge.readZipIndex(buf); if (!zip['modrinth.index.json']) { return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' }; } const result = await importMrpack(buf, zip); if (result && result.ok && result.instance) { // Herkunft mit aktueller Version-ID ergänzen, falls der Index sie nicht trägt const inst = result.instance; const mp = Object.assign({}, inst.modpack || {}, { projectId: projectId, versionId: file.versionId, versionNumber: file.versionNumber, title: (project && project.title) || (inst.modpack && inst.modpack.title) || inst.name, }); 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', properties: ['openFile'], filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }], }); if (res.canceled) return { ok: false, canceled: true }; return importModpackFromPath(res.filePaths[0]); }); // Verknüpfung auf dem Desktop, die genau diese Instanz startet // wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk) ipcMain.handle('instances:shortcut', (_e, id, wunschName) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (process.platform !== 'win32') { return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' }; } try { const roh = (wunschName && wunschName.trim()) || inst.name || 'Instanz'; const name = String(roh).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz'; const datei = path.join(app.getPath('desktop'), name + '.lnk'); const ziel = verknuepfungsZiel(); const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', { target: ziel, // Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium // an ihrem Schalter bleibt (siehe instanzAusArgumenten) args: verknuepfungsArgs([`--launch="${id}"`]), cwd: path.dirname(ziel), description: 'AeroMC – ' + inst.name, icon: verknuepfungsIcon(ziel), iconIndex: 0, }); if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' }; return { ok: true, path: datei }; } catch (err) { return { ok: false, message: 'Verknüpfung fehlgeschlagen: ' + err.message }; } }); /* * Desktop-Verknüpfung, die eine Instanz startet UND direkt mit einem * bestimmten Server verbindet (Quick Play, ab Minecraft 1.20). Technisch * dasselbe wie instances:shortcut, nur mit zusätzlichem "--server="-Argument. * wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk) */ ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!ip) return { ok: false, message: 'Keine Serveradresse angegeben.' }; if (process.platform !== 'win32') { return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' }; } try { const rohName = (wunschName && wunschName.trim()) || `${inst.name || 'Instanz'} - ${name || ip}`; const dateiName = String(rohName).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Server'; const datei = path.join(app.getPath('desktop'), `${dateiName}.lnk`); const ziel = verknuepfungsZiel(); const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', { target: ziel, // Gleichheitsform für beide Kennungen, damit sie beim Umsortieren durch // Chromium zusammenbleiben (siehe instanzAusArgumenten) args: verknuepfungsArgs([`--launch="${id}"`, `--server="${ip}"`]), cwd: path.dirname(ziel), description: `AeroMC – ${inst.name} → ${name || ip}`, icon: verknuepfungsIcon(ziel), iconIndex: 0, }); if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' }; return { ok: true, path: datei }; } catch (err) { return { ok: false, message: 'Verknüpfung fehlgeschlagen: ' + err.message }; } }); /* * 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: filePath, VN_DST: tmp }); // instance.json finden (auch in Unterordner) let base = tmp; if (!fs.existsSync(path.join(base, 'instance.json'))) { const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n)) .find((p) => { try { return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, 'instance.json')); } catch { return false; } }); if (sub) base = sub; } // Kein eigener Export? Dann auf eine MultiMC/Prism-Instanz prüfen – // die bringt statt instance.json eine instance.cfg mit. if (!fs.existsSync(path.join(base, 'instance.json'))) { const mmc = mmcimport.scanSingle(tmp); if (mmc.ok) { const done = mmcimport.importOne(store, mmc.instance, true); return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: done.copied }; } throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).'); } const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8')); const created = store.createOrReplaceImportedInstance({ name: imported.name || 'Import', icon: imported.icon || 'grass', notes: imported.notes || '', minecraft: imported.minecraft || {}, }); if (imported.java) store.updateInstance(created.id, { java: imported.java }); const mcSrc = path.join(base, 'minecraft'); if (fs.existsSync(mcSrc)) fs.cpSync(mcSrc, store.gameDir(created.id), { recursive: true }); const modsSrc = path.join(base, 'mods.json'); if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json')); if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) { fs.copyFileSync(path.join(base, imported.customImage), path.join(store.instancePath(created.id), imported.customImage)); store.updateInstance(created.id, { customImage: imported.customImage }); } return { ok: true, instance: store.getInstance(created.id) }; } catch (err) { return { ok: false, message: 'Import fehlgeschlagen: ' + err.message }; } 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 // --------------------------------------------------------------------------- let backupLaeuft = false; /* * Erstellt eine Sicherung aller Instanzen. * still = kein Fortschritt an die Oberfläche (für den Zeitplan im Hintergrund) */ async function erstelleBackup({ still } = {}) { if (backupLaeuft) return { ok: false, message: 'Es läuft bereits eine Sicherung.' }; backupLaeuft = true; const destinationPath = backup.resolveAutoBackupPath(app.getPath('documents')); try { const result = await backup.createGlobalBackup({ instancesDir: store.getInstancesDir(), userDataDir: app.getPath('userData'), includeSettings: true, destinationPath, onProgress: (progress) => { if (!still && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress); }, }); 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 }; } finally { backupLaeuft = false; } } /* * Alte automatische Sicherungen wegräumen. Ohne das läuft die Platte voll – * jede Sicherung enthält sämtliche Instanzen. */ function raeumeAlteBackups(behalten) { const anzahl = Math.max(1, Number(behalten) || 5); try { const alle = backup.listGlobalBackups(app.getPath('documents')); // neueste zuerst for (const b of alle.slice(anzahl)) { try { fs.rmSync(b.path, { force: true }); } catch { /* egal */ } } return Math.max(0, alle.length - anzahl); } catch { return 0; } } const BACKUP_ABSTAND = { daily: 24 * 3600 * 1000, weekly: 7 * 24 * 3600 * 1000 }; // Ist laut Einstellungen eine Sicherung fällig? function backupFaellig(settings, jetzt = Date.now()) { if (!settings.autoBackupEnabled) return false; const abstand = BACKUP_ABSTAND[settings.autoBackupEvery] || BACKUP_ABSTAND.daily; const zuletzt = Date.parse(settings.autoBackupLast || '') || 0; return jetzt - zuletzt >= abstand; } async function backupNachZeitplan(anlass) { const settings = store.getSettings(); if (!settings.autoBackupEnabled) return null; if (anlass === 'zeitplan' && !backupFaellig(settings)) return null; if (backupLaeuft) return null; const res = await erstelleBackup({ still: true }); if (res && res.ok) { store.saveSettings({ autoBackupLast: new Date().toISOString() }); const geloescht = raeumeAlteBackups(settings.autoBackupKeep); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('backup:auto', { ok: true, anlass, datei: res.path || res.destinationPath || '', aufgeraeumt: geloescht, }); } } else if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('backup:auto', { ok: false, anlass, message: res && res.message }); } return res; } ipcMain.handle('backup:global', () => erstelleBackup({ still: false })); ipcMain.handle('backup:list', async () => backup.listGlobalBackups(app.getPath('documents'))); ipcMain.handle('backup:restore', async (_e, sourcePath) => { if (!sourcePath || !String(sourcePath).trim()) { return { ok: false, message: 'Kein Backup ausgewählt.' }; } try { const currentSettings = store.getSettings(); const result = await backup.restoreGlobalBackup({ sourcePath, instancesDir: store.getInstancesDir(), userDataDir: app.getPath('userData'), includeSettings: true, instancesDirSetting: currentSettings.instancesDir || null, onProgress: (progress) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress); }, }); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('backup:progress', { mode: 'restore', percent: 100, detail: 'Wiederherstellen abgeschlossen' }); } return result; } catch (err) { return { ok: false, message: 'Wiederherstellen fehlgeschlagen: ' + err.message }; } }); // Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden). /* * Welche Konten spielen gerade? Konto-ID -> Anzahl laufender Spiele. * Wie bei MultiMC wird das Konto erst beim Start gelesen; ein laufendes Spiel * behält seines. So kann man das aktive Konto wechseln und mit einem zweiten * Konto parallel starten. */ const spielendeKonten = new Map(); function meldeKontenAenderung() { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('accounts:changed'); } function kontoSpieltJetzt(kontoId) { if (!kontoId) return; spielendeKonten.set(kontoId, (spielendeKonten.get(kontoId) || 0) + 1); meldeKontenAenderung(); } function kontoSpieltNichtMehr(kontoId) { if (!kontoId) return; const rest = (spielendeKonten.get(kontoId) || 1) - 1; if (rest > 0) spielendeKonten.set(kontoId, rest); else spielendeKonten.delete(kontoId); meldeKontenAenderung(); } ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!inst.minecraft.version) { return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' }; } const settings = store.getSettings(); // Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung // aussteigt. Greift auch bei von Hand bearbeiteten Instanzdateien. const speicher = services.pruefeSpeicher( inst.java.minMemMb || settings.defaultMinMemMb, inst.java.maxMemMb || settings.defaultMaxMemMb, gesamtSpeicherMb(), ); if (!speicher.ok) { return { ok: false, message: speicher.message + ' Bitte im Bearbeiten-Dialog oder in den Einstellungen anpassen.' }; } // Auf Wunsch vor dem Start sichern. Bewusst abgewartet – eine Sicherung, // die nebenher läuft, während Minecraft dieselben Dateien anfasst, wäre // nicht verlässlich. if (settings.autoBackupEnabled && settings.autoBackupBeforeLaunch) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' }); } await backupNachZeitplan('vor-dem-start').catch(() => {}); } const opts = { versionId: inst.minecraft.version, loader: inst.minecraft.loader || 'vanilla', loaderVersion: inst.minecraft.loaderVersion || '', gameDir: store.gameDir(id), sharedDir: path.join(app.getPath('userData'), 'shared'), userDataDir: app.getPath('userData'), javaPath: inst.java.path || settings.javaPath || '', minMemMb: inst.java.minMemMb || settings.defaultMinMemMb, maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb, extraArgs: inst.java.extraArgs || '', preLaunchCommand: inst.preLaunchCommand || settings.globalPreLaunch || '', postExitCommand: inst.postExitCommand || settings.globalPostExit || '', wrapperCommand: settings.wrapperCommand || '', mcMaximized: !!settings.mcMaximized, mcWidth: settings.mcWidth || 854, mcHeight: settings.mcHeight || 480, auth: null, // fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar) ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress), quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '', }; // eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start const clientId = settings.azureClientId || auth.DEFAULT_CLIENT_ID; const account = accountId ? await auth.getValidAccountById(clientId, accountId) : await auth.getValidAccount(clientId); if (account && account.profile) { opts.auth = { name: account.profile.name, uuid: account.profile.id, accessToken: account.mcAccessToken, userType: 'msa', }; } // Konto dieses Starts merken – nicht das aktive, denn das kann inzwischen // gewechselt worden sein, während dieses Spiel noch läuft. const kontoId = account && account.profile ? account.profile.id : null; let gezaehlt = false; try { let launchStart = 0; const res = await launcher.prepareAndLaunch(opts, (evt) => { if (evt.phase === 'launch') { launchStart = Date.now(); if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); } } if (evt.phase === 'exit') { if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); } if (launchStart) { const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000)); launchStart = 0; // Spielzeit nur einmal gutschreiben const cur = store.getInstance(id); if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs }); } } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt)); } }); // Start fehlgeschlagen, obwohl schon gezählt -> Zählung zurücknehmen, // sonst bliebe das Konto für immer als "spielt" stehen if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); } if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() }); return res; } catch (err) { if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); } return { ok: false, message: 'Start fehlgeschlagen: ' + err.message }; } }); // --------------------------------------------------------------------------- // IPC – Dialoge // --------------------------------------------------------------------------- ipcMain.handle('dialog:pickFolder', async (_e, title) => { const res = await dialog.showOpenDialog(mainWindow, { title: title || 'Ordner wählen', properties: ['openDirectory', 'createDirectory'], }); return res.canceled ? null : res.filePaths[0]; }); ipcMain.handle('dialog:pickImage', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'Instanz-Bild wählen', properties: ['openFile'], filters: [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }], }); return res.canceled ? null : res.filePaths[0]; }); // eigenes Bild einer Instanz zuweisen / entfernen ipcMain.handle('instances:setImage', (_e, id, sourcePath) => { store.setCustomImage(id, sourcePath); return { meta: store.getInstance(id), iconData: store.iconDataUri(id) }; }); ipcMain.handle('instances:clearImage', (_e, id) => store.clearCustomImage(id)); ipcMain.handle('instances:icon', (_e, id) => store.iconDataUri(id)); // beliebige Bilddatei als data:-URI (für Vorschau vor dem Speichern) ipcMain.handle('file:dataUri', (_e, filePath) => { try { const stat = fs.statSync(filePath); if (stat.size > 8 * 1024 * 1024) return null; // >8 MB nicht als Vorschau let ext = path.extname(filePath).slice(1).toLowerCase(); if (ext === 'jpg') ext = 'jpeg'; const buf = fs.readFileSync(filePath); return `data:image/${ext};base64,` + buf.toString('base64'); } catch { return null; } }); ipcMain.handle('dialog:pickJava', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'java.exe wählen', properties: ['openFile'], filters: [{ name: 'Java', extensions: ['exe'] }], }); return res.canceled ? null : res.filePaths[0]; }); ipcMain.handle('app:openExternal', (_e, url) => { if (typeof url === 'string' && /^https?:\/\//.test(url)) shell.openExternal(url); return true; }); // --------------------------------------------------------------------------- // IPC – Instanzen aus MultiMC/Prism übernehmen // --------------------------------------------------------------------------- ipcMain.handle('mmc:pickFolder', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'MultiMC-Ordner wählen (der Ordner mit MultiMC.exe)', properties: ['openDirectory'], }); return res.canceled ? null : res.filePaths[0]; }); ipcMain.handle('mmc:scan', (_e, folder) => { try { return mmcimport.scan(folder); } catch (err) { return { ok: false, reason: err.message }; } }); ipcMain.handle('mmc:import', (_e, entries, copyData) => { const done = []; const failed = []; for (const entry of entries || []) { try { const imported = mmcimport.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('mmc:progress', { current: done.length + failed.length, total: entries.length }); } } return { ok: true, imported: done, failed }; }); ipcMain.handle('cf:pickFolder', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)', properties: ['openDirectory'], }); return res.canceled ? null : res.filePaths[0]; }); ipcMain.handle('cf:scan', (_e, folder) => { try { return cfimport.scan(folder); } catch (err) { return { ok: false, reason: err.message }; } }); ipcMain.handle('cf:import', (_e, entries, copyData) => { const done = []; const failed = []; for (const entry of entries || []) { try { const imported = cfimport.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('cf:progress', { current: done.length + failed.length, total: entries.length }); } } return { ok: true, imported: done, failed }; }); ipcMain.handle('mc:pickFolder', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)', properties: ['openDirectory'], }); return res.canceled ? null : res.filePaths[0]; }); ipcMain.handle('mc:scan', (_e, folder) => { try { return mcimport.scan(folder); } catch (err) { return { ok: false, reason: err.message }; } }); ipcMain.handle('mc:import', (_e, entries, copyData) => { const done = []; const failed = []; for (const entry of entries || []) { try { const imported = mcimport.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('mc:progress', { current: done.length + failed.length, total: entries.length }); } } 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) // --------------------------------------------------------------------------- ipcMain.handle('bedrock:detect', () => bedrock.detect()); ipcMain.handle('bedrock:launch', () => bedrock.launch()); ipcMain.handle('bedrock:worlds', () => bedrock.listWorlds()); ipcMain.handle('bedrock:list', (_e, kind) => bedrock.listFolder(kind)); ipcMain.handle('bedrock:remove', (_e, kind, name) => bedrock.removeEntry(kind, name)); ipcMain.handle('bedrock:openFolder', (_e, kind) => { const p = bedrock.folderPath(kind); if (p) { fs.mkdirSync(p, { recursive: true }); shell.openPath(p); } return true; }); ipcMain.handle('bedrock:installAddon', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'Add-on oder Welt importieren', properties: ['openFile'], filters: [{ name: 'Bedrock-Pakete', extensions: ['mcpack', 'mcaddon', 'mcworld', 'mctemplate'] }], }); if (res.canceled) return { ok: false, canceled: true }; return bedrock.installAddon(res.filePaths[0]); }); // Weltordner im Explorer zeigen ipcMain.handle('bedrock:openWorld', (_e, dir) => { const base = bedrock.folderPath('worlds'); if (base && dir && dir.startsWith(base) && fs.existsSync(dir)) shell.openPath(dir); return true; }); // --------------------------------------------------------------------------- // IPC – echte Minecraft-Icons (aus den eigenen Spieldateien) // --------------------------------------------------------------------------- function iconDirs() { const ud = app.getPath('userData'); const parent = path.dirname(ud); // auch im Datenordner vor der Umbenennung suchen const candidates = [path.join(ud, 'shared')]; for (const alt of ['viper-network', 'Viper-Network', 'aeromc', 'AeroMC']) { const p = path.join(parent, alt, 'shared'); if (!candidates.includes(p)) candidates.push(p); } return { shared: candidates, out: path.join(ud, 'icons') }; } ipcMain.handle('icons:list', () => { const { out } = iconDirs(); return icons.listIcons(out); }); ipcMain.handle('icons:extract', () => { const { shared, out } = iconDirs(); const res = icons.extractIcons(shared, out); if (!res.ok) return { ok: false, reason: res.reason }; return { ok: true, version: res.version, count: res.count, icons: icons.listIcons(out) }; }); // --------------------------------------------------------------------------- // IPC – Automatische Updates // --------------------------------------------------------------------------- ipcMain.handle('update:check', async () => { try { return await updater.checkForUpdate(app.getVersion()); } catch (err) { return { available: false, error: err.message }; } }); // Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden ipcMain.handle('update:install', async (_e, asset) => { try { const file = await updater.downloadAndInstall(asset, (p) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update:progress', p); }); const res = updater.applyUpdate(file); // Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt setTimeout(() => app.quit(), 1200); return { ok: true, path: file.path, kind: file.kind, mode: res.started }; } catch (err) { return { ok: false, message: err.message }; } }); ipcMain.handle('app:info', () => ({ name: pkg.productName || pkg.build?.productName || pkg.name || 'AeroMC', version: app.getVersion(), author: pkg.author || '', homepage: pkg.homepage || '', launcherWebsite: pkg.launcherWebsite || '', }));