'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, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { execFile, spawn } = require('child_process'); // Statisches ffmpeg-Binary für die Umwandlung der Spielaufzeichnung nach MP4 // (siehe recording:save) - liefert null, falls kein Binary für diese // Plattform/Architektur vorliegt (dann bleibt die Aufnahme als .webm erhalten) let ffmpegPfad = null; try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ } 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 mrimport = require('./mrimport'); 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'); const discordpresence = require('./discordpresence'); const friends = require('./friends'); const crashhelp = require('./crashhelp'); // 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; // --------------------------------------------------------------------------- // Popup-Fenster: alle großen Dialoge als eigene, frei verschiebbare Fenster // (native Titelleiste ersetzt durch den eigenen dunklen Kopfbereich) statt // als Modal im Hauptfenster. Laden dieselbe index.html mit z. B. // "?popout=mods&instanceId=..." – der Renderer blendet dann nur diesen einen // Dialog vollflächig ein. // --------------------------------------------------------------------------- const popoutWindows = new Map(); // modalId -> BrowserWindow const POPOUT_SPEC = { settings: { title: 'AeroMC – Einstellungen', width: 980, height: 720, minWidth: 700, minHeight: 500 }, console: { title: 'AeroMC – Konsole / Log', width: 860, height: 680, minWidth: 640, minHeight: 480 }, mods: { title: 'AeroMC – Mods', width: 900, height: 700, minWidth: 640, minHeight: 480 }, crash: { title: 'AeroMC – Absturzbericht', width: 860, height: 680, minWidth: 640, minHeight: 480 }, logs: { title: 'AeroMC – Logdateien', width: 860, height: 680, minWidth: 640, minHeight: 480 }, content: { title: 'AeroMC – Welten & Pakete', width: 860, height: 680, minWidth: 640, minHeight: 480 }, screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 }, servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 }, skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 }, skineditor: { title: 'AeroMC – Skin-Editor', width: 1180, height: 800, minWidth: 920, minHeight: 600 }, bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 }, login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 }, import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 }, instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 }, update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 }, record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 }, friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 }, stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 }, }; const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 }; let pendingUpdateInfo = null; // letztes Ergebnis von update:check/checkForUpdate, für Update-Popup // Ergebnis der letzten Update-Suche (auch "läuft gerade" oder Fehler) – Fenster, die // erst später aufgehen (z. B. Einstellungen → Info), holen es sich hierüber ab let letzterUpdateCheck = { checking: true }; // Hauptfenster hat seine Empfänger registriert – erst dann den Update-Hinweis schicken const hauptfensterBereit = new Promise((resolve) => ipcMain.once('renderer:ready', resolve)); async function pruefeAufUpdates() { letzterUpdateCheck = { checking: true }; broadcast('update:checked', letzterUpdateCheck); try { const info = await updater.checkForUpdate(app.getVersion()); pendingUpdateInfo = info; letzterUpdateCheck = info; } catch (err) { // offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen letzterUpdateCheck = { available: false, error: (err && err.message) || String(err) }; } broadcast('update:checked', letzterUpdateCheck); return letzterUpdateCheck; } // an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt // muss z. B. auch im herausgelösten Konsolen-Fenster ankommen) function broadcast(channel, payload) { for (const w of [mainWindow, ...popoutWindows.values()]) { if (w && !w.isDestroyed()) w.webContents.send(channel, payload); } } function openPopout(modalId, params) { let win = popoutWindows.get(modalId); if (win && !win.isDestroyed()) { win.show(); win.focus(); // erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu // laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren win.webContents.send('popout:reopen', params || {}); return; } const query = { popout: modalId }; for (const [k, v] of Object.entries(params || {})) if (v !== undefined && v !== null) query[k] = String(v); const spec = POPOUT_SPEC[modalId] || POPOUT_DEFAULT; win = new BrowserWindow({ width: spec.width, height: spec.height, minWidth: spec.minWidth, minHeight: spec.minHeight, title: spec.title || POPOUT_DEFAULT.title, backgroundColor: fensterFarbe(), icon: windowIcon(), frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, // Clip-Modus nimmt auch bei geschlossenem (verstecktem) Fenster weiter auf – // ohne das würde Chromium Zeichenschleife und Zeitgeber dort anhalten backgroundThrottling: modalId !== 'record', }, }); popoutWindows.set(modalId, win); win.loadFile(path.join(__dirname, 'index.html'), { query }); win.once('ready-to-show', () => win.show()); win.webContents.on('did-fail-load', (_e, code, desc) => { console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc); }); win.on('close', (e) => { if (appIsQuitting) return; e.preventDefault(); // Läuft gerade eine Aufzeichnung, darf ein natives Schließen (Alt+F4, // Taskleiste) sie nicht stillschweigend verstecken -> der Renderer // entscheidet selbst (Stoppen+Speichern oder Abbrechen) if (modalId === 'record' && recordingGuardActive) { win.webContents.send('record:closeAttempt'); return; } win.hide(); }); win.on('closed', () => { popoutWindows.delete(modalId); }); } let recordingGuardActive = false; ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; }); ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg)); ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params)); // Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster, // die Popup-Fenster haben ihren eigenen Kopf) ipcMain.on('window:titleBar', (e, design) => { if (!mainWindow || mainWindow.isDestroyed() || e.sender !== mainWindow.webContents) return; try { mainWindow.setTitleBarOverlay(titelleistenFarben(design)); } catch { /* ältere Windows-Fassung */ } }); ipcMain.on('popout:hide', (e) => { const w = BrowserWindow.fromWebContents(e.sender); if (w && !w.isDestroyed()) w.hide(); }); ipcMain.handle('update:getPending', () => pendingUpdateInfo); // --------------------------------------------------------------------------- // Spielaufzeichnung: Fenster-Erkennung per Fenstertitel (kein PID-Tracking // nötig dafür - funktioniert unabhängig von Konto/settings.wrapperCommand). // Video kommt vom Fenster, Systemton separat vom Bildschirm (Chromium erlaubt // keine Pro-Fenster-Tonaufnahme) - siehe renderer.js für den Aufnahme-Teil. // Die PID wird NUR für die optionale isolierte "Nur Minecraft"-Tonspur // gebraucht (per-Prozess-Aufnahme via loopback-capture, siehe unten). // --------------------------------------------------------------------------- const letzteQuickPlayServer = new Map(); // instanceId -> Server-Adresse // Minecraft schreibt diese Zeile bei JEDER Verbindung zu einem Server ins Log // - egal ob per Quick Play oder von Hand im Spiel verbunden, und egal ob das // Aufzeichnungs-Fenster dabei schon offen war. Hier (statt im Renderer) // verfolgt, weil das hier zentral für alle Fenster gilt, auch für ein erst // SPÄTER geöffnetes Aufzeichnungs-Popup, das die Zeile sonst verpasst hätte. const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i; ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null); const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss auf DEM Fenster // des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-Fenster // (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein // Fenster zuerst gefunden wurde, obwohl Electric__Max der Freund war, der geprüft hat) const kontoInstanz = new Map(); // kontoId -> instanceId des gerade laufenden Spiels (für "Einladen": eigener Server) // Ende der Konsolenausgabe des letzten Starts je Instanz – für den Absturz-Assistenten, // weil frühe Fehler (falsches Java, zu viel RAM) in keiner Datei landen const letzteAusgabe = new Map(); const AUSGABE_MAX = 200000; // Herzschläge aller laufenden Spiele – um eine geänderte Sichtbarkeit sofort zu melden const laufendeHerzschlaege = new Set(); ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null); // Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden // soll - vom Renderer kurz vor dem eigentlichen Aufruf gesetzt (siehe // setDisplayMediaRequestHandler weiter unten). Für den cursorfreien // Erfassungsversuch: ganzer Bildschirm ohne Mauszeiger, der Zuschnitt aufs // Minecraft-Fenster passiert danach per Canvas im Renderer (siehe // recording:getWindowBounds). Schlägt das fehl, läuft die Erfassung über die // alte, zuverlässige chromeMediaSource-Fenster-Methode (Mauszeiger sichtbar). let ausstehendeErfassungsQuelle = null; ipcMain.handle('recording:setCaptureTarget', (_e, sourceId) => { ausstehendeErfassungsQuelle = sourceId; }); // Sucht das Fenster des laufenden Minecraft-Prozesses (über die PID aus // laufendePid, robuster als ein Titel-Abgleich) und ermittelt daraus: // - die aktuellen Fenstergrenzen in Bildschirmkoordinaten // - den Bildschirm, auf dem das Fenster gerade sitzt (Mehrschirm-fähig) // - die zu diesem Bildschirm gehörende Erfassungsquelle für getDisplayMedia // Liefert null, wenn das Fenster nicht gefunden wird (z. B. Minecraft nicht // über den Launcher gestartet, oder bereits beendet) - der Renderer fällt in // dem Fall auf die alte Fenster-Erfassung zurück statt abzustürzen. async function findeFensterUndBildschirm(pid, fensterTitel) { let windowManager; try { ({ windowManager } = require('node-window-manager')); } catch (err) { console.log('[Aufzeichnung] node-window-manager Fehler:', err.message); return null; } let alleFenster; try { alleFenster = windowManager.getWindows(); } catch (err) { console.log('[Aufzeichnung] getWindows Fehler:', err.message); return null; } // Die PID kennt der Launcher nur für Prozesse, die er in DIESER laufenden // Sitzung selbst gestartet hat - nach einem Neustart des Launchers (z. B. // während Minecraft weiterläuft) ist laufendePid leer, obwohl das Spiel // noch läuft. Dann über den EXAKTEN Fenstertitel suchen, den der Renderer // schon über recording:listWindows (dort per /Minecraft/i gefiltert und // vom Nutzer im Suchdialog bestätigt) positiv identifiziert hat - NICHT // erneut breit nach /Minecraft/i suchen, das kann auf einem Rechner mit // vielen offenen Fenstern (Editor, Browser-Tabs usw.) ein völlig falsches, // unbeteiligtes Fenster treffen und dessen Inhalt zeigen/aufnehmen. let fenster = pid ? alleFenster.find((w) => w.processId === pid) : null; if (!fenster && fensterTitel) { fenster = alleFenster.find((w) => { try { return w.getTitle() === fensterTitel; } catch { return false; } }); } if (!fenster) { console.log('[Aufzeichnung] kein Minecraft-Fenster gefunden (PID', pid, ', Titel', fensterTitel, ')'); return null; } const fensterBounds = fenster.getBounds(); if (!fensterBounds || !fensterBounds.width || !fensterBounds.height) { console.log('[Aufzeichnung] ungültige Fenstergrenzen:', JSON.stringify(fensterBounds)); return null; } const anzeige = screen.getDisplayMatching(fensterBounds); const screenSources = await desktopCapturer.getSources({ types: ['screen'] }); const quelle = screenSources.find((s) => String(s.display_id) === String(anzeige.id)) || screenSources[0]; if (!quelle) { console.log('[Aufzeichnung] keine Bildschirmquelle gefunden'); return null; } return { fensterBounds, bildschirmBounds: anzeige.bounds, screenSourceId: quelle.id, }; } ipcMain.handle('recording:getWindowBounds', async (_e, instanceId, fensterTitel) => { const pid = laufendePid.get(instanceId); return findeFensterUndBildschirm(pid, fensterTitel); }); // Isolierte Ton-Aufnahme nur eines Prozesses (inkl. Kindprozesse) über // Windows' Process-Loopback-API. Nur eine Aufnahme gleichzeitig aktiv. let aktiveSpielAudioCapture = null; ipcMain.handle('recording:startGameAudio', (event, pid) => { if (aktiveSpielAudioCapture) return { ok: false, message: 'Läuft bereits.' }; let loopback; try { loopback = require('loopback-capture'); } catch (err) { return { ok: false, message: 'Spielton-Isolierung nicht verfügbar: ' + err.message }; } const win = BrowserWindow.fromWebContents(event.sender); try { const capture = new loopback.LoopbackCapture(); capture.start(pid, true, (chunk) => { if (win && !win.isDestroyed()) win.webContents.send('recording:gameAudioChunk', new Uint8Array(chunk)); }); aktiveSpielAudioCapture = { capture }; return { ok: true }; } catch (err) { return { ok: false, message: 'Spielton-Aufnahme fehlgeschlagen (Windows 10 Version 2004+ erforderlich, oder Spiel bereits beendet): ' + err.message }; } }); ipcMain.handle('recording:stopGameAudio', () => { if (aktiveSpielAudioCapture) { try { aktiveSpielAudioCapture.capture.stop(); } catch { /* egal, wird verworfen */ } aktiveSpielAudioCapture = null; } return true; }); ipcMain.handle('recording:listWindows', async () => { const sources = await desktopCapturer.getSources({ types: ['window'], thumbnailSize: { width: 200, height: 120 }, }); return sources .filter((s) => /Minecraft/i.test(s.name)) .map((s) => ({ id: s.id, name: s.name, thumbnail: s.thumbnail && !s.thumbnail.isEmpty() ? s.thumbnail.toDataURL() : null, })); }); // Für den Systemton reicht irgendeine Bildschirmquelle als Pflichtfeld für // getUserMedia -- der Ton ist ohnehin nicht pro Bildschirm getrennt. ipcMain.handle('recording:getAudioSourceId', async () => { const sources = await desktopCapturer.getSources({ types: ['screen'] }); return (sources[0] && sources[0].id) || null; }); function aufzeichnungsOrdner(instanceName) { return path.join(app.getPath('videos'), 'AeroMC Launcher', String(instanceName || 'Instanz').replace(/[^\w.-]+/g, '_')); } // Aufnahme kommt vom MediaRecorder als WebM - für Abspielbarkeit in // praktisch jedem Player (Windows Media Player, Smart-TVs, Handys usw.) wird // direkt nach dem Speichern per ffmpeg in eine MP4-Datei (H.264 + AAC) // umgewandelt, das temporäre WebM danach gelöscht. // // -r erzwingt eine SAUBERE, konstante Bildrate im Ausgabevideo: die // Aufnahme kommt aus einer Canvas-Zeichenschleife (für Zuschnitt/Skalierung, // siehe erzeugeSkaliertenStream im Renderer) und deren Bild-Zeitstempel sind // nicht perfekt gleichmäßig - ohne -r übernimmt ffmpeg diese unregelmäßigen // Zeitstempel unverändert, was zu einer falschen/kaputten Bildrate im // Ausgabevideo führt (z. B. fälschlich "1000fps" laut ffprobe) und manche // Player die Datei gar nicht erst abspielen lässt. function wandleInMp4Um(quellDatei, zielDatei, fps) { return new Promise((resolve, reject) => { execFile(ffmpegPfad, [ '-y', '-i', quellDatei, '-r', String(fps || 30), '-vsync', 'cfr', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', zielDatei, ], (err, _stdout, stderr) => { if (err) reject(new Error(stderr ? String(stderr).split('\n').slice(-5).join(' ') : err.message)); else resolve(); }); }); } ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel, fps) => { let tempDatei = null; try { const zielOrdner = aufzeichnungsOrdner(instanceName); fs.mkdirSync(zielOrdner, { recursive: true }); const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16); const serverTeil = serverLabel ? String(serverLabel).replace(/[^\w.-]+/g, '_') + '_' : ''; let zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}.mp4`); let n = 2; while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.mp4`); n++; } tempDatei = zielDatei.slice(0, -4) + '.webm.tmp'; fs.writeFileSync(tempDatei, Buffer.from(buffer)); if (ffmpegPfad) { await wandleInMp4Um(tempDatei, zielDatei, fps); fs.rmSync(tempDatei, { force: true }); } else { // ffmpeg fehlt (sollte nur in einer kaputten Installation vorkommen) - // wenigstens das unveränderte WebM behalten statt die Aufnahme zu verlieren zielDatei = zielDatei.slice(0, -4) + '.webm'; fs.renameSync(tempDatei, zielDatei); } notify('Aufzeichnung gespeichert', `${instanceName}: ${path.basename(zielDatei)}`); return { ok: true, path: zielDatei }; } catch (err) { if (tempDatei) fs.rmSync(tempDatei, { force: true }); return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; } }); // ---------- Clip-Modus ---------- // Tastenkürzel wie bei ShadowPlay; ist es schon vergeben (z. B. von ShadowPlay // selbst), weicht AeroMC auf Strg+Alt+C aus. const CLIP_TASTENKUERZEL = ['Alt+F10', 'Control+Alt+C']; let clipKuerzel = null; let clipInstanz = null; ipcMain.handle('clip:setActive', (_e, aktiv, instanceId) => { if (clipKuerzel) { globalShortcut.unregister(clipKuerzel); clipKuerzel = null; } clipInstanz = aktiv ? instanceId : null; if (!aktiv) return null; for (const kuerzel of CLIP_TASTENKUERZEL) { const ok = globalShortcut.register(kuerzel, () => { const pid = clipInstanz && laufendePid.get(clipInstanz); if (pid) zeigeIngameOverlay('Clip', 'Wird gespeichert …', pid, 2500).catch(() => {}); const win = popoutWindows.get('record'); if (win && !win.isDestroyed()) win.webContents.send('clip:hotkey'); }); if (ok) { clipKuerzel = kuerzel; break; } } return clipKuerzel; }); // Abschnitte zusammensetzen, vorne auf die Clip-Länge kürzen, als MP4 speichern ipcMain.handle('clip:save', async (_e, daten) => { const d = daten || {}; const abschnitte = Array.isArray(d.abschnitte) ? d.abschnitte : []; if (!abschnitte.length) return { ok: false, message: 'Keine Clip-Daten.' }; if (!ffmpegPfad) return { ok: false, message: 'ffmpeg fehlt – Clips können nicht gespeichert werden.' }; const arbeitsOrdner = fs.mkdtempSync(path.join(app.getPath('temp'), 'aeromc-clip-')); try { const liste = abschnitte.map((buf, i) => { const datei = path.join(arbeitsOrdner, `teil${i}.webm`); fs.writeFileSync(datei, Buffer.from(buf)); return `file '${datei.replace(/\\/g, '/').replace(/'/g, "'\\''")}'`; }); const listenDatei = path.join(arbeitsOrdner, 'liste.txt'); fs.writeFileSync(listenDatei, liste.join('\n'), 'utf8'); const zielOrdner = aufzeichnungsOrdner(d.instanceName); fs.mkdirSync(zielOrdner, { recursive: true }); const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); const serverTeil = d.serverLabel ? String(d.serverLabel).replace(/[^\w.-]+/g, '_') + '_' : ''; let zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}.mp4`); let n = 2; while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `Clip_${serverTeil}${zeitstempel}_${n}.mp4`); n++; } await new Promise((resolve, reject) => { execFile(ffmpegPfad, [ '-y', '-f', 'concat', '-safe', '0', '-i', listenDatei, '-ss', String(Math.max(0, Number(d.vorneAbschneiden) || 0).toFixed(2)), '-r', String(d.fps || 30), '-vsync', 'cfr', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', zielDatei, ], { maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => { if (err) reject(new Error(stderr ? String(stderr).split('\n').slice(-5).join(' ') : err.message)); else resolve(); }); }); const pid = d.instanceId && laufendePid.get(d.instanceId); if (pid) zeigeIngameOverlay('Clip gespeichert', `${d.laenge || ''} Sek. · ${path.basename(zielDatei)}`, pid, 5000).catch(() => {}); return { ok: true, path: zielDatei }; } catch (err) { return { ok: false, message: 'Clip konnte nicht gespeichert werden: ' + err.message }; } finally { fs.rmSync(arbeitsOrdner, { recursive: true, force: true }); } }); ipcMain.handle('recording:openFolder', (_e, instanceName) => { const dir = aufzeichnungsOrdner(instanceName); fs.mkdirSync(dir, { recursive: true }); shell.openPath(dir); return true; }); // ---------- Aufzeichnungen-Galerie ---------- ipcMain.handle('recording:list', (_e, instanceName) => { const ordner = aufzeichnungsOrdner(instanceName); try { return fs.readdirSync(ordner) .filter((name) => name.toLowerCase().endsWith('.mp4')) .map((name) => { const stat = fs.statSync(path.join(ordner, name)); return { name, size: stat.size, mtimeMs: stat.mtimeMs }; }) .sort((a, b) => b.mtimeMs - a.mtimeMs); } catch { return []; } }); ipcMain.handle('recording:thumbnail', async (_e, instanceName, dateiname) => { if (!ffmpegPfad) return null; const ordner = aufzeichnungsOrdner(instanceName); const quelle = path.join(ordner, dateiname); if (!fs.existsSync(quelle)) return null; const cacheOrdner = path.join(ordner, '.thumbnails'); const cacheDatei = path.join(cacheOrdner, dateiname + '.jpg'); if (!fs.existsSync(cacheDatei)) { fs.mkdirSync(cacheOrdner, { recursive: true }); await new Promise((resolve) => { // Erster Versuch bei 1s (überspringt oft ein schwarzes/leeres Startbild), // Fehler wird ignoriert - dann bleibt einfach kein Vorschaubild execFile(ffmpegPfad, ['-y', '-ss', '00:00:01', '-i', quelle, '-frames:v', '1', '-vf', 'scale=240:-1', cacheDatei], () => resolve()); }); } try { if (!fs.existsSync(cacheDatei)) return null; return 'data:image/jpeg;base64,' + fs.readFileSync(cacheDatei).toString('base64'); } catch { return null; } }); ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => { const ordner = aufzeichnungsOrdner(instanceName); try { fs.rmSync(path.join(ordner, dateiname), { force: true }); fs.rmSync(path.join(ordner, '.thumbnails', dateiname + '.jpg'), { force: true }); return true; } catch { return false; } }); ipcMain.handle('recording:open', (_e, instanceName, dateiname) => { const ziel = path.join(aufzeichnungsOrdner(instanceName), dateiname); if (fs.existsSync(ziel)) shell.openPath(ziel); return true; }); // --------------------------------------------------------------------------- // 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. // Rechtsklick-Menü für Textfelder und markierten Text – Electron bringt von sich // aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V. // Gilt für Haupt- und alle Popup-Fenster. app.on('web-contents-created', (_e, contents) => { contents.on('context-menu', (_ev, params) => { const auswahl = String(params.selectionText || '').trim(); if (!params.isEditable && !auswahl) return; const f = params.editFlags || {}; const vorlage = params.isEditable ? [ { label: 'Ausschneiden', role: 'cut', enabled: !!f.canCut }, { label: 'Kopieren', role: 'copy', enabled: !!f.canCopy }, { label: 'Einfügen', role: 'paste', enabled: !!f.canPaste }, { type: 'separator' }, { label: 'Alles auswählen', role: 'selectAll', enabled: !!f.canSelectAll }, ] : [{ label: 'Kopieren', role: 'copy', enabled: !!f.canCopy }]; const fenster = BrowserWindow.fromWebContents(contents); Menu.buildFromTemplate(vorlage).popup(fenster ? { window: fenster } : {}); }); }); // Eigene App-Kennung für Windows (gleich wie appId in package.json und die // Startmenü-Verknüpfung des Installers). Ohne das zeigt Windows bei // Benachrichtigungen "electron.app.AeroMC" ohne Logo an. const APP_KENNUNG = 'com.aeromc.launcher'; if (process.platform === 'win32') app.setAppUserModelId(APP_KENNUNG); /* * Name und Logo dieser App-Kennung bei Windows hinterlegen * (HKCU\Software\Classes\AppUserModelId\). Damit stimmt die Anzeige * auch ohne Startmenü-Verknüpfung, z. B. bei der Portable-Fassung. Das Logo * liegt im Paket (app.asar), das Windows nicht lesen kann – deshalb eine Kopie * im Datenordner. */ let benachrichtigungsLogo = null; function registriereAppBeiWindows() { if (process.platform !== 'win32') return; try { const ziel = path.join(app.getPath('userData'), 'aeromc-logo.png'); const quelle = path.join(__dirname, 'assets', 'logo.png'); if (fs.existsSync(quelle)) { const bild = nativeImage.createFromPath(quelle); if (!bild.isEmpty()) { fs.writeFileSync(ziel, bild.resize({ width: 128, height: 128, quality: 'best' }).toPNG()); benachrichtigungsLogo = ziel; } } const schluessel = `HKCU\\Software\\Classes\\AppUserModelId\\${APP_KENNUNG}`; execFile('reg', ['add', schluessel, '/v', 'DisplayName', '/t', 'REG_SZ', '/d', 'AeroMC', '/f'], { windowsHide: true }, () => {}); if (benachrichtigungsLogo) { execFile('reg', ['add', schluessel, '/v', 'IconUri', '/t', 'REG_SZ', '/d', benachrichtigungsLogo, '/f'], { windowsHide: true }, () => {}); } } catch { /* rein kosmetisch */ } } 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; } // Eigene Titelleiste im Hauptfenster: Minimieren/Maximieren/Schließen bleiben // echte Windows-Knöpfe (Andocken, Fensterlayouts …), Windows zeichnet sie aber // in den Farben des Designs. Die Farben müssen zu --titel-bg/--titel-fg in // styles.css bzw. designs.css passen. const TITELLEISTE = { dark: { color: '#2d2d30', symbolColor: '#e4e4e6' }, light: { color: '#f0f0f0', symbolColor: '#1c1c1c' }, aero: { color: '#0a0e1a', symbolColor: '#e8ecfb' }, lila: { color: '#0f0a1a', symbolColor: '#f0eafb' }, minecraft: { color: '#1e1e1f', symbolColor: '#ffffff' }, }; function titelleistenFarben(design) { return Object.assign({ height: 32 }, TITELLEISTE[design] || TITELLEISTE.dark); } // Grundfarbe eines neuen Fensters passend zum Design – sonst blitzt beim // Öffnen kurz die falsche Farbe auf, bis die Oberfläche gezeichnet ist function fensterFarbe() { const design = (store.getSettings() || {}).theme; return { light: '#f0f0f0', aero: '#05070d', lila: '#08050f', minecraft: '#313233' }[design] || '#1b1e24'; } 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: fensterFarbe(), title: 'AeroMC', icon: windowIcon(), autoHideMenuBar: true, titleBarStyle: 'hidden', titleBarOverlay: titelleistenFarben((store.getSettings() || {}).theme), 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) { // Hauptfenster schließt wirklich -> keine nur versteckten Popup-Fenster // zurücklassen (die sonst window-all-closed nie erreichen ließen) for (const w of popoutWindows.values()) if (!w.isDestroyed()) w.destroy(); 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)); } ipcMain.once('renderer:ready', () => oeffneVorbereitetenImport()); } // Freundesystem-Anmeldung: Minecraft-Zugang eines Kontos für Mojangs Sitzungsprüfung friends.setzeZugangsHelfer(async (uuid) => { const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID; const konto = await auth.getValidAccountById(clientId, uuid).catch(() => null); return konto && konto.profile && konto.mcAccessToken ? { accessToken: konto.mcAccessToken, name: konto.profile.name } : null; }); /* * Das Setup kann optional andere Launcher übernehmen lassen (installer/Installer.cs): * es legt dazu pending-import.json mit den angekreuzten Launchern ab. Beim ersten * sichtbaren Start öffnet sich der Import-Dialog damit schon fertig durchsucht – * übernommen wird erst, was man dort bestätigt. Die Datei gilt nur einmal. */ function oeffneVorbereitetenImport() { const datei = path.join(app.getPath('userData'), 'pending-import.json'); if (!fs.existsSync(datei)) return; // im Autostart unsichtbar gestartet: erst anbieten, wenn das Fenster gezeigt wird if (mainWindow && !mainWindow.isVisible()) { mainWindow.once('show', () => oeffneVorbereitetenImport()); return; } let quellen = []; try { const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, '')); quellen = (Array.isArray(daten.sources) ? daten.sources : []) .filter((q) => q && ['mc', 'cf', 'mr', 'mmc', 'technic'].includes(q.kind)) .map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' })); } catch { /* beschädigt -> verwerfen */ } try { fs.unlinkSync(datei); } catch { /* egal */ } if (!quellen.length) return; openPopout('import', { kind: quellen[0].kind, vorauswahl: JSON.stringify(quellen) }); } 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'); // Menü bei jedem Rechtsklick neu bauen – so sind Favoriten und zuletzt // gespielte Instanzen immer aktuell tray.on('right-click', () => tray.popUpContextMenu(trayMenue())); tray.on('click', () => showMainWindow()); } catch { /* z. B. wenn kein Icon vorhanden ist – Autostart läuft dann ohne Tray-Symbol weiter */ } } // Instanzen direkt aus dem Infobereich starten, ohne das Fenster zu öffnen: // erst die Schnellstart-Favoriten (Strg+1 … 5), dann die zuletzt gespielten function trayMenue() { const alle = store.listInstances(); const nachId = new Map(alle.map((i) => [i.id, i])); const schnell = store.getSettings().schnellstart || {}; const favoriten = Object.keys(schnell).sort() .map((taste) => ({ taste, inst: nachId.get(schnell[taste]) })) .filter((f) => f.inst); const favIds = new Set(favoriten.map((f) => f.inst.id)); const zuletzt = alle.filter((i) => i.lastPlayed && !favIds.has(i.id)) .sort((a, b) => String(b.lastPlayed).localeCompare(String(a.lastPlayed))) .slice(0, 5); const starte = (inst) => () => starteInstanzImFenster(inst.id); const eintrag = (inst, zusatz) => ({ label: `${inst.name} · ${inst.minecraft.version || '?'}${zusatz ? ' ' + zusatz : ''}`, click: starte(inst), }); const vorlage = [{ label: 'AeroMC öffnen', click: () => showMainWindow() }, { type: 'separator' }]; if (favoriten.length) { vorlage.push({ label: 'Schnellstart', enabled: false }); favoriten.forEach((f) => vorlage.push(eintrag(f.inst, `Strg+${f.taste}`))); } if (zuletzt.length) { if (favoriten.length) vorlage.push({ type: 'separator' }); vorlage.push({ label: 'Zuletzt gespielt', enabled: false }); zuletzt.forEach((i) => vorlage.push(eintrag(i))); } if (!favoriten.length && !zuletzt.length) vorlage.push({ label: 'Noch keine Instanz gespielt', enabled: false }); vorlage.push({ type: 'separator' }, { label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } }); return Menu.buildFromTemplate(vorlage); } function destroyTray() { if (tray) { tray.destroy(); tray = null; } } // Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden function applyAutostart(enabled) { try { // Gleiches Problem wie bei den Desktop-Verknüpfungen (siehe verknuepfungsArgs // weiter oben): im Entwicklungsmodus zeigt process.execPath nur auf die nackte // electron.exe, die ohne Pfadangabe ihren eigenen leeren Startbildschirm zeigt // statt AeroMC zu laden. Zusätzlich: bei der Portable-Fassung zeigt // process.execPath auf den entpackten Temp-Ordner – der wäre morgen tot, // deshalb wie bei den Verknüpfungen über verknuepfungsZiel() auflösen. const args = enabled ? (app.isPackaged ? ['--hidden'] : [path.join(__dirname, '..'), '--hidden']) : []; app.setLoginItemSettings({ openAtLogin: !!enabled, path: verknuepfungsZiel(), args }); } catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ } // Symbol im Infobereich gibt es immer (Schnellstart per Rechtsklick) createTray(); } app.whenReady().then(() => { if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst registriereAppBeiWindows(); 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()); discordpresence.configure(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); // Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog: // das vom Renderer per recording:setCaptureTarget gewählte Fenster wird // hier direkt zurückgegeben, statt den Nutzer nochmal wählen zu lassen session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => { desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { const quelle = sources.find((s) => s.id === ausstehendeErfassungsQuelle) || sources[0]; console.log('[Aufzeichnung] setDisplayMediaRequestHandler: angefordert=', ausstehendeErfassungsQuelle, 'gewählt=', quelle && quelle.id); callback(quelle ? { video: quelle } : {}); }); }, { useSystemPicker: false }); // 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); // Freundesystem: alle 60s im Hintergrund prüfen, ob ein Freund neu online // gegangen ist -> Windows-Meldung. Der erste Durchlauf legt nur die // Vergleichsbasis an (noch keine Meldung, sonst würde jeder schon online // befindliche Freund beim Start fälschlich als "gerade online gegangen" gelten). setTimeout(() => { pruefeFreundeOnline().catch(() => {}); }, 15000); setInterval(() => { pruefeFreundeOnline().catch(() => {}); }, 60000); // Einladungen ("Komm auf meinen Server") öfter prüfen – sie gelten nur ein paar Minuten setTimeout(() => { pruefeEinladungen().catch(() => {}); }, 8000); setInterval(() => { pruefeEinladungen().catch(() => {}); }, 15000); setTimeout(() => { pruefeNachrichten().catch(() => {}); }, 9000); setInterval(() => { pruefeNachrichten().catch(() => {}); }, 15000); // Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000); setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000); // gleich beim Start auf Updates prüfen; den Hinweis-Dialog aber erst zeigen, // wenn das Hauptfenster bereit ist (sonst ginge er verloren) pruefeAufUpdates().then(async (info) => { if (!info.available) return; await hauptfensterBereit; broadcast('update:available', info); }).catch(() => {}); // 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) broadcast('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; discordpresence.shutdown(); }); app.on('will-quit', () => { globalShortcut.unregisterAll(); }); // --------------------------------------------------------------------------- // 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); if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s); if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsEnabled')) { aktualisiereFreundeTastenkuerzel(); aktualisiereAnfragenZahl().catch(() => {}); } // Server ein-/ausgeblendet -> Freunden gleich melden, nicht erst beim nächsten Herzschlag if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsShareServer')) { for (const herzschlag of laufendeHerzschlaege) herzschlag(); } // Einstellungen können jetzt auch aus dem Popup-Fenster gespeichert werden – // das Hauptfenster muss Theme/Sortierung/Bedrock-Sichtbarkeit/Skin-Hintergrund // dann selbst nachziehen. NICHT bei der beiläufigen "letzte Instanz"-Notiz // (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das // Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert. const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId'); // an alle Fenster: das Design muss auch in offenen Popup-Fenstern wechseln if (!nurLetzteInstanz) broadcast('settings:changed', 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 // 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', (_e, id, project, art) => installiereInhalt(id, project, art)); // installiert ein Projekt (Modrinth oder CurseForge) samt Pflicht-Abhängigkeiten – // auch vom Performance-Paket beim Anlegen einer Instanz genutzt async function installiereInhalt(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', versionId: isDep ? '' : (proj.versionId || ''), }); 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 }; } /* * Performance-Paket: bewährte Optimierungs-Mods, per Häkchen beim Anlegen einer * Instanz mitinstalliert. Feste Modrinth-IDs statt Namen, damit sie sich mit den * automatisch nachgezogenen Abhängigkeiten nicht doppeln. Was es für Version * oder Loader (noch) nicht gibt, wird einfach übersprungen. */ const PERFORMANCE_MODS = [ { projectId: 'AANobbMI', title: 'Sodium' }, // Grafik/FPS { projectId: 'gvQqBUqZ', title: 'Lithium' }, // Spiellogik { projectId: 'uXXizFIs', title: 'FerriteCore' }, // Arbeitsspeicher { projectId: '5ZwdcRci', title: 'ImmediatelyFast' }, // Darstellung (HUD, Text) { projectId: 'NNAgCjsB', title: 'Entity Culling' }, // verdeckte Objekte nicht zeichnen { projectId: 'nmDcB62a', title: 'ModernFix' }, // Ladezeit/Speicher ]; const PERFORMANCE_LOADER = new Set(['fabric', 'quilt', 'neoforge']); ipcMain.handle('instances:installPerformancePack', async (_e, id) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!PERFORMANCE_LOADER.has(inst.minecraft.loader)) { return { ok: false, message: 'Das Performance-Paket gibt es für Fabric, Quilt und NeoForge.' }; } const installiert = []; const fehlend = []; broadcast('ui:toast', { text: `Performance-Paket für „${inst.name}" wird installiert …` }); for (const mod of PERFORMANCE_MODS) { const res = await installiereInhalt(id, { projectId: mod.projectId, title: mod.title }, 'mod').catch((err) => ({ ok: false, message: err.message })); if (res.ok) installiert.push(mod.title); else fehlend.push(mod.title); } broadcast('mods:changed', { id }); broadcast('ui:toast', installiert.length ? { text: `Performance-Paket: ${installiert.length} von ${PERFORMANCE_MODS.length} Mods installiert` + (fehlend.length ? ` (für diese Version nicht verfügbar: ${fehlend.join(', ')})` : '') + '.' } : { text: 'Performance-Paket: Für diese Version/diesen Loader gibt es die Mods (noch) nicht.', error: true }); return { ok: true, installiert, fehlend }; }); /* * 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(BrowserWindow.fromWebContents(e.sender) || 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 []; } }); // --------------------------------------------------------------------------- // OptiFine – kein offizielles Download-API (Downloads laufen bei OptiFine // bewusst über eine Werbeseite, das umgehen wir nicht). Stattdessen: Nutzer // lädt den Installer selbst von optifine.net, AeroMC bereitet Vanilla vor, // startet den echten Installer sichtbar mit dem passenden Zielordner in der // Zwischenablage und übernimmt danach die neu entstandene Profil-Version. // --------------------------------------------------------------------------- const optifineSetupState = new Map(); // instanceId -> { sharedDir, before: Set } function scanOptifineVersions(sharedDir, mcVersion) { const versionsDir = path.join(sharedDir, 'versions'); if (!fs.existsSync(versionsDir)) return []; const prefix = String(mcVersion || ''); return fs.readdirSync(versionsDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) .filter((name) => /optifine/i.test(name) && (!prefix || name.startsWith(prefix))) .filter((name) => fs.existsSync(path.join(versionsDir, name, name + '.json'))) .sort(); } ipcMain.handle('optifine:find', (_e, mcVersion) => { const sharedDir = path.join(app.getPath('userData'), 'shared'); return scanOptifineVersions(sharedDir, mcVersion); }); ipcMain.handle('optifine:setup', async (_e, id) => { 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.' }; const picked = await dialog.showOpenDialog(mainWindow, { title: 'OptiFine-Installer wählen (zuvor von optifine.net heruntergeladen)', properties: ['openFile'], filters: [{ name: 'OptiFine-Installer', extensions: ['jar'] }], }); if (picked.canceled || !picked.filePaths.length) return { ok: false, canceled: true }; const installerPath = picked.filePaths[0]; const settings = store.getSettings(); const sharedDir = path.join(app.getPath('userData'), 'shared'); const opts = { versionId: inst.minecraft.version, loader: 'vanilla', loaderVersion: '', gameDir: store.gameDir(id), sharedDir, userDataDir: app.getPath('userData'), javaPath: inst.java.path || settings.javaPath || '', minMemMb: inst.java.minMemMb || settings.defaultMinMemMb, maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb, extraArgs: '', auth: null, ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress), }; // Vanilla-Dateien sicherstellen (kein echter Start, da kein Konto übergeben wird) let prep; try { prep = await launcher.prepareAndLaunch(opts, (evt) => { broadcast('launch:progress', Object.assign({ id }, evt)); }); } catch (err) { return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message }; } if (!prep.needAuth && !prep.ok) return { ok: false, message: prep.message || 'Vorbereitung fehlgeschlagen.' }; const javaExe = (prep.java && prep.java.path) || opts.javaPath || 'java'; optifineSetupState.set(id, { sharedDir, before: new Set(scanOptifineVersions(sharedDir, inst.minecraft.version)), }); try { const child = spawn(javaExe, ['-jar', installerPath], { detached: true, stdio: 'ignore' }); child.unref(); } catch (err) { return { ok: false, message: 'Installer konnte nicht gestartet werden: ' + err.message }; } try { clipboard.writeText(sharedDir); } catch { /* egal */ } return { ok: true, sharedDir }; }); ipcMain.handle('optifine:confirm', (_e, id) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; const state = optifineSetupState.get(id); const sharedDir = (state && state.sharedDir) || path.join(app.getPath('userData'), 'shared'); const nachher = scanOptifineVersions(sharedDir, inst.minecraft.version); const vorher = (state && state.before) || new Set(); const neu = nachher.filter((v) => !vorher.has(v)); const treffer = neu[0] || nachher[0]; optifineSetupState.delete(id); if (!treffer) { return { ok: false, message: 'Keine OptiFine-Version für ' + (inst.minecraft.version || '?') + ' gefunden. ' + 'Wurde die Installation im OptiFine-Fenster abgeschlossen und dabei der kopierte Ordner ausgewählt?', }; } return { ok: true, versionId: treffer }; }); 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); }); // ---- Absturz-Assistent ---- // Wertet Absturzbericht, latest.log und Konsolenausgabe des letzten Starts aus // (siehe crashhelp.js) und liefert verständliche Funde mit Lösungs-Aktion. function absturzDaten(id, seit) { const inst = store.getInstance(id); if (!inst) return null; const gameDir = store.gameDir(id); const seitMs = Number(seit) || 0; const bericht = crashreports.lies(gameDir, seitMs); // latest.log nur, wenn es zu diesem Start gehört (sonst ist es vom letzten Mal) let log = ''; try { const logDatei = path.join(gameDir, 'logs', 'latest.log'); if (fs.existsSync(logDatei) && (!seitMs || fs.statSync(logDatei).mtimeMs >= seitMs)) { const r = crashreports.liesLog(gameDir, 'latest.log', 4000); if (r.ok) log = r.text; } } catch { /* egal */ } const settings = store.getSettings(); const text = [bericht.ok ? bericht.text : '', log, letzteAusgabe.get(id) || ''].join('\n'); const funde = crashhelp.analysiere(text, { loader: inst.minecraft.loader, version: inst.minecraft.version, maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb, gesamtMb: gesamtSpeicherMb(), mods: store.listMods(id, 'mod'), }); return { inst, funde, bericht: bericht.ok ? bericht : null, log, ausgabe: letzteAusgabe.get(id) || '' }; } ipcMain.handle('crash:analyze', (_e, id, seit) => { const daten = absturzDaten(id, seit); if (!daten) return { ok: false }; return { ok: true, funde: daten.funde, bericht: daten.bericht }; }); // ---- Fehlerbericht an M_Viper (nur auf ausdrücklichen Wunsch) ---- // Vor dem Senden werden persönliche Angaben entfernt: Windows-Benutzerordner in // Pfaden, Zugangs-Tokens und – wenn anonym – Minecraft-Name und UUID. function regexSicher(t) { return String(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function bereinigeBericht(text, konto) { let t = String(text || ''); const heim = os.homedir(); for (const variante of [heim, heim.replace(/\\/g, '/'), heim.replace(/\\/g, '\\\\')]) { t = t.replace(new RegExp(regexSicher(variante), 'gi'), '%USERPROFILE%'); } const benutzer = os.userInfo().username; if (benutzer && benutzer.length > 2) t = t.replace(new RegExp('(\\\\|/)' + regexSicher(benutzer) + '(\\\\|/)', 'gi'), '$1%USERNAME%$2'); // Zugangsdaten nie mitschicken t = t.replace(/eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g, '***'); t = t.replace(/((?:accessToken|auth_access_token|auth_session|session)["'\s:=]+(?:token:)?)[^\s"',]+/gi, '$1***'); if (konto) { if (konto.name) t = t.replace(new RegExp(regexSicher(konto.name), 'g'), ''); if (konto.uuid) { const ohne = String(konto.uuid).replace(/-/g, ''); const mit = ohne.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5'); t = t.replace(new RegExp(regexSicher(ohne) + '|' + regexSicher(mit), 'gi'), ''); } } return t; } ipcMain.handle('crash:report', async (_e, id, seit, wahl) => { const daten = absturzDaten(id, seit); if (!daten) return { ok: false, message: 'Instanz nicht gefunden.' }; const mitName = !!(wahl && wahl.mitName); const aktiv = aktivesFreundeKonto(); // anonym: Name/UUID aus dem Text entfernen; mit Name: nur Pfade/Tokens bereinigen const anonymKonto = mitName ? null : (aktiv ? { name: aktiv.username, uuid: aktiv.uuid } : null); const ende = (text, zeilen) => String(text || '').split(/\r?\n/).slice(-zeilen).join('\n'); let bericht = ''; if (daten.bericht) bericht += '--- Absturzbericht ' + (daten.bericht.datei || '') + ' ---\n' + daten.bericht.text + '\n\n'; if (daten.log) bericht += '--- latest.log (Ende) ---\n' + ende(daten.log, daten.bericht ? 150 : 400) + '\n\n'; if (daten.ausgabe) bericht += '--- Konsolenausgabe (Ende) ---\n' + ende(daten.ausgabe, 300) + '\n'; bericht = bereinigeBericht(bericht, anonymKonto).slice(-190000); const inst = daten.inst; const nutzlast = { launcherVersion: app.getVersion(), mcVersion: inst.minecraft.version || '', loader: inst.minecraft.loader || 'vanilla', loaderVersion: inst.minecraft.loaderVersion || '', system: `Windows ${os.release()} · ${Math.round(os.totalmem() / 1073741824)} GB RAM · Instanz-RAM ${inst.java.maxMemMb || store.getSettings().defaultMaxMemMb || '?'} MB`, funde: daten.funde.map((f) => ({ titel: f.titel, text: f.text })), mods: store.listMods(id, 'mod').map((m) => ({ datei: m.filename, name: m.title || '', version: m.versionNumber || '', aktiv: m.enabled !== false, })), bericht, kommentar: bereinigeBericht(String((wahl && wahl.kommentar) || '').slice(0, 2000), anonymKonto), }; if (mitName && aktiv) { nutzlast.uuid = aktiv.uuid; nutzlast.username = aktiv.username; } const res = await friends.sendReport(nutzlast); return res.ok ? { ok: true, id: res.data && res.data.id } : res; }); // Lösungs-Aktion eines Fundes ausführen ipcMain.handle('crash:fix', async (_e, id, aktion) => { const inst = store.getInstance(id); const a = aktion || {}; if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; try { switch (a.typ) { case 'mod-installieren': { // Mod-IDs und Modrinth-Namen weichen manchmal ab -> ein paar Schreibweisen probieren const kandidaten = [...new Set([a.projekt, crashhelp.projektFuer(a.name), String(a.name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-')].filter((k) => k && /^[\w-]{1,64}$/.test(k)))]; let letzter = null; for (const projekt of kandidaten) { const res = await installiereInhalt(id, { projectId: projekt, title: a.name || projekt }, 'mod').catch((err) => ({ ok: false, message: err.message })); if (res.ok) return { ok: true, text: `„${a.name || projekt}“ installiert.` }; letzter = res.message; } return { ok: false, message: `„${a.name}“ wurde auf Modrinth nicht gefunden${letzter ? ` (${letzter})` : ''}. Bitte im Inhalts-Dialog von Hand suchen.` }; } case 'mod-deaktivieren': { const res = store.setModEnabled(id, String(a.dateiname || ''), false, 'mod'); return res.ok ? { ok: true, text: `„${a.name || a.dateiname}“ abgeschaltet.` } : { ok: false, message: 'Konnte den Mod nicht abschalten.' }; } case 'ram': { const mb = Math.round(Number(a.mb) || 0); if (mb < 1024) return { ok: false, message: 'Ungültige Speichergröße.' }; const minMb = inst.java.minMemMb && inst.java.minMemMb > mb ? Math.min(mb, 1024) : inst.java.minMemMb; store.updateInstance(id, { java: { maxMemMb: mb, minMemMb: minMb || null } }); broadcast('instances:changed'); return { ok: true, text: `Arbeitsspeicher auf ${Math.round(mb / 102.4) / 10} GB gestellt.` }; } case 'java-auto': { store.updateInstance(id, { java: { path: '' } }); store.saveSettings({ autoJava: true }); broadcast('instances:changed'); return { ok: true, text: 'Beim nächsten Start wählt AeroMC das passende Java und lädt es bei Bedarf herunter.' }; } case 'mods-ordner': { const dir = store.inhaltsDir(id, 'mod'); fs.mkdirSync(dir, { recursive: true }); shell.openPath(dir); return { ok: true, text: 'Mods-Ordner geöffnet.' }; } default: return { ok: false, message: 'Unbekannte Aktion.' }; } } catch (err) { return { ok: false, message: err.message }; } }); // nach einer Lösung gleich erneut starten (Start läuft im Hauptfenster) ipcMain.handle('crash:relaunch', (_e, id) => { if (!store.getInstance(id)) return false; showMainWindow(); starteInstanzImFenster(id); return true; }); // ---- 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) => { broadcast('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', (_e, id, projectIds, art) => wendeModUpdatesAn(id, projectIds, art)); async function wendeModUpdatesAn(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 }; } /* * Automatische Mod-Updates vor dem Start (Einstellung autoModUpdates): sucht * für die über Modrinth installierten Mods nach neueren Fassungen und spielt * sie ein. Begrenzt auf eine Höchstdauer – ein langsamer Server soll den * Spielstart nicht minutenlang aufhalten; dann wird eben ohne Update gestartet. */ async function modUpdatesVorDemStart(id) { const inst = store.getInstance(id); if (!inst || !inst.minecraft.version || !inst.minecraft.loader || inst.minecraft.loader === 'vanilla') return null; const mods = store.listMods(id, 'mod'); if (!mods.some((m) => m.projectId)) return null; broadcast('launch:progress', { id, phase: 'mod-updates', detail: 'Suche nach Mod-Updates …' }); const suche = services.modrinthFindUpdates(mods, inst.minecraft.version, inst.minecraft.loader, null, 'mod'); const zeitlimit = new Promise((resolve) => setTimeout(() => resolve(null), 20000)); const gefunden = await Promise.race([suche, zeitlimit]).catch(() => null); if (!gefunden || !gefunden.treffer || !gefunden.treffer.length) return null; broadcast('launch:progress', { id, phase: 'mod-updates', detail: `${gefunden.treffer.length} Mod-Update(s) werden installiert …` }); const res = await wendeModUpdatesAn(id, gefunden.treffer.map((t) => t.projectId), 'mod'); broadcast('mods:changed', { id }); return res; } // --------------------------------------------------------------------------- // 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) => { const res = auth.setActive(id); // Ohne das hier bleibt z. B. ein separates Konten- oder Aufzeichnungs- // Fenster (eigener Renderer-Prozess, siehe cross-window state) auf dem // alten Konto stehen, bis irgendein anderes Ereignis zufällig ein Neuladen // auslöst - der Listener auf 'accounts:changed' existiert im Renderer // schon, wurde von hier nur nie ausgelöst. broadcast('accounts:changed'); // Anfragen-Zahl am "Freunde"-Knopf gilt für das aktive Konto ungeleseneNachrichten = []; aktualisiereAnfragenZahl().catch(() => {}); pruefeNachrichten().catch(() => {}); return res; }); // ---- 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; } // Armform (breit/schmal) eines beliebigen Kontos – steht in den Textur-Angaben // des öffentlichen Profils bei Mojang (SKIN.metadata.model = "slim"). // Ergebnis 10 Minuten merken, nach eigenen Skin-Änderungen sofort verwerfen. const armformCache = new Map(); // uuid -> { modell, zeit } ipcMain.handle('skin:modelFor', async (_e, uuid) => { const id = String(uuid || '').replace(/-/g, '').toLowerCase(); if (!/^[0-9a-f]{32}$/.test(id)) return 'classic'; const gemerkt = armformCache.get(id); if (gemerkt && Date.now() - gemerkt.zeit < 10 * 60 * 1000) return gemerkt.modell; try { const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (!res.ok) return gemerkt ? gemerkt.modell : 'classic'; const profil = await res.json(); const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures'); const texturen = eigenschaft ? JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')) : {}; const skin = texturen.textures && texturen.textures.SKIN; const modell = skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic'; armformCache.set(id, { modell, zeit: Date.now() }); return modell; } catch { return gemerkt ? gemerkt.modell : 'classic'; } }); // Zeitpunkt der letzten eigenen Skin-Änderung: danach hinkt der Sitzungsserver // (= was im Spiel zu sehen ist) noch kurz hinterher let letzterSkinWechsel = 0; const SKIN_NACHLAUF_MS = 3 * 60 * 1000; // Kontoprofil + der Skin, den andere im Spiel sehen (siehe skins.waehleAnzeigeSkin) async function profilMitSpielSkin(token) { const p = await skins.holeProfil(token); if (!p.ok) return p; const kuerzlich = Date.now() - letzterSkinWechsel < SKIN_NACHLAUF_MS; let spiel = null; if (!kuerzlich) { try { spiel = await skins.holeSpielTexturen(p.id); } catch { /* dann eben das Kontoprofil */ } } return skins.waehleAnzeigeSkin(p, spiel, kuerzlich); } ipcMain.handle('skin:profile', async () => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; try { return await profilMitSpielSkin(token); } catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; } }); ipcMain.handle('skin:choose', async (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 { const res = await skins.setzeSkin(token, dateiPfad, variante); if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); } return res; } 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 { const res = await skins.setzeSkinZurueck(token); if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); } return res; } catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } }); // ---------- Skin-Editor ---------- // Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net) // würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt // der Hauptprozess die Datei und reicht sie als data:-Adresse weiter. const PNG_KENNUNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); function skinPngAusBase64(base64) { const daten = Buffer.from(String(base64 || ''), 'base64'); if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null; if (daten.readUInt32BE(16) !== 64 || daten.readUInt32BE(20) !== 64) return null; return daten; } ipcMain.handle('skin:currentData', async () => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; try { const p = await profilMitSpielSkin(token); if (!p.ok) return p; if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' }; const res = await fetch(p.skinUrl); if (!res.ok) return { ok: false, message: 'Skin-Datei nicht abrufbar (HTTP ' + res.status + ').' }; const daten = Buffer.from(await res.arrayBuffer()); return { ok: true, name: p.name, variante: p.variante, dataUri: 'data:image/png;base64,' + daten.toString('base64') }; } catch (err) { return { ok: false, message: 'Skin konnte nicht geladen werden: ' + err.message }; } }); // ---------- Umhänge (offiziell von Mojang + eigene AeroMC-Umhänge) ---------- // Bilder immer als data:-Adresse in die Oberfläche (fremde Server schicken // keine CORS-Kopfzeilen – die 3D-Ansicht dürfte sie sonst nicht laden) async function bildAlsDataUri(url) { const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (!res.ok) throw new Error('HTTP ' + res.status); const daten = Buffer.from(await res.arrayBuffer()); if (daten.length > 6 * 1024 * 1024) throw new Error('Bild zu groß'); return 'data:image/png;base64,' + daten.toString('base64'); } // PNG mit Seitenverhältnis 2:1 (64×32 … 1024×512) – wie der Server es verlangt function umhangPngAusBase64(base64) { const daten = Buffer.from(String(base64 || ''), 'base64'); if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null; const breite = daten.readUInt32BE(16); const hoehe = daten.readUInt32BE(20); if (![64, 128, 256, 512, 1024].includes(breite) || breite !== 2 * hoehe) return null; return daten; } // Umhang eines beliebigen Spielers fürs Anzeigen: eigener AeroMC-Umhang hat // Vorrang, sonst der offizielle aus Mojangs Profil. 5 Minuten gemerkt. const umhangCache = new Map(); // uuid -> { dataUri, quelle, zeit } async function umhangFuer(uuid) { const id = String(uuid || '').replace(/-/g, '').toLowerCase(); if (!/^[0-9a-f]{32}$/.test(id)) return null; const gemerkt = umhangCache.get(id); if (gemerkt && Date.now() - gemerkt.zeit < 5 * 60 * 1000) return gemerkt; let ergebnis = { dataUri: null, quelle: null, zeit: Date.now() }; try { const aero = await friends.getCapes([id]); const eintrag = aero.ok && aero.capes && aero.capes[id]; if (eintrag && eintrag.url) ergebnis = { dataUri: await bildAlsDataUri(eintrag.url), quelle: 'aeromc', zeit: Date.now() }; } catch { /* weiter mit Mojang */ } if (!ergebnis.dataUri) { try { const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (res.ok) { const profil = await res.json(); const eig = (profil.properties || []).find((p) => p.name === 'textures'); const tex = eig ? JSON.parse(Buffer.from(eig.value, 'base64').toString('utf8')) : {}; const cape = tex.textures && tex.textures.CAPE; if (cape && cape.url) ergebnis = { dataUri: await bildAlsDataUri(skins.aufHttps(cape.url)), quelle: 'mojang', zeit: Date.now() }; } } catch { /* kein Umhang */ } } umhangCache.set(id, ergebnis); return ergebnis; } ipcMain.handle('cape:forUuid', async (_e, uuid) => { const r = await umhangFuer(uuid); return r && r.dataUri ? { dataUri: r.dataUri, quelle: r.quelle } : null; }); // Übersicht fürs Skin-Fenster: offizielle Umhänge + eigener AeroMC-Umhang ipcMain.handle('cape:info', async () => { const konto = aktivesFreundeKonto(); const token = await aktivesToken(); if (!konto || !token) return { ok: false, message: 'Kein angemeldetes Konto.' }; const ergebnis = { ok: true, name: konto.username, offiziell: [], aeromc: null, aeromcFehler: '' }; try { const p = await skins.holeProfil(token); if (p.ok) ergebnis.offiziell = p.capes || []; } catch { /* ohne offizielle Liste weiter */ } const aero = await friends.getCapes([konto.uuid]); if (aero.ok) ergebnis.aeromc = aero.capes[String(konto.uuid).replace(/-/g, '').toLowerCase()] || null; else { ergebnis.aeromcFehler = aero.message; ergebnis.aeromcFehlt = !!aero.fehlt; } return ergebnis; }); ipcMain.handle('cape:setOfficial', async (_e, capeId) => { const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; try { const res = await skins.setzeUmhang(token, capeId || null); if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); } return res; } catch (err) { return { ok: false, message: 'Umhang konnte nicht gesetzt werden: ' + err.message }; } }); // Der Umhang-Agent muss als echte Datei vorliegen (Java liest nicht aus app.asar): // einmal nach userData kopieren, Dateiname mit Größe, damit ein Update ihn ersetzt. // Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt. function umhangAgent() { try { const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar'); const groesse = fs.statSync(quelle).size; const ordner = path.join(app.getPath('userData'), 'agent'); const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`); if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) { fs.mkdirSync(ordner, { recursive: true }); fs.writeFileSync(ziel, fs.readFileSync(quelle)); for (const alt of fs.readdirSync(ordner)) { if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } } } } return `${ziel}=${friends.BASIS_URL}`; } catch (err) { console.error('Umhang-Agent nicht verfügbar:', err.message); return ''; } } // verständliche Meldung, wenn der Server das neue Plugin noch nicht hat function umhangFehler(res) { if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' }; return res; } ipcMain.handle('cape:upload', async (_e, base64) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' }; const res = await friends.uploadCape(konto.uuid, base64); if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); } return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res); }); ipcMain.handle('cape:remove', async () => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' }; const res = await friends.removeCape(konto.uuid); if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); } return res.ok ? res : umhangFehler(res); }); // für den Editor: eigener Umhang, sonst der getragene offizielle ipcMain.handle('cape:currentData', async () => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' }; umhangCache.delete(String(konto.uuid).replace(/-/g, '').toLowerCase()); const r = await umhangFuer(konto.uuid); if (!r || !r.dataUri) return { ok: false, keiner: true, message: 'Dein Konto hat noch keinen Umhang.' }; return { ok: true, dataUri: r.dataUri, quelle: r.quelle }; }); // Bild wählen: art 'umhang' = fertige Umhang-Datei (PNG), 'bild' = beliebiges Motiv zum Einsetzen ipcMain.handle('cape:chooseFile', async (e, art) => { const motiv = art === 'bild'; const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: motiv ? 'Bild für den Umhang wählen' : 'Umhang-Datei öffnen', properties: ['openFile'], filters: motiv ? [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] }] : [{ name: 'PNG-Bild', extensions: ['png'] }], }); if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true }; const pfad = res.filePaths[0]; try { const st = fs.statSync(pfad); if (st.size > 15 * 1024 * 1024) return { ok: false, message: 'Die Datei ist zu groß (über 15 MB).' }; const endung = path.extname(pfad).slice(1).toLowerCase().replace('jpg', 'jpeg'); return { ok: true, pfad, dataUri: `data:image/${endung || 'png'};base64,` + fs.readFileSync(pfad).toString('base64') }; } catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; } }); ipcMain.handle('cape:saveData', async (e, base64) => { const daten = umhangPngAusBase64(base64); if (!daten) return { ok: false, message: 'Ungültige Umhang-Daten.' }; const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Umhang speichern', defaultPath: path.join(app.getPath('pictures'), 'umhang.png'), filters: [{ name: 'PNG-Bild', extensions: ['png'] }], }); if (res.canceled || !res.filePath) return { ok: false, canceled: true }; try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; } catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; } }); ipcMain.handle('skin:readData', (_e, dateiPfad) => { const geprueft = skins.pruefeDatei(dateiPfad); if (!geprueft.ok) return geprueft; try { return { ok: true, dataUri: 'data:image/png;base64,' + fs.readFileSync(dateiPfad).toString('base64') }; } catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; } }); ipcMain.handle('skin:saveData', async (e, base64) => { const daten = skinPngAusBase64(base64); if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' }; const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Skin speichern', defaultPath: path.join(app.getPath('pictures'), 'skin.png'), filters: [{ name: 'PNG-Bild', extensions: ['png'] }], }); if (res.canceled || !res.filePath) return { ok: false, canceled: true }; try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; } catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; } }); ipcMain.handle('skin:applyData', async (_e, base64, variante) => { const daten = skinPngAusBase64(base64); if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' }; const token = await aktivesToken(); if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png'); try { fs.writeFileSync(tmp, daten); const res = await skins.setzeSkin(token, tmp, variante); if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); } return res; } catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; } finally { try { fs.unlinkSync(tmp); } catch { /* egal */ } } }); ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id)); ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; }); // ---------- Freundesystem ---------- // Nutzt immer das aktive Konto (gleiche Quelle wie discordpresence/die // "spielt gerade"-Anzeige: auth.currentAccount().profile.{id,name}) - keine // eigene Anmeldung nötig. // Findet DAS Minecraft-Fenster des angegebenen Prozesses (pid) - wichtig, // damit der Ingame-Toast auf dem Bildschirm des prüfenden Kontos erscheint, // nicht zufällig auf dem eines anderen laufenden Kontos (bei zwei Konten // über denselben Launcher gäbe es sonst z. B. eine Meldung über M_Viper // selbst, angezeigt auf M_Vipers eigenem Fenster). // // node-window-manager erkennt ein gerade laufendes Fenster manchmal nicht // beim allerersten Abfragen (z. B. Spiel lädt noch, kurz minimiert) - deshalb // mehrere Versuche mit kurzer Pause. Wird das Fenster DIESER PID trotzdem // nicht gefunden, wird bewusst auf kein anderes Fenster ausgewichen - lieber // keine Meldung als eine beim falschen Spieler. async function findeMinecraftFenster(pid) { if (!pid) return null; let windowManager; try { ({ windowManager } = require('node-window-manager')); } catch { return null; } for (let versuch = 1; versuch <= 4; versuch++) { let alleFenster; try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; } const treffer = alleFenster.find((w) => w.processId === pid); if (treffer) return treffer; if (versuch < 4) await new Promise((r) => setTimeout(r, 300)); } return null; } // pid -> Anzahl gerade sichtbarer Toasts auf diesem Fenster (zum Stapeln, // falls mehrere Freunde im selben Durchlauf gemeldet werden) const toastsProFenster = new Map(); // 3D-Spielerkopf (isometrischer Würfel, gleiche Quelle wie im Freunde-Dialog) // als data:-URI - vorab im Hauptprozess geladen und eingebettet, damit der // Kopf schon beim Erscheinen des Toasts da ist statt nachzuladen. Pro uuid // zwischengespeichert; bei Fehler/Zeitüberschreitung einfach ohne Kopf. const spielerKopfCache = new Map(); async function spielerKopf(uuid) { if (!uuid) return null; if (spielerKopfCache.has(uuid)) return spielerKopfCache.get(uuid); const abbruch = new AbortController(); const zeitgeber = setTimeout(() => abbruch.abort(), 3000); try { const res = await fetch(`https://crafthead.net/cube/${encodeURIComponent(uuid)}/64`, { signal: abbruch.signal }); if (!res.ok) return null; const uri = 'data:image/png;base64,' + Buffer.from(await res.arrayBuffer()).toString('base64'); spielerKopfCache.set(uuid, uri); return uri; } catch { return null; } finally { clearTimeout(zeitgeber); } } // Kleiner, durchsichtiger Toast oben rechts auf dem Minecraft-Fenster (wie // Discords Ingame-Overlay), bewusst OHNE Windows-Meldung als Rückfall - // stiehlt nie den Fokus (showInactive + focusable:false), damit das Spiel // nicht gestört wird. Jeder Toast ist ein eigenes Fenster, damit zwei // gleichzeitig laufende Konten jeweils ihre eigene Meldung bekommen. // Liefert true, wenn der Toast angezeigt wurde. `text` ist eine Zeile oder // eine Liste von Zeilen; eine Zeile ist entweder Text oder { uuid, text } - // dann steht der 3D-Spielerkopf davor. async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) { const fenster = await findeMinecraftFenster(pid); if (!fenster) return false; const bounds = fenster.getBounds(); const zeilen = await Promise.all((Array.isArray(text) ? text : [text]).map(async (z) => { if (typeof z === 'string') return { text: z, kopf: null }; return { text: z.text, kopf: await spielerKopf(z.uuid), umbruch: !!z.umbruch }; })); const breite = 320; // Zeilen mit umbruch (z. B. Nachrichten) dürfen mehrzeilig werden – Höhe grob schätzen const zeilenHoehe = (z) => (z.umbruch ? Math.max(z.kopf ? 24 : 18, Math.ceil(String(z.text).length / 38) * 17) : (z.kopf ? 24 : 18)); const hoehe = 44 + zeilen.reduce((summe, z) => summe + zeilenHoehe(z), 0); const stapel = toastsProFenster.get(pid) || 0; toastsProFenster.set(pid, stapel + 1); const overlayFenster = new BrowserWindow({ x: Math.round(bounds.x + bounds.width - breite - 24), y: Math.round(bounds.y + 24 + stapel * (hoehe + 8)), width: breite, height: hoehe, frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, focusable: false, resizable: false, hasShadow: false, show: false, webPreferences: { contextIsolation: true, sandbox: true }, }); overlayFenster.setIgnoreMouseEvents(true); overlayFenster.setAlwaysOnTop(true, 'screen-saver'); const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // charset=utf-8 nicht vergessen: ohne das interpretiert Chromium die // data:-URL mit einer anderen Kodierung, wodurch z. B. "·" als "·" // dargestellt wird (klassisches UTF-8/Latin-1-Mojibake) const html = `data:text/html;charset=utf-8,${encodeURIComponent(`
${esc(titel)}
${zeilen.map((z) => `
${z.kopf ? `` : ''} ${esc(z.text)}
`).join('')}
`)}`; overlayFenster.loadURL(html); overlayFenster.once('ready-to-show', () => { if (!overlayFenster.isDestroyed()) overlayFenster.showInactive(); }); setTimeout(() => { if (!overlayFenster.isDestroyed()) overlayFenster.destroy(); toastsProFenster.set(pid, Math.max(0, (toastsProFenster.get(pid) || 1) - 1)); }, dauerMs); return true; } // ---------- Tastenkürzel: Online-Freunde ingame anzeigen ---------- // Nur registriert, solange über diesen Launcher ein Spiel läuft - ein globales // Tastenkürzel fängt die Kombination systemweit ab, das soll andere Programme // außerhalb des Spielens nicht stören. const FREUNDE_TASTENKUERZEL = 'Control+Alt+F'; const EINLADUNG_TASTENKUERZEL = 'Control+Alt+J'; let freundeTastenkuerzelAktiv = false; function aktualisiereFreundeTastenkuerzel() { const soll = kontoPid.size > 0 && store.getSettings().friendsEnabled !== false; if (soll && !freundeTastenkuerzelAktiv) { freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => { zeigeOnlineFreundeIngame().catch(() => {}); }); globalShortcut.register(EINLADUNG_TASTENKUERZEL, () => { nimmEinladungIngameAn().catch(() => {}); }); } else if (!soll && freundeTastenkuerzelAktiv) { globalShortcut.unregister(FREUNDE_TASTENKUERZEL); globalShortcut.unregister(EINLADUNG_TASTENKUERZEL); freundeTastenkuerzelAktiv = false; } } // Konto + Fenster-PID des Spiels im Vordergrund (bei nur einem laufenden Spiel: dieses) function vordergrundKonto() { let aktivePid = null; try { aktivePid = require('node-window-manager').windowManager.getActiveWindow().processId; } catch { /* egal */ } let eintrag = [...kontoPid].find(([, pid]) => pid === aktivePid); if (!eintrag && kontoPid.size === 1) eintrag = [...kontoPid][0]; return eintrag || null; } // Zeigt die gerade online befindlichen Freunde des Kontos, dessen // Minecraft-Fenster gerade im Vordergrund ist, als Toast auf genau diesem // Fenster. Läuft nur ein Spiel, wird dieses genommen, auch wenn es nicht // im Vordergrund ist. async function zeigeOnlineFreundeIngame() { const eintrag = vordergrundKonto(); if (!eintrag) return; const [kontoUuid, pid] = eintrag; const res = await friends.getFriends(kontoUuid); if (!res.ok) { await zeigeIngameOverlay('Freunde', 'Freundesystem nicht erreichbar', pid, 4000); return; } const spitznamen = store.getSettings().friendNicknames || {}; const online = (res.data || []).filter((f) => f.online); const MAX_ZEILEN = 10; const zeilen = online.slice(0, MAX_ZEILEN).map((f) => ({ uuid: f.uuid, text: (spitznamen[f.uuid] || f.username) + (f.server ? ` · ${f.server}` : ''), })); if (online.length > MAX_ZEILEN) zeilen.push(`… und ${online.length - MAX_ZEILEN} weitere`); await zeigeIngameOverlay( online.length ? `Freunde online (${online.length})` : 'Freunde online', zeilen.length ? zeilen : ['Gerade ist niemand online'], pid, 8000, ); } // ---------- Einladungen: "Komm auf meinen Server" ---------- // Eine Einladung enthält Server-Adresse und Minecraft-Version des Einladenden. // Im Launcher: Hinweis + "Beitreten" im Freunde-Fenster (startet eine passende // Instanz per Quick Play). Im Spiel: Toast auf dem richtigen Fenster; ein // laufendes Minecraft lässt sich von außen nicht auf einen anderen Server // umlenken – Strg+Alt+J kopiert deshalb die Adresse für "Direkt verbinden". const gezeigteEinladungen = new Set(); // Einladungs-IDs, die schon gemeldet wurden const letzteEinladung = new Map(); // kontoUuid -> zuletzt erhaltene Einladung (für Strg+Alt+J) async function pruefeEinladungen() { if (store.getSettings().friendsEnabled === false) return; const aktiv = aktivesFreundeKonto(); const konten = new Set([...kontoPid.keys()]); if (aktiv) konten.add(aktiv.uuid); for (const kontoUuid of konten) { const res = await friends.getInvites(kontoUuid); if (!res.ok) continue; for (const einl of res.data || []) { if (gezeigteEinladungen.has(einl.id)) continue; gezeigteEinladungen.add(einl.id); letzteEinladung.set(kontoUuid, einl); const pid = kontoPid.get(kontoUuid); if (pid) { await zeigeIngameOverlay('Einladung', [ { uuid: einl.fromUuid, text: `${einl.fromUsername || 'Ein Freund'} lädt dich ein · ${einl.server}` }, 'Strg+Alt+J: Adresse kopieren', ], pid, 10000); } if (aktiv && aktiv.uuid === kontoUuid) broadcast('friends:invite', einl); } } } // Strg+Alt+J im Spiel: Adresse der letzten Einladung in die Zwischenablage async function nimmEinladungIngameAn() { const eintrag = vordergrundKonto(); if (!eintrag) return; const [kontoUuid, pid] = eintrag; const einl = letzteEinladung.get(kontoUuid); if (!einl) { await zeigeIngameOverlay('Einladung', 'Gerade keine offene Einladung', pid, 4000); return; } clipboard.writeText(einl.server); await zeigeIngameOverlay('Adresse kopiert', [ einl.server, 'Mehrspieler → Direkt verbinden → Strg+V', ], pid, 9000); } // Passende eigene Instanz für einen Server: gleiche Version und den Server schon // in der Liste > gleiche Version > (Version unbekannt) Server in der Liste. // Jeweils die zuletzt gespielte. function instanzFuerServer(server, version) { const norm = (a) => String(a || '').trim().toLowerCase().replace(/:25565$/, ''); const ziel = norm(server); const neuesteZuerst = (liste) => [...liste].sort((a, b) => String(b.lastPlayed || '').localeCompare(String(a.lastPlayed || ''))); const kenntServer = (inst) => { try { const r = servers.lies(store.gameDir(inst.id)); return r.ok && r.server.some((x) => norm(x.ip) === ziel); } catch { return false; } }; const alle = store.listInstances().filter((i) => i.minecraft && i.minecraft.version); const passend = version ? alle.filter((i) => i.minecraft.version === version) : alle; return neuesteZuerst(passend.filter(kenntServer))[0] || (version ? neuesteZuerst(passend)[0] : null) || null; } ipcMain.handle('friends:join', (_e, server, version) => { if (!server) return { ok: false, message: 'Kein Server bekannt.' }; const inst = instanzFuerServer(server, version); if (!inst) return { ok: false, noInstance: true, version: version || '' }; showMainWindow(); starteInstanzImFenster(inst.id, server); return { ok: true, instanceName: inst.name }; }); // keine passende Instanz: neue Vanilla-Instanz mit der Version des Freundes anlegen und beitreten ipcMain.handle('friends:joinNew', (_e, server, version) => { if (!server || !version) return { ok: false, message: 'Server oder Version unbekannt.' }; const inst = store.createInstance({ name: `${server} (${version})`, minecraft: { version, loader: 'vanilla' } }); broadcast('instances:changed'); showMainWindow(); starteInstanzImFenster(inst.id, server); return { ok: true, instanceName: inst.name }; }); // eigener aktueller Server (des aktiven Kontos), falls es gerade auf einem spielt function eigenerServer() { const konto = aktivesFreundeKonto(); const instId = konto && kontoInstanz.get(konto.uuid); const server = instId && letzteQuickPlayServer.get(instId); if (!server) return null; const inst = store.getInstance(instId); return { konto, server, version: (inst && inst.minecraft.version) || '' }; } ipcMain.handle('friends:myServer', () => { const e = eigenerServer(); return e ? { server: e.server, version: e.version } : null; }); ipcMain.handle('friends:invite', async (_e, targetUuid) => { const e = eigenerServer(); if (!e) return { ok: false, message: 'Du bist gerade auf keinem Server – erst einem Server beitreten.' }; return friends.sendInvite(e.konto.uuid, targetUuid, e.server, e.version); }); ipcMain.handle('friends:invites', async () => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.getInvites(konto.uuid); return res.ok ? { ok: true, list: res.data || [] } : res; }); ipcMain.handle('friends:dismissInvite', async (_e, inviteId) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false }; return friends.dismissInvite(konto.uuid, inviteId); }); // ---------- Instanz per Code teilen ---------- // Geteilt wird nur die Beschreibung: Minecraft-Version, Loader und die Liste der // Mods/Ressourcenpakete/Shader mit ihrer genauen Modrinth/CurseForge-Version. // Wer den Code einlöst, lädt alles selbst von dort – keine Dateien über den // eigenen Server, keine Urheberrechtsprobleme. Von Hand hinzugefügte Dateien // (ohne Herkunft) lassen sich so nicht übertragen und werden genannt. const TEILBARE_ARTEN = ['mod', 'resourcepack', 'shader']; const TEILBARE_LOADER = new Set(['vanilla', 'fabric', 'quilt', 'forge', 'neoforge']); function teilBeschreibung(id) { const inst = store.getInstance(id); if (!inst) return null; const inhalte = []; const nichtUebertragbar = []; for (const art of TEILBARE_ARTEN) { for (const m of store.listMods(id, art)) { if (m.fehlt) continue; if (!m.projectId) { nichtUebertragbar.push(m.filename); continue; } inhalte.push({ art, projectId: m.projectId, versionId: m.versionId || '', title: m.title || m.projectId, aktiv: m.enabled !== false, }); } } return { format: 1, name: inst.name, icon: inst.icon || '', minecraft: { version: inst.minecraft.version, loader: inst.minecraft.loader || 'vanilla', loaderVersion: inst.minecraft.loaderVersion || '', }, inhalte, nichtUebertragbar, }; } ipcMain.handle('share:create', async (_e, id) => { const beschreibung = teilBeschreibung(id); if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' }; if (!TEILBARE_LOADER.has(beschreibung.minecraft.loader)) { return { ok: false, message: 'OptiFine-Instanzen lassen sich nicht teilen – OptiFine muss jeder selbst installieren.' }; } const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Bitte zuerst mit einem Microsoft-Konto anmelden.' }; const res = await friends.createShare(konto.uuid, beschreibung); if (!res.ok) return res; return { ok: true, code: res.data.code, tage: res.data.days || 30, anzahl: beschreibung.inhalte.length, nichtUebertragbar: beschreibung.nichtUebertragbar, }; }); ipcMain.handle('share:fetch', async (_e, code) => { const res = await friends.getShare(code); if (!res.ok) return res; const m = res.data && res.data.manifest; if (!m || !m.minecraft || !Array.isArray(m.inhalte)) return { ok: false, message: 'Die geteilte Instanz ist beschädigt.' }; return { ok: true, data: res.data }; }); ipcMain.handle('share:install', async (_e, code) => { const abruf = await friends.getShare(code); if (!abruf.ok) return abruf; const m = abruf.data && abruf.data.manifest; const version = m && m.minecraft && String(m.minecraft.version || ''); const loader = m && m.minecraft && String(m.minecraft.loader || 'vanilla'); if (!version || !/^[\w.\- ]{1,32}$/.test(version) || !TEILBARE_LOADER.has(loader) || !Array.isArray(m.inhalte)) { return { ok: false, message: 'Die geteilte Instanz ist beschädigt.' }; } // eindeutiger Name, damit nichts Vorhandenes ersetzt wird const basis = String(m.name || 'Geteilte Instanz').replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 60) || 'Geteilte Instanz'; const vorhanden = new Set(store.listInstances().map((i) => String(i.name).toLowerCase())); let name = basis; for (let n = 2; vorhanden.has(name.toLowerCase()); n++) name = `${basis} (${n})`; const von = abruf.data.fromUsername || 'einem Freund'; const inst = store.createInstance({ name, icon: typeof m.icon === 'string' ? m.icon : undefined, notes: `Geteilt von ${von} (Code ${abruf.data.code})`, minecraft: { version, loader, loaderVersion: String(m.minecraft.loaderVersion || '') }, }); broadcast('instances:changed'); const inhalte = m.inhalte.filter((it) => it && TEILBARE_ARTEN.includes(it.art) && /^[\w-]{1,64}$/.test(String(it.projectId || ''))); const installiert = []; const fehlgeschlagen = []; for (let i = 0; i < inhalte.length; i++) { const it = inhalte[i]; const titel = String(it.title || it.projectId).slice(0, 80); broadcast('share:progress', { aktuell: i + 1, gesamt: inhalte.length, titel }); // schon als Abhängigkeit eines früheren Eintrags mitgekommen? if (store.listMods(inst.id, it.art).some((x) => x.projectId === it.projectId)) { installiert.push(titel); continue; } const res = await installiereInhalt(inst.id, { projectId: it.projectId, title: titel, versionId: typeof it.versionId === 'string' ? it.versionId : '', }, it.art).catch((err) => ({ ok: false, message: err.message })); if (res.ok) { installiert.push(titel); // war beim Teilenden abgeschaltet -> hier auch if (it.aktiv === false && res.entry) store.setModEnabled(inst.id, res.entry.filename, false, it.art); } else { fehlgeschlagen.push({ titel, grund: res.message || 'unbekannt' }); } } broadcast('instances:changed'); return { ok: true, instanz: { id: inst.id, name: inst.name }, installiert: installiert.length, fehlgeschlagen, nichtUebertragbar: Array.isArray(m.nichtUebertragbar) ? m.nichtUebertragbar.slice(0, 50) : [], von, }; }); // ---------- Kurznachrichten ---------- // Ungelesene Nachrichten werden zusammen mit den Einladungen abgefragt. Neue // erscheinen im Launcher als Hinweis und – wer gerade spielt – als Meldung im Spiel. const gezeigteNachrichten = new Set(); let ungeleseneNachrichten = []; // des aktiven Kontos (für Zähler und Liste) async function pruefeNachrichten() { if (store.getSettings().friendsEnabled === false) return; const aktiv = aktivesFreundeKonto(); const konten = new Set([...kontoPid.keys()]); if (aktiv) konten.add(aktiv.uuid); const spitznamen = store.getSettings().friendNicknames || {}; for (const kontoUuid of konten) { const res = await friends.getUnreadMessages(kontoUuid); if (!res.ok) continue; const liste = res.data || []; const neu = liste.filter((n) => !gezeigteNachrichten.has(n.id)); neu.forEach((n) => gezeigteNachrichten.add(n.id)); if (aktiv && aktiv.uuid === kontoUuid) { const vorher = ungeleseneNachrichten.length; ungeleseneNachrichten = liste; if (neu.length > 2) broadcast('friends:message', { sammel: neu.length }); else neu.forEach((n) => broadcast('friends:message', n)); if (neu.length || vorher !== liste.length) aktualisiereAnfragenZahl().catch(() => {}); } const pid = kontoPid.get(kontoUuid); if (pid) { for (const n of neu.slice(-3)) { const name = spitznamen[n.fromUuid] || n.fromUsername || 'Ein Freund'; const inhalt = n.imageUrl ? `📷 Screenshot${n.text ? ' – ' + n.text : ''}` : n.text; await zeigeIngameOverlay('Nachricht', [{ uuid: n.fromUuid, text: `${name}: ${inhalt}`, umbruch: true }], pid, 9000); } } } } ipcMain.handle('friends:messages', async (_e, otherUuid) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.getMessages(konto.uuid, otherUuid); if (!res.ok) return res; // geöffnet = gelesen if (ungeleseneNachrichten.some((n) => n.fromUuid === otherUuid)) { await friends.markMessagesRead(konto.uuid, otherUuid).catch(() => {}); ungeleseneNachrichten = ungeleseneNachrichten.filter((n) => n.fromUuid !== otherUuid); aktualisiereAnfragenZahl().catch(() => {}); } return { ok: true, list: res.data || [], ich: konto.uuid }; }); // Screenshot verkleinern (höchstens 1600 px breit), als JPEG an einen Freund schicken ipcMain.handle('friends:sendScreenshot', async (_e, otherUuid, instanceId, dateiname, text) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const datei = safeSub(instanceId, 'screenshots', String(dateiname || '')); if (!datei || !fs.existsSync(datei)) return { ok: false, message: 'Screenshot nicht gefunden.' }; let bild = nativeImage.createFromPath(datei); if (bild.isEmpty()) return { ok: false, message: 'Bild konnte nicht gelesen werden.' }; const { width } = bild.getSize(); if (width > 1600) bild = bild.resize({ width: 1600, quality: 'best' }); let jpeg = bild.toJPEG(85); if (jpeg.length > 2000000) jpeg = bild.toJPEG(65); const res = await friends.sendMessage(konto.uuid, otherUuid, String(text || '').trim().slice(0, 200), jpeg.toString('base64')); return res; }); ipcMain.handle('friends:sendMessage', async (_e, otherUuid, text) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const t = String(text || '').trim().slice(0, 200); if (!t) return { ok: false, message: 'Leere Nachricht.' }; return friends.sendMessage(konto.uuid, otherUuid, t); }); // ungelesene Nachrichten je Freund (für die Zahl am Sprechblasen-Knopf) ipcMain.handle('friends:unreadByFriend', () => { const zaehler = {}; for (const n of ungeleseneNachrichten) zaehler[n.fromUuid] = (zaehler[n.fromUuid] || 0) + 1; return zaehler; }); // ---------- Offene Anfragen + ungelesene Nachrichten (Zahl am "Freunde"-Knopf) ---------- let letzteAnfragenZahl = { anfragen: 0, nachrichten: 0 }; async function aktualisiereAnfragenZahl() { let anfragen = 0; const acc = auth.currentAccount(); if (store.getSettings().friendsEnabled !== false && acc && acc.profile) { const res = await friends.getRequests(acc.profile.id); if (!res.ok) return; // nicht erreichbar -> letzten bekannten Stand behalten anfragen = (res.data || []).length; } const aktiv = aktivesFreundeKonto(); const nachrichten = aktiv && acc && acc.profile && acc.profile.id === aktiv.uuid ? ungeleseneNachrichten.length : 0; letzteAnfragenZahl = { anfragen, nachrichten }; broadcast('friends:requestCount', letzteAnfragenZahl); } ipcMain.handle('friends:requestCount', () => letzteAnfragenZahl); // eigene uuid -> (freund-uuid -> war beim letzten Durchlauf online). Pro // Konto getrennt, damit zwei gleichzeitig spielende Konten sich die // Vergleichsbasis nicht gegenseitig überschreiben. const freundeOnlineBaseline = new Map(); // Prüft für JEDES gerade über diesen Launcher spielende Konto (kontoPid) dessen // eigene Freundesliste und zeigt die Meldung auf DESSEN Fenster - nicht nur für // das gerade "aktive" Konto, sonst bekäme bei zwei laufenden Konten immer nur // eines die Meldungen. Ohne laufendes Spiel gibt es kein Fenster für den Toast, // daher werden nur spielende Konten geprüft. async function pruefeFreundeOnline() { if (store.getSettings().friendsEnabled === false) return; for (const [kontoUuid, pid] of kontoPid) { const res = await friends.getFriends(kontoUuid); if (!res.ok) continue; const bisherige = freundeOnlineBaseline.get(kontoUuid); const neu = new Map(); for (const f of res.data || []) { let bekanntOnline = f.online; // Meldet auch beim allerersten Check, wenn ein Freund schon online ist - // nur ausgelassen, wenn er beim vorherigen Check schon als online // gemeldet war (sonst käme jede Minute dieselbe Meldung) if (f.online && (!bisherige || bisherige.get(f.uuid) !== true)) { const gezeigt = await zeigeIngameOverlay( 'Freund online', { uuid: f.uuid, text: `${f.username} ist jetzt online` }, pid, ); // Fenster (noch) nicht gefunden, z. B. weil das Spiel noch lädt -> // nicht als "schon gemeldet" merken, nächster Durchlauf versucht es erneut if (!gezeigt) bekanntOnline = false; } neu.set(f.uuid, bekanntOnline); } freundeOnlineBaseline.set(kontoUuid, neu); } } function aktivesFreundeKonto() { const acc = auth.currentAccount(); return acc && acc.profile ? { uuid: acc.profile.id, username: acc.profile.name } : null; } ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false); ipcMain.handle('friends:list', async () => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.getFriends(konto.uuid); return res.ok ? { ok: true, list: res.data || [] } : res; }); ipcMain.handle('friends:requests', async () => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.getRequests(konto.uuid); return res.ok ? { ok: true, list: res.data || [] } : res; }); ipcMain.handle('friends:add', async (_e, targetUsername) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.sendRequest(konto.uuid, konto.username, targetUsername); return res.ok ? { ok: true } : res; }); ipcMain.handle('friends:sentRequests', async () => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.getSentRequests(konto.uuid); return res.ok ? { ok: true, list: res.data || [] } : res; }); ipcMain.handle('friends:cancel', async (_e, otherUuid) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.cancelRequest(konto.uuid, otherUuid); return res.ok ? { ok: true } : res; }); ipcMain.handle('friends:respond', async (_e, otherUuid, accept) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.respond(konto.uuid, otherUuid, accept); aktualisiereAnfragenZahl().catch(() => {}); return res.ok ? { ok: true } : res; }); ipcMain.handle('friends:remove', async (_e, otherUuid) => { const konto = aktivesFreundeKonto(); if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; const res = await friends.removeFriend(konto.uuid, otherUuid); return res.ok ? { ok: true } : res; }); // Spitznamen sind rein lokal (nur meine eigene Ansicht) - kein Grund, dafür // die WordPress-Datenbank zu bemühen, landen einfach in settings.json ipcMain.handle('friends:setNickname', (_e, uuid, nickname) => { const aktuelle = store.getSettings().friendNicknames || {}; const neu = Object.assign({}, aktuelle); if (nickname) neu[uuid] = nickname; else delete neu[uuid]; store.saveSettings({ friendNicknames: neu }); return true; }); ipcMain.handle('auth:login', async () => { const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID; try { const account = await auth.login( clientId, (code) => { broadcast('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) => { const res = store.createInstance(data); broadcast('instances:changed'); return res; }); ipcMain.handle('instances:update', (_e, id, patch) => { const res = store.updateInstance(id, patch); broadcast('instances:changed'); return res; }); 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 res = await sichereWelt(id, worldName, false); if (res.ok) { const inst = store.getInstance(id); notify('Welt gesichert', `${worldName} (${inst ? inst.name : ''}) wurde gesichert.`); } return res; }); // automatische Sicherungen: so viele Stände je Welt bleiben erhalten const AUTO_WELT_STAENDE = 5; /* * Eine Welt als ZIP sichern. automatisch = true: Unterordner "Automatisch", * es bleiben nur die letzten AUTO_WELT_STAENDE Stände dieser Welt. */ async function sichereWelt(id, worldName, automatisch) { 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 { let zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen', (inst.name || 'Instanz').replace(/[^\w.-]+/g, '_')); if (automatisch) zielOrdner = path.join(zielOrdner, 'Automatisch'); 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 }); if (automatisch) { // ältere automatische Stände dieser Welt wegräumen const praefix = safeWorld + '_'; const staende = fs.readdirSync(zielOrdner) .filter((n) => n.startsWith(praefix) && n.endsWith('.zip') && /^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}/.test(n.slice(praefix.length))) .sort(); for (const alt of staende.slice(0, Math.max(0, staende.length - AUTO_WELT_STAENDE))) { try { fs.unlinkSync(path.join(zielOrdner, alt)); } catch { /* egal */ } } } return { ok: true, path: zielDatei }; } catch (err) { return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message }; } } /* * Nach einer Spielsitzung: die Welt sichern, die während der Sitzung zuletzt * gespeichert wurde (level.dat neuer als der Spielstart). Läuft im Hintergrund. */ async function sichereZuletztGespielteWelt(id, sitzungStart) { const savesDir = path.join(store.gameDir(id), 'saves'); let beste = null; try { for (const e of fs.readdirSync(savesDir, { withFileTypes: true })) { if (!e.isDirectory()) continue; const level = path.join(savesDir, e.name, 'level.dat'); try { const zeit = fs.statSync(level).mtimeMs; if (zeit >= sitzungStart - 5000 && (!beste || zeit > beste.zeit)) beste = { name: e.name, zeit }; } catch { /* keine Welt */ } } } catch { return; } if (!beste) return; // nur Mehrspieler gespielt const res = await sichereWelt(id, beste.name, true); if (res.ok) broadcast('ui:toast', { text: `Welt „${beste.name}“ automatisch gesichert (die letzten ${AUTO_WELT_STAENDE} Stände bleiben erhalten).` }); } // Welt in eine andere Instanz kopieren ipcMain.handle('world:copyTo', async (_e, id, worldName, zielId) => { const quelle = store.getInstance(id); const ziel = store.getInstance(zielId); if (!quelle || !ziel) return { ok: false, message: 'Instanz nicht gefunden.' }; if (id === zielId) return { ok: false, message: 'Quelle und Ziel sind dieselbe Instanz.' }; const savesQuelle = path.join(store.gameDir(id), 'saves'); const worldDir = path.join(savesQuelle, String(worldName || '')); if (path.dirname(worldDir) !== savesQuelle || !fs.existsSync(worldDir)) return { ok: false, message: 'Welt nicht gefunden.' }; if (laufendePid.has(id)) return { ok: false, message: 'Bitte erst Minecraft in dieser Instanz beenden – sonst wird die Welt mitten beim Speichern kopiert.' }; const savesZiel = path.join(store.gameDir(zielId), 'saves'); fs.mkdirSync(savesZiel, { recursive: true }); let name = String(worldName); for (let n = 2; fs.existsSync(path.join(savesZiel, name)); n++) name = `${worldName} (${n})`; try { await fs.promises.cp(worldDir, path.join(savesZiel, name), { recursive: true, filter: (src) => path.basename(src) !== 'session.lock', }); return { ok: true, name, ziel: ziel.name }; } catch (err) { return { ok: false, message: 'Kopieren 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; broadcast('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.' }; broadcast('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, }), }); broadcast('instances:changed'); 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) => { broadcast('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) => { const o = opts || {}; try { if (o.source === 'curseforge') { const zugang = services.curseforgeZugang(store.getSettings()); return await services.curseforgeSearch(zugang, Object.assign({}, o, { art: 'modpack' })); } const q = Object.assign({}, o, { 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) || ''; broadcast('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.', }; } broadcast('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) { broadcast('instances:changed'); notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`); } return result; } catch (err) { return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message }; } }); ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => { try { 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.' }; } const rawId = String((project && (project.cfId || project.projectId || project.id)) || '').replace(/^cf-/, ''); const modId = Number(rawId); if (!modId) return { ok: false, message: 'Kein Modpack gewählt.' }; const gameVersion = (project && project.gameVersion) || ''; broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' }); const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion); if (!file || !file.url) { return { ok: false, message: gameVersion ? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.') : 'Keine passende Modpack-Datei gefunden.', }; } broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' }); const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url; const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen); const zip = forge.readZipIndex(buf); if (!zip['manifest.json']) { return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' }; } const result = await importCurseForge(buf, zip, zugang); if (result && result.ok && result.instance) { broadcast('instances:changed'); 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) { broadcast('instances:changed'); 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 (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Instanz importieren', properties: ['openFile'], filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }], }); if (res.canceled) return { ok: false, canceled: true }; const result = await importInstanceZipFromPath(res.filePaths[0]); if (result && result.ok) broadcast('instances:changed'); return result; }); /* * 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); if (instanceRes && instanceRes.ok) broadcast('instances:changed'); 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 (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isFocused())) return; if (!Notification.isSupported()) return; const n = new Notification({ title, body, silent: false, icon: benachrichtigungsLogo || undefined }); 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() { broadcast('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) => { // für die Spielaufzeichnung merken, mit welchem Server (Quick Play) diese // Instanz zuletzt gestartet wurde -- muss im Hauptprozess liegen, weil // Serverliste und Aufzeichnung zwei getrennte Popup-Fenster mit jeweils // eigenem Renderer-Speicher sind if (launchOpts && launchOpts.quickPlayServer) letzteQuickPlayServer.set(id, launchOpts.quickPlayServer); else letzteQuickPlayServer.delete(id); 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) { broadcast('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' }); await backupNachZeitplan('vor-dem-start').catch(() => {}); } // Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten) let modUpdates = null; if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null); const opts = { javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(), 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; let freundeHerzschlag = null; let herzschlagJetzt = null; // sofort melden, z. B. sobald ein Server erkannt wurde letzteAusgabe.set(id, ''); try { let launchStart = 0; const res = await launcher.prepareAndLaunch(opts, (evt) => { if (evt.phase === 'launch') { launchStart = Date.now(); if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); } discordpresence.setActivity({ details: 'Spielt Minecraft', state: `${inst.name} · ${inst.minecraft.version}`, startTimestamp: launchStart, }); // Freundesystem: eigene Online-Meldung, nur wenn nicht in den // Einstellungen ausgeschaltet (siehe friendsEnabled) - sonst bleibt // man für niemanden auffindbar/sichtbar if (account && account.profile && store.getSettings().friendsEnabled !== false) { const sendeHerzschlag = () => { friends.heartbeat({ uuid: account.profile.id, username: account.profile.name, instance: inst.name, // nur, wenn man seinen Server zeigen möchte (Einstellung, live gelesen) server: store.getSettings().friendsShareServer === false ? '' : (letzteQuickPlayServer.get(id) || ''), // für "Beitreten": damit Freunde eine passende Instanz wählen können version: inst.minecraft.version || '', }).catch(() => {}); }; herzschlagJetzt = sendeHerzschlag; laufendeHerzschlaege.add(sendeHerzschlag); sendeHerzschlag(); freundeHerzschlag = setInterval(sendeHerzschlag, 60000); } } if (evt.phase === 'exit') { if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); } discordpresence.clearActivity(); if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; } if (herzschlagJetzt) laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId); laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg // beim nächsten Start dieses Kontos sollen bereits online befindliche // Freunde wieder gemeldet werden if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); } aktualisiereFreundeTastenkuerzel(); if (launchStart) { const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000)); const sitzungStart = launchStart; launchStart = 0; // Spielzeit nur einmal gutschreiben if (store.getSettings().autoWorldBackup !== false) { // kurz warten, bis Minecraft die Weltdateien sicher geschlossen hat setTimeout(() => { sichereZuletztGespielteWelt(id, sitzungStart).catch(() => {}); }, 3000); } const cur = store.getInstance(id); if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs }); } } if (evt.phase === 'log') { const bisher = (letzteAusgabe.get(id) || '') + evt.line; letzteAusgabe.set(id, bisher.length > AUSGABE_MAX ? bisher.slice(-AUSGABE_MAX) : bisher); const m = VERBINDUNGS_ZEILE.exec(evt.line); if (m) { const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`; letzteQuickPlayServer.set(id, server); // damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im // Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde broadcast('recording:serverDetected', { id, server }); // Freunde sollen den Server gleich sehen (für "Beitreten"), nicht erst beim nächsten Herzschlag if (herzschlagJetzt) herzschlagJetzt(); } } broadcast('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); discordpresence.clearActivity(); } if (!res.ok && freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; } if (!res.ok && herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; } if (res.ok) { const aktuell = store.getInstance(id); store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 }); } if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length; if (res.ok && res.pid) laufendePid.set(id, res.pid); if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); } return res; } catch (err) { if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); } if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; } if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; } return { ok: false, message: 'Start fehlgeschlagen: ' + err.message }; } }); // --------------------------------------------------------------------------- // IPC – Dialoge // --------------------------------------------------------------------------- ipcMain.handle('dialog:pickFolder', async (e, title) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 }); } broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length }); } if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); ipcMain.handle('cf:pickFolder', async (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 }); } broadcast('cf:progress', { current: done.length + failed.length, total: entries.length }); } if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); // ---------- Modrinth App ---------- ipcMain.handle('mr:pickFolder', async (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { title: 'Datenordner der Modrinth App wählen (meist %APPDATA%\\ModrinthApp)', defaultPath: mrimport.standardOrdner(), properties: ['openDirectory'], }); return res.canceled ? null : res.filePaths[0]; }); ipcMain.handle('mr:scan', async (_e, folder) => { try { return await mrimport.scan(folder); } catch (err) { return { ok: false, reason: err.message }; } }); /* * War die Instanz in der Modrinth App mit einem Modrinth-Modpack verknüpft, * übernehmen wir die Verknüpfung – samt der Liste der Dateien, die zum Paket * gehören. Ohne diese Liste würde "Modpack aktualisieren" alte Mods liegen * lassen; deshalb wird die Paketdatei einmal geladen und nur ihr Inhaltsverzeichnis * gelesen. Klappt das nicht (offline …), bleibt die Instanz ohne Verknüpfung. */ async function verknuepfeModrinthModpack(instId, link) { const kopf = { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } }; const vres = await fetch(`https://api.modrinth.com/v2/version/${encodeURIComponent(link.versionId)}`, kopf); if (!vres.ok) return false; const version = await vres.json(); const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0]; if (!datei || !datei.url) return false; const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); if (!dl.ok) return false; const buf = Buffer.from(await dl.arrayBuffer()); const zip = forge.readZipIndex(buf); if (!zip['modrinth.index.json']) return false; const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^\uFEFF/, '')); const dateien = (index.files || []) .filter((f) => f.path && !(f.env && f.env.client === 'unsupported')) .map((f) => String(f.path).replace(/\\/g, '/')); for (const prefix of ['overrides/', 'client-overrides/']) { for (const name of Object.keys(zip)) if (name.startsWith(prefix) && !name.endsWith('/')) dateien.push(name.slice(prefix.length)); } let titel = index.name || ''; try { const pres = await fetch(`https://api.modrinth.com/v2/project/${encodeURIComponent(link.projectId)}`, kopf); if (pres.ok) titel = (await pres.json()).title || titel; } catch { /* Titel ist nur Zierde */ } store.updateInstance(instId, { modpack: { quelle: 'modrinth', name: index.name || titel, title: titel, projectId: link.projectId, versionId: link.versionId, versionNummer: version.version_number || '', dateien, }, }); return true; } ipcMain.handle('mr:import', async (_e, entries, copyData) => { const done = []; const failed = []; const liste = entries || []; for (const entry of liste) { try { const imported = mrimport.importOne(store, entry, copyData !== false); if (entry.modpack && entry.modpack.projectId && entry.modpack.versionId) { broadcast('mr:progress', { current: done.length + failed.length, total: liste.length, detail: 'Modpack-Verknüpfung für ' + entry.name }); try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); } catch { imported.modpack = false; } } done.push(imported); } catch (err) { failed.push({ name: entry.name, message: err.message }); } broadcast('mr:progress', { current: done.length + failed.length, total: liste.length }); } if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); ipcMain.handle('mc:pickFolder', async (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 }); } broadcast('mc:progress', { current: done.length + failed.length, total: entries.length }); } if (done.length) broadcast('instances:changed'); return { ok: true, imported: done, failed }; }); ipcMain.handle('technic:pickFolder', async (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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 }); } broadcast('technic:progress', { current: done.length + failed.length, total: entries.length }); } if (done.length) broadcast('instances:changed'); 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) => { broadcast('technicPlatform:progress', p); }); if (res.ok && res.instance) { broadcast('instances:changed'); 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 (e) => { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || 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', () => pruefeAufUpdates()); ipcMain.handle('update:lastCheck', () => letzterUpdateCheck); // 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) => { broadcast('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 || '', })); // Lizenztext für die Info-Seite in den Einstellungen. Die LICENSE-Datei liegt // im App-Root und wird über "files" im electron-builder-Config mitgepackt. ipcMain.handle('app:license', () => { try { return fs.readFileSync(path.join(app.getAppPath(), 'LICENSE'), 'utf8'); } catch { return 'Lizenztext konnte nicht geladen werden.'; } });