'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 } = 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 javadl = require('./javadl'); const crashreports = require('./crashreports'); const skins = require('./skins'); const servers = require('./servers'); // 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; } // Startwunsch an die Oberfläche geben – dort hängt die gesamte Rückmeldung dran function starteInstanzImFenster(id) { if (!id || !mainWindow || mainWindow.isDestroyed()) return; mainWindow.webContents.send('instance:launch-request', id); } app.on('second-instance', (_e, argv) => { if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } starteInstanzImFenster(instanzAusArgumenten(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; } 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() { mainWindow = new BrowserWindow({ width: 1200, height: 820, minWidth: 940, minHeight: 640, backgroundColor: '#1b1e24', title: 'AeroMC', icon: windowIcon(), autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, }, }); mainWindow.loadFile(path.join(__dirname, 'index.html')); // mit "--launch " gestartet -> Instanz anwerfen, sobald die Oberfläche steht const startId = instanzAusArgumenten(process.argv); if (startId) { mainWindow.webContents.once('did-finish-load', () => starteInstanzImFenster(startId)); } } 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()); createWindow(); // 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 (info.available && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('update:available', info); } } catch { /* offline o. Ä. – still ignorieren */ } }, 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(); }); // --------------------------------------------------------------------------- // 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); 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 // --------------------------------------------------------------------------- // IPC – Minecraft-Versionen (Mojang) // --------------------------------------------------------------------------- ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData'))); // --------------------------------------------------------------------------- // IPC – Mods (Modrinth) // --------------------------------------------------------------------------- ipcMain.handle('mods:search', (_e, opts) => services.modrinthSearch(opts)); ipcMain.handle('mods:list', (_e, id) => store.listMods(id)); ipcMain.handle('mods:install', async (_e, id, project) => { 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.' }; if (!loader || loader === 'vanilla') return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' }; const modsDir = store.modsDir(id); const done = new Set(store.listMods(id).map((m) => m.projectId)); const added = []; let firstError = 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 { const res = await services.modrinthInstall({ projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, }); if (!res.ok) { if (!isDep && !firstError) firstError = res.message; return; } store.addMod(id, res.entry); 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 }; }); // 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) => store.removeMod(id, filename)); // 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); }); // 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) => { const res = store.setModEnabled(id, filename, !!enabled); if (res.ok) return res; const gruende = { 'not-found': 'Die Datei liegt nicht mehr im mods-Ordner.', 'both-exist': 'Es liegen beide Fassungen im mods-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) => { 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), gameVersion, loader, (p) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p); }); 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) => { 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 modsDir = store.modsDir(id); const gewuenscht = new Set(projectIds || []); const erneuert = []; const fehler = []; for (const mod of store.listMods(id)) { if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue; try { const res = await services.modrinthInstall({ projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir, }); if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; } store.addMod(id, res.entry); // 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); // war die Mod abgeschaltet, bleibt sie es auch nach dem Update if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false); 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)); 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 }; } }); // ---- 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 files = index.files || []; let i = 0; for (const f of files) { i++; if (f.env && f.env.client === 'unsupported') continue; const url = f.downloads && f.downloads[0]; if (!url || !f.path) continue; const dest = path.join(gameDir, f.path.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' }); } const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher/0.11' } }); if (!res.ok) continue; fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); } // overrides einspielen for (const prefix of ['overrides/', 'client-overrides/']) { for (const name of Object.keys(zip)) { if (name.startsWith(prefix) && !name.endsWith('/')) { 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: files.length }; } // ---- 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: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 }; 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 }; } }); // Verknüpfung auf dem Desktop, die genau diese Instanz startet ipcMain.handle('instances:shortcut', (_e, id) => { 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 name = String(inst.name || 'Instanz').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: `--launch="${id}"`, cwd: path.dirname(ziel), description: 'AeroMC – ' + inst.name, icon: 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 }; } }); 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 }; 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 }); // 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 */ } } }); // --------------------------------------------------------------------------- // 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' }); } 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) => { 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), }; // 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 { done.push(mmcimport.importOne(store, entry, copyData !== false)); } 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 { done.push(cfimport.importOne(store, entry, copyData !== false)); } 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 { done.push(mcimport.importOne(store, entry, copyData !== false)); } 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 }; }); // --------------------------------------------------------------------------- // 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 || '', gitpage: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''), }));