diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..96fcada --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,109 @@ +# Changelog + +Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. + +## [1.1.4] - 2026-10-02 + +### Neu +- Neuer Installer im modernen Look: Schritt-für-Schritt-Assistent mit Lizenz, Ordnerwahl und Verknüpfungen, eigene Deinstallation (Instanzen und Welten bleiben auf Wunsch erhalten) – eine vorhandene Installation wird automatisch übernommen +- Optionaler Import im Installer: erkennt Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic und bietet deren Instanzen beim ersten Start zum Übernehmen an +- Updates aus dem Launcher heraus laufen jetzt ohne Rückfragen durch und starten AeroMC danach von selbst neu +- Freunde: „Beitreten“ startet eine passende Instanz und verbindet direkt mit dem Server, auf dem ein Freund spielt – gibt es keine passende Instanz, wird eine angelegt +- Freunde: „Einladen“ schickt einem Freund deinen aktuellen Server – er bekommt die Einladung im Launcher und als Meldung im Spiel (Strg+Alt+J kopiert dort die Adresse) +- Freunde: Der eigene Server lässt sich in den Einstellungen vor Freunden verbergen +- Clip-Modus in der Spielaufzeichnung: läuft im Hintergrund mit, Alt+F10 speichert die letzten 30, 60 oder 90 Sekunden als MP4 +- Performance-Paket beim Anlegen einer Fabric-, Quilt- oder NeoForge-Instanz: Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling und ModernFix per Häkchen mitinstallieren +- Automatische Mod-Updates vor dem Spielstart (Optionen → Spiel → Mods) +- Spielzeit-Statistik: Gesamtspielzeit, Spielstarts, meistgespielte Instanz und Spielzeit pro Instanz auf einen Blick + +### Geändert +- Die Suche nach Updates startet jetzt sofort beim Öffnen des Launchers, die Info-Seite zeigt das Ergebnis direkt an +- Direkt einem Server beitreten funktioniert jetzt auch bei Minecraft-Versionen vor 1.20 + +### Behoben +- Die Info-Seite zeigte „Kein Update-Check ausgeführt“, obwohl der Launcher beim Start bereits gesucht hatte + +## [1.1.3] - 2026-09-29 + +### Neu +- Freundesystem: Freunde per Minecraft-Name hinzufügen, Anfragen annehmen/ablehnen, gesendete Anfragen zurückziehen, Spitznamen vergeben – mit 3D-Spielerkopf und Online-Status +- In-Game-Meldung mit 3D-Spielerkopf, sobald ein Freund online kommt – bei mehreren gleichzeitig laufenden Konten erscheint sie im jeweils richtigen Spielfenster +- Tastenkombination Strg+Alt+F zeigt im Spiel an, welche Freunde gerade online sind (inklusive Server) +- Zähler für offene Freundschaftsanfragen am "Freunde"-Knopf +- Freundesystem in den Einstellungen abschaltbar – man ist dann weder sichtbar noch als Freund hinzufügbar, der "Freunde"-Knopf wird ausgeblendet +- Neuer Reiter "Aufzeichnungen" im Screenshot-Fenster mit Vorschaubildern, Abspielen und Löschen + +### Geändert +- Aufzeichnungen werden jetzt als MP4 gespeichert und laufen damit in jedem gängigen Player +- Der Mauszeiger ist in Aufzeichnungen nicht mehr zu sehen + +### Behoben +- Aufzeichnungen ruckelten bzw. hatten eine falsche Bildrate +- Wechsel des aktiven Microsoft-Kontos wurde nicht in allen Fenstern übernommen + +## [1.1.2] - 2026-09-27 + +### Neu +- Auflösung, Qualität und Bildrate der Spielaufzeichnung einstellbar (Nativ/1080p/720p/480p, Niedrig/Mittel/Hoch, 30/60 fps) – inklusive Anzeige der tatsächlich aufgenommenen Pixelgröße +- Lautstärke jeder einzelnen Tonspur der Aufzeichnung (Systemton, Nur Minecraft, jedes Mikrofon einzeln) regelbar, auch während eine Aufnahme bereits läuft +- Video- und Audioeinstellungen der Spielaufzeichnung bleiben jetzt über einen Neustart des Launchers hinweg erhalten + +### Geändert +- Anzeige "Aufnahme läuft" samt Timer sitzt jetzt über dem Start/Stopp-Knopf statt auf dem Videobild +- Mikrofonnamen in der Audioquellen-Auswahl zeigen keine technische USB-Kennung mehr (z. B. "(9886:0038)") + +### Behoben +- Servername in der Spielaufzeichnung fehlte, wenn man sich schon vor dem Öffnen des Aufzeichnungsfensters mit dem Server verbunden hatte – wird jetzt zentral erkannt und trägt sich auch nachträglich in den Titel ein +- Vorschaubild der Aufzeichnung blieb beim ersten Öffnen manchmal schwarz – wird jetzt automatisch erkannt und neu erfasst + +## [1.0.9] - 2026-08-09 + +### Behoben +- Update-Installation brach mit "EPERM: operation not permitted, lstat …" ab, wenn vom vorherigen Versuch noch eine gesperrte Installer-Datei im Temp-Ordner lag (z. B. durch einen Virenscanner) – der Download weicht jetzt automatisch auf einen neuen Dateinamen aus +- Autostart mit Windows öffnete im Entwicklungsmodus ein leeres Electron-Fenster statt AeroMC (gleiche Ursache wie zuvor bei den Desktop-Verknüpfungen), zusätzlich zeigte der Autostart-Eintrag bei der Portable-Fassung potenziell auf einen flüchtigen Temp-Pfad +- Wiederherstellen aus einem ungültigen oder beschädigten Backup-Archiv löschte die aktuellen, unveränderten Instanzen ersatzlos, obwohl der Vorgang mit einer Fehlermeldung abgebrochen wurde – der Rollback greift jetzt nur noch, wenn am Originalbestand überhaupt schon etwas verändert wurde + +## [1.0.8] - 2026-08-08 + +### Neu +- Datenpakete lassen sich jetzt wie Mods, Ressourcenpakete und Shader direkt aus dem Modrinth-Katalog durchsuchen, installieren, aktivieren/deaktivieren und aktualisieren +- Modpacks lassen sich jetzt auch aus dem CurseForge-Katalog durchsuchen und mit einem Klick installieren (bisher nur Modrinth und Technic; CurseForge ging nur per manuellem ZIP-Import) – braucht wie die bestehende CurseForge-Suche einen eigenen API-Key +- OptiFine als eigenständige Loader-Option: Installer-Datei auswählen, AeroMC bereitet die passende Minecraft-Version vor und startet den offiziellen OptiFine-Installer sichtbar; nach Abschluss wird die neue Version automatisch übernommen +- Von Hand hinzugefügte OptiFine-Dateien im Mods-Dialog werden jetzt erkannt und eigens gekennzeichnet (funktioniert bei jedem Loader, u. a. für Fabric + OptiFabric) +- Lizenztext direkt in den Einstellungen unter Info einsehbar + +### Geändert +- Lizenzangabe von MIT auf die tatsächlich geltenden Nutzungsbedingungen umgestellt (nur privater, nicht-kommerzieller Gebrauch – siehe LICENSE) + +## [1.0.7] - 2026-08-06 + +### Neu +- Desktop-Verknüpfung pro Server: startet die Instanz und verbindet direkt mit dem hinterlegten Server (Quick Play, ab MC 1.20) +- Name der Desktop-Verknüpfung (Instanz oder Server) lässt sich beim Erstellen frei selbst vergeben + +### Behoben +- Desktop-Verknüpfungen zeigten im Entwicklungsmodus einen leeren Electron-Startbildschirm statt AeroMC zu öffnen +- Desktop-Verknüpfungen zeigten im Entwicklungsmodus das Electron-Symbol statt des AeroMC-Logos +- Desktop-Verknüpfungen (Instanz und Server) starteten bei kaltem Start unzuverlässig gar keine Instanz – der Startwunsch traf manchmal ein, bevor die Oberfläche bereit war, ihn zu empfangen, und ging dann verloren + +## [1.0.6] - 2026-08-05 + +### Neu +- Technic-Modpacks direkt durchsuchen und installieren (Katalog), oder eine vorhandene Technic-Installation importieren +- Mods lassen sich jetzt auch als eigene Datei hinzufügen, nicht nur über die Suche +- Dateien (Mods, Modpacks, Instanzen) per Drag & Drop aufs Fenster ziehen +- Tastenkürzel: Strg+F (Suche), Strg+N (neue Instanz), Entf (Instanz löschen) +- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird +- Serverliste zeigt jetzt Live-Status (Spieleranzahl, MOTD, Version) +- Ein-Klick-Knopf für empfohlene GC-Flags bei den JVM-Argumenten +- Einzelne Welten lassen sich separat schnellsichern +- Warnung, wenn ein Mod entfernt wird, den ein anderer installierter Mod noch braucht +- Skin-Hintergrund kann automatisch zwischen mehreren angemeldeten Konten durchwechseln +- Optionaler Autostart mit Windows (im Infobereich statt als Fenster) + +### Geändert +- Update-Suche läuft automatisch beim Start, Anzeige wird grün wenn alles aktuell ist + +### Behoben +- Bedrock-Start repariert (ging nach einem Windows-Update nicht mehr) und startete zwischenzeitlich doppelt +- Absturz beim Schließen des Spiels durch hängende Mod-Threads (z. B. JourneyMap-Webmap) behoben diff --git a/package-lock.json b/package-lock.json index 3b9d659..b774c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aeromc", - "version": "1.1.3", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aeromc", - "version": "1.1.3", + "version": "1.1.4", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@xhayper/discord-rpc": "^1.3.4", diff --git a/package.json b/package.json index 4620438..e516ecf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aeromc", "productName": "AeroMC", - "version": "1.1.3", + "version": "1.1.4", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "homepage": "https://m-viper.de", "launcherWebsite": "https://aeromc.viper.ipv64.net", @@ -13,7 +13,7 @@ "start": "electron .", "prebuild": "node scripts/write-build-config.js", "prebuild:portable": "node scripts/write-build-config.js", - "build": "electron-builder --win nsis --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Setup.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"", + "build": "electron-builder --win dir && node scripts/build-setup.js", "build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"" }, "build": { @@ -32,12 +32,8 @@ "node_modules/ffmpeg-static/**/*" ], "win": { - "target": "nsis", + "target": "dir", "icon": "src/assets/icon.ico" - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true } }, "devDependencies": { diff --git a/src/friends.js b/src/friends.js index a86a20d..79e8f16 100644 --- a/src/friends.js +++ b/src/friends.js @@ -1,70 +1,85 @@ -'use strict'; - -/* - * friends.js – Anbindung an das AeroMC-Freundesystem (WordPress-Plugin) - * ----------------------------------------------------------------------------- - * Schlanke Fetch-Wrapper gegen die REST-Routen des "AeroMC Freunde"-Plugins - * (siehe wordpress-plugin/aeromc-friends/). Keine eigene Anmeldung - jeder - * Aufruf trägt einfach die Minecraft-UUID des aktiven Kontos. - * - * Genau wie bei discordpresence.js: jeder Fehler (Plugin nicht installiert, - * kein Internet, Seite down) wird abgefangen und nach oben als { ok:false, - * message } gereicht statt den Launcher zu stören - ein soziales Extra darf - * nie den Spielstart oder die Bedienung blockieren. - */ - -const BASIS_URL = 'https://viper-network.de/wp-json/aeromc/v1'; - -async function anfrage(pfad, { method = 'GET', body } = {}) { - try { - const url = method === 'GET' && body - ? `${BASIS_URL}${pfad}?${new URLSearchParams(body).toString()}` - : `${BASIS_URL}${pfad}`; - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: method === 'GET' ? undefined : JSON.stringify(body || {}), - }); - const daten = await res.json().catch(() => null); - if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` }; - return { ok: true, data: daten }; - } catch (err) { - return { ok: false, message: 'Freundesystem nicht erreichbar: ' + err.message }; - } -} - -function heartbeat({ uuid, username, instance, server }) { - return anfrage('/presence', { method: 'POST', body: { uuid, username, instance, server } }); -} - -function getFriends(uuid) { - return anfrage('/friends', { method: 'GET', body: { uuid } }); -} - -function getRequests(uuid) { - return anfrage('/friends/requests', { method: 'GET', body: { uuid } }); -} - -function getSentRequests(uuid) { - return anfrage('/friends/sent', { method: 'GET', body: { uuid } }); -} - -function sendRequest(uuid, username, targetUsername) { - return anfrage('/friends/request', { method: 'POST', body: { uuid, username, targetUsername } }); -} - -function respond(uuid, otherUuid, accept) { - return anfrage('/friends/respond', { method: 'POST', body: { uuid, otherUuid, accept } }); -} - -function cancelRequest(uuid, otherUuid) { - return anfrage('/friends/cancel', { method: 'POST', body: { uuid, otherUuid } }); -} - -function removeFriend(uuid, otherUuid) { - return anfrage('/friends/remove', { method: 'POST', body: { uuid, otherUuid } }); -} - -module.exports = { - heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend, -}; +'use strict'; + +/* + * friends.js – Anbindung an das AeroMC-Freundesystem (WordPress-Plugin) + * ----------------------------------------------------------------------------- + * Schlanke Fetch-Wrapper gegen die REST-Routen des "AeroMC Freunde"-Plugins + * (siehe wordpress-plugin/aeromc-friends/). Keine eigene Anmeldung - jeder + * Aufruf trägt einfach die Minecraft-UUID des aktiven Kontos. + * + * Genau wie bei discordpresence.js: jeder Fehler (Plugin nicht installiert, + * kein Internet, Seite down) wird abgefangen und nach oben als { ok:false, + * message } gereicht statt den Launcher zu stören - ein soziales Extra darf + * nie den Spielstart oder die Bedienung blockieren. + */ + +const BASIS_URL = 'https://viper-network.de/wp-json/aeromc/v1'; + +async function anfrage(pfad, { method = 'GET', body } = {}) { + try { + const url = method === 'GET' && body + ? `${BASIS_URL}${pfad}?${new URLSearchParams(body).toString()}` + : `${BASIS_URL}${pfad}`; + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'GET' ? undefined : JSON.stringify(body || {}), + }); + const daten = await res.json().catch(() => null); + if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` }; + return { ok: true, data: daten }; + } catch (err) { + return { ok: false, message: 'Freundesystem nicht erreichbar: ' + err.message }; + } +} + +function heartbeat({ uuid, username, instance, server, version }) { + return anfrage('/presence', { method: 'POST', body: { uuid, username, instance, server, version } }); +} + +function getFriends(uuid) { + return anfrage('/friends', { method: 'GET', body: { uuid } }); +} + +function getRequests(uuid) { + return anfrage('/friends/requests', { method: 'GET', body: { uuid } }); +} + +function getSentRequests(uuid) { + return anfrage('/friends/sent', { method: 'GET', body: { uuid } }); +} + +function sendRequest(uuid, username, targetUsername) { + return anfrage('/friends/request', { method: 'POST', body: { uuid, username, targetUsername } }); +} + +function respond(uuid, otherUuid, accept) { + return anfrage('/friends/respond', { method: 'POST', body: { uuid, otherUuid, accept } }); +} + +function cancelRequest(uuid, otherUuid) { + return anfrage('/friends/cancel', { method: 'POST', body: { uuid, otherUuid } }); +} + +function removeFriend(uuid, otherUuid) { + return anfrage('/friends/remove', { method: 'POST', body: { uuid, otherUuid } }); +} + +// Einladung "Komm auf meinen Server" (Adresse + Minecraft-Version des Einladenden) +function sendInvite(uuid, targetUuid, server, version) { + return anfrage('/friends/invite', { method: 'POST', body: { uuid, targetUuid, server, version } }); +} + +// an mich gerichtete, noch gültige Einladungen +function getInvites(uuid) { + return anfrage('/friends/invites', { method: 'GET', body: { uuid } }); +} + +function dismissInvite(uuid, id) { + return anfrage('/friends/invites/dismiss', { method: 'POST', body: { uuid, id } }); +} + +module.exports = { + heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend, + sendInvite, getInvites, dismissInvite, +}; diff --git a/src/index.html b/src/index.html index 05aee94..03c1835 100644 --- a/src/index.html +++ b/src/index.html @@ -1,1473 +1,1531 @@ - - - - - - AeroMC - - - - -
- - - - - - - - - -
- -
- - -
- - -
-
-
- -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + AeroMC + + + + +
+ + + + + + + + + + +
+ +
+ + +
+ + +
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/launcher.js b/src/launcher.js index 60d28f7..55a61dd 100644 --- a/src/launcher.js +++ b/src/launcher.js @@ -357,6 +357,13 @@ function buildArgs(vj, ctx) { * auth: null (kein Login) ODER { name, uuid, accessToken, userType } * onProgress: (evt) => void */ +// true für Release-Versionen vor 1.20 (kein Quick Play). Snapshots und +// Unbekanntes gelten als neu – die meisten heutigen Snapshots sind > 1.20. +function vorQuickPlay(versionId) { + const m = /^1\.(\d+)(?:\.\d+)?(?:-(?:pre|rc)\d+)?$/.exec(String(versionId || '').trim()); + return !!m && Number(m[1]) < 20; +} + async function prepareAndLaunch(opts, onProgress) { const emit = (phase, extra) => onProgress && onProgress(Object.assign({ phase }, extra)); @@ -526,9 +533,17 @@ async function prepareAndLaunch(opts, onProgress) { // Fenstergröße bzw. maximiert starten (Minecraft-Argumente) if (opts.mcMaximized) args.push('--fullscreen'); else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight)); - // Ab 1.20: direkt einem Server beitreten (Quick Play) + // Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die + // älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden) if (opts.quickPlayServer) { - args.push('--quickPlayMultiplayer', String(opts.quickPlayServer)); + const adresse = String(opts.quickPlayServer).trim(); + if (vorQuickPlay(opts.versionId)) { + const m = /^(.*?)(?::(\d+))?$/.exec(adresse); + args.push('--server', m[1]); + if (m[2]) args.push('--port', m[2]); + } else { + args.push('--quickPlayMultiplayer', adresse); + } } ensureDir(opts.gameDir); diff --git a/src/main.js b/src/main.js index b7cbe4f..aa8f8ed 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3015 +1,3351 @@ -'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 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'); - -// 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 }, - 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 }, -}; -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 - -// 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: '#1b1e24', - 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, - }, - }); - 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)); -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) -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; } - - console.log('[Aufzeichnung] gefunden:', JSON.stringify({ fensterBounds, bildschirmBounds: anzeige.bounds, screenSourceId: quelle.id })); - 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 }; - } -}); - -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. -const hatEinzelSperre = app.requestSingleInstanceLock(); -if (!hatEinzelSperre) app.quit(); - -/* - * Instanz-Kennung aus den Startargumenten lesen. - * - * Wichtig: Bei einem zweiten Start sortiert Chromium die Argumente um – - * alle Schalter wandern nach vorn, alles andere ans Ende. Aus - * --launch 1.21.11 - * wird dabei - * --launch --allow-file-access-from-files 1.21.11 - * und die Kennung steht nicht mehr hinter ihrem Schalter. Deshalb wird die - * Form "--launch=" verwendet: die bleibt ein einziges Argument. - */ -function instanzAusArgumenten(argv) { - const args = (argv || []).map(String); - - for (const a of args) { - if (a.startsWith('--launch=')) return a.slice('--launch='.length); - } - - // Notnagel für Verknüpfungen aus früheren Fassungen (und den Aufruf von - // Hand): Steht "--launch" allein da, ist die Kennung durch das Umsortieren - // ans Ende gerutscht – das letzte Argument, das kein Schalter ist. - if (args.includes('--launch')) { - const i = args.indexOf('--launch'); - if (args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]; - for (let j = args.length - 1; j > 0; j--) { - if (!args[j].startsWith('-')) return args[j]; - } - } - return null; -} - -// wie instanzAusArgumenten, nur für die optionale Server-Adresse einer -// Server-Verknüpfung ("--server=") – bleibt aus demselben Grund als ein -// einziges "--server=..."-Argument zusammen -function serverAusArgumenten(argv) { - const args = (argv || []).map(String); - for (const a of args) { - if (a.startsWith('--server=')) return a.slice('--server='.length); - } - return null; -} - -// Startwunsch an die Oberfläche geben – dort hängt die gesamte Rückmeldung dran -function starteInstanzImFenster(id, serverIp) { - if (!id || !mainWindow || mainWindow.isDestroyed()) return; - mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null }); -} - -app.on('second-instance', (_e, argv) => { - // showMainWindow() statt nur restore()+focus(): bei aktivem Autostart kann - // das Fenster echt versteckt sein (hide(), nicht nur minimiert) – das - // braucht ein show(), sonst bleibt es beim zweiten Start unsichtbar - showMainWindow(); - starteInstanzImFenster(instanzAusArgumenten(argv), serverAusArgumenten(argv)); -}); - -// Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf -// den entpackten Temp-Ordner – eine Verknüpfung dorthin wäre morgen tot. -function verknuepfungsZiel() { - if (updater.detectInstallKind() === 'portable') { - return updater.portableTarget() || process.execPath; - } - return process.execPath; -} - -/* - * Baut die Argumente für eine Verknüpfung zusammen. - * Im Entwicklungsmodus (npm start) zeigt process.execPath nur auf die nackte - * electron.exe – die weiß ohne Pfadangabe nicht, welche App sie laden soll, - * und zeigt stattdessen ihren eigenen Startbildschirm statt AeroMC. Im - * gepackten Build (Setup/Portable) ist die App fest eingebaut, daher dort - * kein Zusatz nötig. - */ -function verknuepfungsArgs(teile) { - const praefix = app.isPackaged ? '' : `"${path.join(__dirname, '..')}" `; - return praefix + teile.join(' '); -} - -/* - * Icon für eine Verknüpfung. Im gepackten Build trägt die EXE selbst schon - * das richtige Icon als Ressource (funktioniert auch aus dem asar heraus). - * Im Entwicklungsmodus zeigt "ziel" auf die nackte electron.exe (Atom-Logo) – - * dort stattdessen direkt die eigene .ico-Datei verwenden, die im - * Entwicklungsordner als normale Datei vorliegt. - */ -function verknuepfungsIcon(ziel) { - if (app.isPackaged) return ziel; - return windowIcon() || ziel; -} - -function windowIcon() { - const png = path.join(__dirname, 'assets', 'logo.png'); - const ico = path.join(__dirname, 'assets', 'icon.ico'); - if (fs.existsSync(ico)) return ico; - if (fs.existsSync(png)) return png; - return undefined; // Electron-Standard, solange kein Logo hinterlegt ist -} - -function createWindow(startHidden) { - mainWindow = new BrowserWindow({ - width: 1200, - height: 820, - minWidth: 940, - minHeight: 640, - backgroundColor: '#1b1e24', - title: 'AeroMC', - icon: windowIcon(), - autoHideMenuBar: true, - show: !startHidden, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - }, - }); - - mainWindow.loadFile(path.join(__dirname, 'index.html')); - - // Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im - // Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der - // Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist. - mainWindow.on('close', (e) => { - if (appIsQuitting || !store.getSettings().autostart) { - // 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)); - } -} - -let tray = null; -let appIsQuitting = false; - -function showMainWindow() { - if (!mainWindow || mainWindow.isDestroyed()) { createWindow(false); return; } - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); -} - -function createTray() { - if (tray) return; - try { - const iconPfad = windowIcon(); - const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty(); - tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 })); - tray.setToolTip('AeroMC'); - tray.setContextMenu(Menu.buildFromTemplate([ - { label: 'Öffnen', click: () => showMainWindow() }, - { type: 'separator' }, - { label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } }, - ])); - tray.on('click', () => showMainWindow()); - } catch { /* z. B. wenn kein Icon vorhanden ist – Autostart läuft dann ohne Tray-Symbol weiter */ } -} - -function destroyTray() { - if (tray) { tray.destroy(); tray = null; } -} - -// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden -function applyAutostart(enabled) { - try { - // 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 */ } - if (enabled) createTray(); - else destroyTray(); -} - -app.whenReady().then(() => { - if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst - store.init(app.getPath('userData')); - services.setCacheDir(app.getPath('userData')); // Java-Suche zwischenspeichern - // selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen - services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java'))); - // Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden) - auth.init(app.getPath('userData'), { - available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } }, - encrypt: (s) => safeStorage.encryptString(s).toString('base64'), - decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')), - }); - applyProxy(store.getSettings()); - 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); - // Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf - setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000); - setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000); - - // automatisch auf Updates prüfen (kurz nach dem Start) - setTimeout(async () => { - try { - const info = await updater.checkForUpdate(app.getVersion()); - pendingUpdateInfo = info; - // Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen - // kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt - broadcast('update:checked', info); - if (info.available) broadcast('update:available', info); - } catch (err) { - // offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen - broadcast('update:checked', { error: (err && err.message) || String(err) }); - } - }, 4000); - - // Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten) - setTimeout(() => checkAuthServices(), 2500); - setInterval(() => checkAuthServices(), 120000); - - // Anmeldung im Hintergrund auffrischen -> kein erneuter Login beim Start nötig - const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID; - auth.getValidAccount(cid) - .then((acc) => { - if (acc) 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(() => {}); - } - // 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'); - if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('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', async (_e, id, project, art) => { - const inst = store.getInstance(id); - if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; - const typ = services.inhaltsArt(art || 'mod'); - const gameVersion = inst.minecraft.version; - const loader = inst.minecraft.loader; - if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' }; - // Nur Mods brauchen einen Loader – Ressourcenpakete und Shader laufen auch - // in einer Vanilla-Instanz. - if (typ.loaderNoetig && (!loader || loader === 'vanilla')) { - return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' }; - } - - const modsDir = store.inhaltsDir(id, art || 'mod'); - const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId)); - const added = []; - let firstError = null; - const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-'))); - const zugang = istCf ? services.curseforgeZugang(store.getSettings()) : null; - - // installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten - async function installOne(proj, isDep) { - if (done.has(proj.projectId)) return; - done.add(proj.projectId); - try { - let res; - if (String(proj.projectId || '').startsWith('cf-') || proj.source === 'curseforge') { - res = await services.curseforgeInstall({ - zugang, projectId: proj.projectId, title: proj.title, - gameVersion, loader, modsDir, art: art || 'mod', - }); - if (!res.ok) { - if (!isDep && !firstError) firstError = res.message; - return; - } - // merken, welche Pflicht-Abhängigkeiten dieser Mod mitbringt – damit - // wir beim Entfernen warnen können, wenn ein anderer Mod ihn braucht - res.entry.requiredDeps = (res.dependencies || []) - .filter((d) => d.required && d.projectId).map((d) => d.projectId); - store.addMod(id, res.entry, art || 'mod'); - added.push(res.entry); - for (const dep of res.dependencies || []) { - if (dep.required && dep.projectId && !done.has(dep.projectId)) { - await installOne({ projectId: dep.projectId, title: dep.projectId, source: 'curseforge' }, true); - } - } - } else { - res = await services.modrinthInstall({ - projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod', - }); - if (!res.ok) { - if (!isDep && !firstError) firstError = res.message; - return; - } - res.entry.requiredDeps = (res.dependencies || []) - .filter((d) => d.dependency_type === 'required' && d.project_id).map((d) => d.project_id); - store.addMod(id, res.entry, art || 'mod'); - added.push(res.entry); - for (const dep of res.dependencies || []) { - if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) { - const title = await services.modrinthProjectTitle(dep.project_id); - await installOne({ projectId: dep.project_id, title }, true); - } - } - } - } catch (err) { - if (!isDep && !firstError) firstError = err.message; - } - } - - await installOne(project, false); - if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' }; - return { ok: true, entry: added[0], added, depCount: added.length - 1 }; -}); - -/* - * Lokale Datei(en) direkt in den Inhaltsordner kopieren – für Mods, die - * nicht über Modrinth/CurseForge zu finden sind (Dev-Build, privater Mod, - * per Hand zugeschickt). Die Datei taucht danach ganz normal in der - * "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt. - */ -ipcMain.handle('mods:addFile', async (e, id, art) => { - const inst = store.getInstance(id); - if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; - const typ = services.inhaltsArt(art || 'mod'); - const filters = (art || 'mod') === 'mod' - ? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }] - : [{ name: typ.label || 'Datei', extensions: ['zip'] }]; - const res = await dialog.showOpenDialog(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); -}); - -// ---- 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', async (_e, id, projectIds, art) => { - const inst = store.getInstance(id); - if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; - const gameVersion = inst.minecraft.version; - const loader = inst.minecraft.loader; - const sorte = art || 'mod'; - const modsDir = store.inhaltsDir(id, sorte); - const gewuenscht = new Set(projectIds || []); - const erneuert = []; - const fehler = []; - - for (const mod of store.listMods(id, sorte)) { - if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue; - try { - const res = await services.modrinthInstall({ - projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir, art: sorte, - }); - if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; } - store.addMod(id, res.entry, sorte); - // alte Datei erst weg, wenn die neue liegt – und nie, wenn sie gleich heißt - if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename, sorte); - // war der Eintrag abgeschaltet, bleibt er es auch nach dem Update - if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false, sorte); - erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber }); - } catch (err) { - fehler.push({ title: mod.title, message: err.message }); - } - } - return { ok: true, erneuert, fehler }; -}); - -// --------------------------------------------------------------------------- -// IPC – Microsoft-Login (Phase 3) -// --------------------------------------------------------------------------- - -ipcMain.handle('auth:status', () => auth.currentAccount()); -ipcMain.handle('auth:health', () => authHealth); -ipcMain.handle('auth:recheck', () => checkAuthServices()); -// "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal -ipcMain.handle('auth:accounts', () => auth.listAccounts() - .map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) }))); -ipcMain.handle('auth:setActive', (_e, id) => { - 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 - aktualisiereAnfragenZahl().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; -} - -ipcMain.handle('skin:profile', async () => { - const token = await aktivesToken(); - if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; - try { return await skins.holeProfil(token); } - catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; } -}); - -ipcMain.handle('skin:choose', async (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) 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) broadcast('skin:changed'); - return res; - } - catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } -}); -ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id)); -ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; }); - -// ---------- 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) }; - })); - const breite = 320; - const hoehe = 44 + zeilen.reduce((summe, z) => summe + (z.kopf ? 24 : 18), 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'; -let freundeTastenkuerzelAktiv = false; - -function aktualisiereFreundeTastenkuerzel() { - const soll = kontoPid.size > 0 && store.getSettings().friendsEnabled !== false; - if (soll && !freundeTastenkuerzelAktiv) { - freundeTastenkuerzelAktiv = globalShortcut.register(FREUNDE_TASTENKUERZEL, () => { - zeigeOnlineFreundeIngame().catch(() => {}); - }); - } else if (!soll && freundeTastenkuerzelAktiv) { - globalShortcut.unregister(FREUNDE_TASTENKUERZEL); - freundeTastenkuerzelAktiv = false; - } -} - -// 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() { - 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]; - 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, - ); -} - -// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ---------- -let letzteAnfragenZahl = 0; -async function aktualisiereAnfragenZahl() { - let zahl = 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 - zahl = (res.data || []).length; - } - letzteAnfragenZahl = zahl; - broadcast('friends:requestCount', zahl); -} -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 inst = store.getInstance(id); - if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; - const worldDir = path.join(store.gameDir(id), 'saves', String(worldName || '')); - if (path.dirname(worldDir) !== path.join(store.gameDir(id), 'saves') || !fs.existsSync(worldDir)) { - return { ok: false, message: 'Welt nicht gefunden.' }; - } - - try { - const zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen', - (inst.name || 'Instanz').replace(/[^\w.-]+/g, '_')); - fs.mkdirSync(zielOrdner, { recursive: true }); - const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16); - const safeWorld = String(worldName).replace(/[^\w.-]+/g, '_'); - const zielDatei = path.join(zielOrdner, `${safeWorld}_${zeitstempel}.zip`); - if (fs.existsSync(zielDatei)) fs.unlinkSync(zielDatei); - - await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force", - { VN_SRC: worldDir, VN_DST: zielDatei }); - notify('Welt gesichert', `${worldName} (${inst.name}) wurde gesichert.`); - return { ok: true, path: zielDatei }; - } catch (err) { - return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message }; - } -}); - -// ---- Modpack-Import (Modrinth .mrpack) ---- - -async function importMrpack(buf, zip) { - const raw = forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, ''); - const index = JSON.parse(raw); - const deps = index.dependencies || {}; - let loader = 'vanilla', loaderVersion = ''; - if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; } - else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; } - else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; } - else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; } - - const created = store.createOrReplaceImportedInstance({ - name: index.name || 'Modpack', - minecraft: { version: deps.minecraft || '', loader, loaderVersion }, - }); - const gameDir = store.gameDir(created.id); - const inside = (dest) => dest.startsWith(gameDir + path.sep); - - const eingespielt = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: false }); - - // Herkunft merken, damit sich das Paket später aktualisieren lässt - const herkunft = await modpackHerkunft(buf, index); - store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) }); - - return { - ok: true, - instance: store.getInstance(created.id), - fileCount: eingespielt.dateien.length, - }; -} - -/* - * Spielt ein .mrpack in ein Spielverzeichnis ein. - * schonePlayerDaten: bei einer Aktualisierung bleiben Welten, Bildschirmfotos - * und die Optionen unangetastet – sonst wären die Einstellungen des Spielers weg. - * -> { dateien: [relative Pfade] } - */ -const SPIELERDATEN = [/^saves\//i, /^screenshots\//i, /^logs\//i, /^options\.txt$/i, /^servers\.dat$/i]; - -async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) { - const inside = (dest) => dest.startsWith(gameDir + path.sep); - const geschuetzt = (rel) => schonePlayerDaten && SPIELERDATEN.some((r) => r.test(rel)); - const dateien = []; - - const files = (index.files || []).filter((f) => !(f.env && f.env.client === 'unsupported')); - let i = 0; - for (const f of files) { - i++; - const url = f.downloads && f.downloads[0]; - if (!url || !f.path) continue; - const rel = String(f.path).replace(/\\/g, '/'); - if (geschuetzt(rel)) continue; - const dest = path.join(gameDir, rel.replace(/\//g, path.sep)); - if (!inside(dest)) continue; - 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 }); - 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(() => {}); - } - - const opts = { - versionId: inst.minecraft.version, - loader: inst.minecraft.loader || 'vanilla', - loaderVersion: inst.minecraft.loaderVersion || '', - gameDir: store.gameDir(id), - sharedDir: path.join(app.getPath('userData'), 'shared'), - userDataDir: app.getPath('userData'), - javaPath: inst.java.path || settings.javaPath || '', - minMemMb: inst.java.minMemMb || settings.defaultMinMemMb, - maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb, - extraArgs: inst.java.extraArgs || '', - preLaunchCommand: inst.preLaunchCommand || settings.globalPreLaunch || '', - postExitCommand: inst.postExitCommand || settings.globalPostExit || '', - wrapperCommand: settings.wrapperCommand || '', - mcMaximized: !!settings.mcMaximized, - mcWidth: settings.mcWidth || 854, - mcHeight: settings.mcHeight || 480, - auth: null, - // fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar) - ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress), - quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '', - }; - - // eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start - const clientId = settings.azureClientId || auth.DEFAULT_CLIENT_ID; - const account = accountId - ? await auth.getValidAccountById(clientId, accountId) - : await auth.getValidAccount(clientId); - if (account && account.profile) { - opts.auth = { - name: account.profile.name, - uuid: account.profile.id, - accessToken: account.mcAccessToken, - userType: 'msa', - }; - } - - // Konto dieses Starts merken – nicht das aktive, denn das kann inzwischen - // gewechselt worden sein, während dieses Spiel noch läuft. - const kontoId = account && account.profile ? account.profile.id : null; - let gezaehlt = false; - let freundeHerzschlag = null; - - 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, - server: letzteQuickPlayServer.get(id) || '', - }).catch(() => {}); - }; - sendeHerzschlag(); - freundeHerzschlag = setInterval(sendeHerzschlag, 60000); - } - } - if (evt.phase === 'exit') { - if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); } - discordpresence.clearActivity(); - if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; } - 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)); - launchStart = 0; // Spielzeit nur einmal gutschreiben - const cur = store.getInstance(id); - if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs }); - } - } - if (evt.phase === 'log') { - 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 }); - } - } - 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) store.updateInstance(id, { lastPlayed: new Date().toISOString() }); - if (res.ok && res.pid) laufendePid.set(id, res.pid); - if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); aktualisiereFreundeTastenkuerzel(); } - return res; - } catch (err) { - if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); } - if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = 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 }; -}); - -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', async () => { - try { - const info = await updater.checkForUpdate(app.getVersion()); - pendingUpdateInfo = info; - return info; - } catch (err) { return { available: false, error: err.message }; } -}); - -// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden -ipcMain.handle('update:install', async (_e, asset) => { - try { - const file = await updater.downloadAndInstall(asset, (p) => { - 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.'; - } -}); +'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 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'); + +// 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 }, + 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: '#1b1e24', + 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)); +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) +// 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. +const hatEinzelSperre = app.requestSingleInstanceLock(); +if (!hatEinzelSperre) app.quit(); + +/* + * Instanz-Kennung aus den Startargumenten lesen. + * + * Wichtig: Bei einem zweiten Start sortiert Chromium die Argumente um – + * alle Schalter wandern nach vorn, alles andere ans Ende. Aus + * --launch 1.21.11 + * wird dabei + * --launch --allow-file-access-from-files 1.21.11 + * und die Kennung steht nicht mehr hinter ihrem Schalter. Deshalb wird die + * Form "--launch=" verwendet: die bleibt ein einziges Argument. + */ +function instanzAusArgumenten(argv) { + const args = (argv || []).map(String); + + for (const a of args) { + if (a.startsWith('--launch=')) return a.slice('--launch='.length); + } + + // Notnagel für Verknüpfungen aus früheren Fassungen (und den Aufruf von + // Hand): Steht "--launch" allein da, ist die Kennung durch das Umsortieren + // ans Ende gerutscht – das letzte Argument, das kein Schalter ist. + if (args.includes('--launch')) { + const i = args.indexOf('--launch'); + if (args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]; + for (let j = args.length - 1; j > 0; j--) { + if (!args[j].startsWith('-')) return args[j]; + } + } + return null; +} + +// wie instanzAusArgumenten, nur für die optionale Server-Adresse einer +// Server-Verknüpfung ("--server=") – bleibt aus demselben Grund als ein +// einziges "--server=..."-Argument zusammen +function serverAusArgumenten(argv) { + const args = (argv || []).map(String); + for (const a of args) { + if (a.startsWith('--server=')) return a.slice('--server='.length); + } + return null; +} + +// Startwunsch an die Oberfläche geben – dort hängt die gesamte Rückmeldung dran +function starteInstanzImFenster(id, serverIp) { + if (!id || !mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null }); +} + +app.on('second-instance', (_e, argv) => { + // showMainWindow() statt nur restore()+focus(): bei aktivem Autostart kann + // das Fenster echt versteckt sein (hide(), nicht nur minimiert) – das + // braucht ein show(), sonst bleibt es beim zweiten Start unsichtbar + showMainWindow(); + starteInstanzImFenster(instanzAusArgumenten(argv), serverAusArgumenten(argv)); +}); + +// Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf +// den entpackten Temp-Ordner – eine Verknüpfung dorthin wäre morgen tot. +function verknuepfungsZiel() { + if (updater.detectInstallKind() === 'portable') { + return updater.portableTarget() || process.execPath; + } + return process.execPath; +} + +/* + * Baut die Argumente für eine Verknüpfung zusammen. + * Im Entwicklungsmodus (npm start) zeigt process.execPath nur auf die nackte + * electron.exe – die weiß ohne Pfadangabe nicht, welche App sie laden soll, + * und zeigt stattdessen ihren eigenen Startbildschirm statt AeroMC. Im + * gepackten Build (Setup/Portable) ist die App fest eingebaut, daher dort + * kein Zusatz nötig. + */ +function verknuepfungsArgs(teile) { + const praefix = app.isPackaged ? '' : `"${path.join(__dirname, '..')}" `; + return praefix + teile.join(' '); +} + +/* + * Icon für eine Verknüpfung. Im gepackten Build trägt die EXE selbst schon + * das richtige Icon als Ressource (funktioniert auch aus dem asar heraus). + * Im Entwicklungsmodus zeigt "ziel" auf die nackte electron.exe (Atom-Logo) – + * dort stattdessen direkt die eigene .ico-Datei verwenden, die im + * Entwicklungsordner als normale Datei vorliegt. + */ +function verknuepfungsIcon(ziel) { + if (app.isPackaged) return ziel; + return windowIcon() || ziel; +} + +function windowIcon() { + const png = path.join(__dirname, 'assets', 'logo.png'); + const ico = path.join(__dirname, 'assets', 'icon.ico'); + if (fs.existsSync(ico)) return ico; + if (fs.existsSync(png)) return png; + return undefined; // Electron-Standard, solange kein Logo hinterlegt ist +} + +function createWindow(startHidden) { + mainWindow = new BrowserWindow({ + width: 1200, + height: 820, + minWidth: 940, + minHeight: 640, + backgroundColor: '#1b1e24', + title: 'AeroMC', + icon: windowIcon(), + autoHideMenuBar: true, + show: !startHidden, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); + + // Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im + // Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der + // Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist. + mainWindow.on('close', (e) => { + if (appIsQuitting || !store.getSettings().autostart) { + // 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()); +} + +/* + * 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', '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'); + tray.setContextMenu(Menu.buildFromTemplate([ + { label: 'Öffnen', click: () => showMainWindow() }, + { type: 'separator' }, + { label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } }, + ])); + tray.on('click', () => showMainWindow()); + } catch { /* z. B. wenn kein Icon vorhanden ist – Autostart läuft dann ohne Tray-Symbol weiter */ } +} + +function destroyTray() { + if (tray) { tray.destroy(); tray = null; } +} + +// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden +function applyAutostart(enabled) { + try { + // 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 */ } + if (enabled) createTray(); + else destroyTray(); +} + +app.whenReady().then(() => { + if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst + store.init(app.getPath('userData')); + services.setCacheDir(app.getPath('userData')); // Java-Suche zwischenspeichern + // selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen + services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java'))); + // Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden) + auth.init(app.getPath('userData'), { + available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } }, + encrypt: (s) => safeStorage.encryptString(s).toString('base64'), + decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')), + }); + applyProxy(store.getSettings()); + 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); + // 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'); + if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('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', + }); + 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); +}); + +// ---- 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 + aktualisiereAnfragenZahl().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; +} + +ipcMain.handle('skin:profile', async () => { + const token = await aktivesToken(); + if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; + try { return await skins.holeProfil(token); } + catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; } +}); + +ipcMain.handle('skin:choose', async (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) 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) broadcast('skin:changed'); + return res; + } + catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } +}); +ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id)); +ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; }); + +// ---------- 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) }; + })); + const breite = 320; + const hoehe = 44 + zeilen.reduce((summe, z) => summe + (z.kopf ? 24 : 18), 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); +}); + +// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ---------- +let letzteAnfragenZahl = 0; +async function aktualisiereAnfragenZahl() { + let zahl = 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 + zahl = (res.data || []).length; + } + letzteAnfragenZahl = zahl; + broadcast('friends:requestCount', zahl); +} +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 inst = store.getInstance(id); + if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; + const worldDir = path.join(store.gameDir(id), 'saves', String(worldName || '')); + if (path.dirname(worldDir) !== path.join(store.gameDir(id), 'saves') || !fs.existsSync(worldDir)) { + return { ok: false, message: 'Welt nicht gefunden.' }; + } + + try { + const zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen', + (inst.name || 'Instanz').replace(/[^\w.-]+/g, '_')); + fs.mkdirSync(zielOrdner, { recursive: true }); + const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16); + const safeWorld = String(worldName).replace(/[^\w.-]+/g, '_'); + const zielDatei = path.join(zielOrdner, `${safeWorld}_${zeitstempel}.zip`); + if (fs.existsSync(zielDatei)) fs.unlinkSync(zielDatei); + + await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force", + { VN_SRC: worldDir, VN_DST: zielDatei }); + notify('Welt gesichert', `${worldName} (${inst.name}) wurde gesichert.`); + return { ok: true, path: zielDatei }; + } catch (err) { + return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message }; + } +}); + +// ---- Modpack-Import (Modrinth .mrpack) ---- + +async function importMrpack(buf, zip) { + const raw = forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, ''); + const index = JSON.parse(raw); + const deps = index.dependencies || {}; + let loader = 'vanilla', loaderVersion = ''; + if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; } + else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; } + else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; } + else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; } + + const created = store.createOrReplaceImportedInstance({ + name: index.name || 'Modpack', + minecraft: { version: deps.minecraft || '', loader, loaderVersion }, + }); + const gameDir = store.gameDir(created.id); + const inside = (dest) => dest.startsWith(gameDir + path.sep); + + const eingespielt = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: false }); + + // Herkunft merken, damit sich das Paket später aktualisieren lässt + const herkunft = await modpackHerkunft(buf, index); + store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) }); + + return { + ok: true, + instance: store.getInstance(created.id), + fileCount: eingespielt.dateien.length, + }; +} + +/* + * Spielt ein .mrpack in ein Spielverzeichnis ein. + * schonePlayerDaten: bei einer Aktualisierung bleiben Welten, Bildschirmfotos + * und die Optionen unangetastet – sonst wären die Einstellungen des Spielers weg. + * -> { dateien: [relative Pfade] } + */ +const SPIELERDATEN = [/^saves\//i, /^screenshots\//i, /^logs\//i, /^options\.txt$/i, /^servers\.dat$/i]; + +async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) { + const inside = (dest) => dest.startsWith(gameDir + path.sep); + const geschuetzt = (rel) => schonePlayerDaten && SPIELERDATEN.some((r) => r.test(rel)); + const dateien = []; + + const files = (index.files || []).filter((f) => !(f.env && f.env.client === 'unsupported')); + let i = 0; + for (const f of files) { + i++; + const url = f.downloads && f.downloads[0]; + if (!url || !f.path) continue; + const rel = String(f.path).replace(/\\/g, '/'); + if (geschuetzt(rel)) continue; + const dest = path.join(gameDir, rel.replace(/\//g, path.sep)); + if (!inside(dest)) continue; + 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 }); + 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 = { + 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 + + 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)); + launchStart = 0; // Spielzeit nur einmal gutschreiben + const cur = store.getInstance(id); + if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs }); + } + } + if (evt.phase === 'log') { + 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 }; +}); + +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.'; + } +}); diff --git a/src/preload.js b/src/preload.js index 8ce0d58..39d92db 100644 --- a/src/preload.js +++ b/src/preload.js @@ -1,327 +1,352 @@ -'use strict'; - -/* - * preload.js – stellt dem Renderer eine schmale, sichere API bereit. - * contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff. - */ - -const { contextBridge, ipcRenderer } = require('electron'); - -// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses, -// damit man sie auch ohne offene DevTools sieht -window.addEventListener('error', (e) => { - ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message); -}); -window.addEventListener('unhandledrejection', (e) => { - ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason)); -}); - -contextBridge.exposeInMainWorld('api', { - // Einstellungen - getSettings: () => ipcRenderer.invoke('settings:get'), - saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch), - getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'), - detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath), - scanJava: () => ipcRenderer.invoke('java:scan'), - listJava: () => ipcRenderer.invoke('java:list'), - - // Minecraft-Versionen (Mojang) - mcVersions: () => ipcRenderer.invoke('mc:versions'), - loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion), - optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion), - optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id), - optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id), - - // Mods (Modrinth) - modSearch: (opts) => ipcRenderer.invoke('mods:search', opts), - curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey), - // art: 'mod' | 'resourcepack' | 'shader' - modList: (id, art) => ipcRenderer.invoke('mods:list', id, art), - modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art), - modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art), - modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art), - modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art), - modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art), - systemMemory: () => ipcRenderer.invoke('system:memory'), - checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb), - crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit), - onAutoBackup: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('backup:auto', l); - return () => ipcRenderer.removeListener('backup:auto', l); - }, - crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id), - serversList: (id) => ipcRenderer.invoke('servers:list', id), - serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag), - serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip), - serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName), - serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag), - serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip), - logsList: (id) => ipcRenderer.invoke('logs:list', id), - logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name), - logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id), - modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art), - modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art), - onModUpdateProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('mods:updateProgress', l); - return () => ipcRenderer.removeListener('mods:updateProgress', l); - }, - - // Instanzen - listInstances: () => ipcRenderer.invoke('instances:list'), - getInstance: (id) => ipcRenderer.invoke('instances:get', id), - createInstance: (data) => ipcRenderer.invoke('instances:create', data), - updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch), - deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id), - duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id), - openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id), - openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id), - - // Welten & Ressourcenpakete - worlds: (id) => ipcRenderer.invoke('instance:worlds', id), - resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id), - datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id), - deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name), - backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName), - openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub), - screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id), - screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name), - openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name), - launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts), - exportInstance: (id) => ipcRenderer.invoke('instances:export', id), - createGlobalBackup: () => ipcRenderer.invoke('backup:global'), - listGlobalBackups: () => ipcRenderer.invoke('backup:list'), - restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath), - onBackupProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('backup:progress', l); - return () => ipcRenderer.removeListener('backup:progress', l); - }, - importInstance: () => ipcRenderer.invoke('instances:import'), - createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName), - serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName), - // Start über eine Desktop-Verknüpfung ("--launch=", optional "--server=") - onLaunchRequest: (cb) => { - const l = (_e, payload) => cb(payload); - ipcRenderer.on('instance:launch-request', l); - return () => ipcRenderer.removeListener('instance:launch-request', l); - }, - // Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist – - // verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht, - // weil er ankommt, bevor die Oberfläche fertig eingerichtet ist - notifyReady: () => ipcRenderer.send('renderer:ready'), - importModpack: () => ipcRenderer.invoke('modpack:import'), - // Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip) - filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt), - modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts), - modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project), - modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project), - modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id), - modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId), - onLaunchProgress: (cb) => { - const listener = (_e, data) => cb(data); - ipcRenderer.on('launch:progress', listener); - return () => ipcRenderer.removeListener('launch:progress', listener); - }, - - // eigenes Instanz-Bild - pickImage: () => ipcRenderer.invoke('dialog:pickImage'), - setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath), - clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id), - instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id), - fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p), - - // Microsoft-Login (Phase 3) + Mehrkonten - authStatus: () => ipcRenderer.invoke('auth:status'), - authAccounts: () => ipcRenderer.invoke('auth:accounts'), - authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id), - skinProfile: () => ipcRenderer.invoke('skin:profile'), - skinChoose: () => ipcRenderer.invoke('skin:choose'), - skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante), - skinReset: () => ipcRenderer.invoke('skin:reset'), - authRemove: (id) => ipcRenderer.invoke('auth:remove', id), - authLogin: () => ipcRenderer.invoke('auth:login'), - authLogout: () => ipcRenderer.invoke('auth:logout'), - authHealth: () => ipcRenderer.invoke('auth:health'), - authRecheck: () => ipcRenderer.invoke('auth:recheck'), - onAuthHealth: (cb) => { - const listener = (_e, data) => cb(data); - ipcRenderer.on('auth:health', listener); - return () => ipcRenderer.removeListener('auth:health', listener); - }, - // meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen - onAccountsChanged: (cb) => { - const listener = () => cb(); - ipcRenderer.on('accounts:changed', listener); - return () => ipcRenderer.removeListener('accounts:changed', listener); - }, - onAuthRefreshed: (cb) => { - const listener = () => cb(); - ipcRenderer.on('auth:refreshed', listener); - return () => ipcRenderer.removeListener('auth:refreshed', listener); - }, - onAuthCode: (cb) => { - const listener = (_e, data) => cb(data); - ipcRenderer.on('auth:code', listener); - return () => ipcRenderer.removeListener('auth:code', listener); - }, - - // Dialoge / App - // MultiMC-Import - mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'), - mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder), - mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData), - onMmcProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('mmc:progress', l); - return () => ipcRenderer.removeListener('mmc:progress', l); - }, - cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'), - cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder), - cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData), - onCfProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('cf:progress', l); - return () => ipcRenderer.removeListener('cf:progress', l); - }, - mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'), - mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder), - mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData), - onMcProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('mc:progress', l); - return () => ipcRenderer.removeListener('mc:progress', l); - }, - technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'), - technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder), - technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData), - onTechnicProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('technic:progress', l); - return () => ipcRenderer.removeListener('technic:progress', l); - }, - technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query), - technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug), - onTechnicPlatformProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('technicPlatform:progress', l); - return () => ipcRenderer.removeListener('technicPlatform:progress', l); - }, - - // Bedrock (Windows-Store-Ausgabe) - bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'), - bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'), - bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'), - bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind), - bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name), - bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind), - bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'), - bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir), - - // echte Minecraft-Icons - iconsList: () => ipcRenderer.invoke('icons:list'), - iconsExtract: () => ipcRenderer.invoke('icons:extract'), - - // Updates - updateCheck: () => ipcRenderer.invoke('update:check'), - updateInstall: (asset) => ipcRenderer.invoke('update:install', asset), - onUpdateAvailable: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('update:available', l); - return () => ipcRenderer.removeListener('update:available', l); - }, - // Ergebnis des automatischen Checks beim Start – auch wenn kein Update - // verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei - // verfügbarem Update feuert) - onUpdateChecked: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('update:checked', l); - return () => ipcRenderer.removeListener('update:checked', l); - }, - onUpdateProgress: (cb) => { - const l = (_e, d) => cb(d); - ipcRenderer.on('update:progress', l); - return () => ipcRenderer.removeListener('update:progress', l); - }, - - openExternal: (url) => ipcRenderer.invoke('app:openExternal', url), - pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title), - pickJava: () => ipcRenderer.invoke('dialog:pickJava'), - appInfo: () => ipcRenderer.invoke('app:info'), - appLicense: () => ipcRenderer.invoke('app:license'), - - // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster - popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params), - popoutHide: () => ipcRenderer.send('popout:hide'), - onPopoutReopen: (cb) => { - const l = (_e, params) => cb(params); - ipcRenderer.on('popout:reopen', l); - return () => ipcRenderer.removeListener('popout:reopen', l); - }, - onSettingsChanged: (cb) => { - const l = (_e, s) => cb(s); - ipcRenderer.on('settings:changed', l); - return () => ipcRenderer.removeListener('settings:changed', l); - }, - onInstancesChanged: (cb) => { - const l = () => cb(); - ipcRenderer.on('instances:changed', l); - return () => ipcRenderer.removeListener('instances:changed', l); - }, - onSkinChanged: (cb) => { - const l = () => cb(); - ipcRenderer.on('skin:changed', l); - return () => ipcRenderer.removeListener('skin:changed', l); - }, - updateGetPending: () => ipcRenderer.invoke('update:getPending'), - - // Spielaufzeichnung - recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'), - recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'), - recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps), - recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName), - recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName), - recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname), - recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname), - recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname), - - // Freundesystem - friendsEnabled: () => ipcRenderer.invoke('friends:enabled'), - friendsList: () => ipcRenderer.invoke('friends:list'), - friendsRequests: () => ipcRenderer.invoke('friends:requests'), - friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername), - friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept), - friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid), - friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'), - friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid), - friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname), - friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'), - onFriendsRequestCount: (cb) => { - const l = (_e, zahl) => cb(zahl); - ipcRenderer.on('friends:requestCount', l); - return () => ipcRenderer.removeListener('friends:requestCount', l); - }, - recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId), - onRecordingServerDetected: (cb) => { - const l = (_e, payload) => cb(payload); - ipcRenderer.on('recording:serverDetected', l); - return () => ipcRenderer.removeListener('recording:serverDetected', l); - }, - recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId), - recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId), - recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel), - recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid), - recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'), - onGameAudioChunk: (cb) => { - const l = (_e, chunk) => cb(chunk); - ipcRenderer.on('recording:gameAudioChunk', l); - return () => ipcRenderer.removeListener('recording:gameAudioChunk', l); - }, - recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active), - onRecordCloseAttempt: (cb) => { - const l = () => cb(); - ipcRenderer.on('record:closeAttempt', l); - return () => ipcRenderer.removeListener('record:closeAttempt', l); - }, -}); +'use strict'; + +/* + * preload.js – stellt dem Renderer eine schmale, sichere API bereit. + * contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff. + */ + +const { contextBridge, ipcRenderer } = require('electron'); + +// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses, +// damit man sie auch ohne offene DevTools sieht +window.addEventListener('error', (e) => { + ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message); +}); +window.addEventListener('unhandledrejection', (e) => { + ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason)); +}); + +contextBridge.exposeInMainWorld('api', { + // Einstellungen + getSettings: () => ipcRenderer.invoke('settings:get'), + saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch), + getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'), + detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath), + scanJava: () => ipcRenderer.invoke('java:scan'), + listJava: () => ipcRenderer.invoke('java:list'), + + // Minecraft-Versionen (Mojang) + mcVersions: () => ipcRenderer.invoke('mc:versions'), + loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion), + optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion), + optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id), + optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id), + + // Mods (Modrinth) + modSearch: (opts) => ipcRenderer.invoke('mods:search', opts), + curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey), + // art: 'mod' | 'resourcepack' | 'shader' + modList: (id, art) => ipcRenderer.invoke('mods:list', id, art), + modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art), + modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art), + modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art), + modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art), + modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art), + systemMemory: () => ipcRenderer.invoke('system:memory'), + checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb), + crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit), + onAutoBackup: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('backup:auto', l); + return () => ipcRenderer.removeListener('backup:auto', l); + }, + crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id), + serversList: (id) => ipcRenderer.invoke('servers:list', id), + serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag), + serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip), + serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName), + serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag), + serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip), + logsList: (id) => ipcRenderer.invoke('logs:list', id), + logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name), + logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id), + modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art), + modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art), + onModUpdateProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('mods:updateProgress', l); + return () => ipcRenderer.removeListener('mods:updateProgress', l); + }, + + // Instanzen + listInstances: () => ipcRenderer.invoke('instances:list'), + getInstance: (id) => ipcRenderer.invoke('instances:get', id), + createInstance: (data) => ipcRenderer.invoke('instances:create', data), + installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id), + updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch), + deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id), + duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id), + openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id), + openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id), + + // Welten & Ressourcenpakete + worlds: (id) => ipcRenderer.invoke('instance:worlds', id), + resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id), + datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id), + deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name), + backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName), + openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub), + screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id), + screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name), + openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name), + launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts), + exportInstance: (id) => ipcRenderer.invoke('instances:export', id), + createGlobalBackup: () => ipcRenderer.invoke('backup:global'), + listGlobalBackups: () => ipcRenderer.invoke('backup:list'), + restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath), + onBackupProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('backup:progress', l); + return () => ipcRenderer.removeListener('backup:progress', l); + }, + importInstance: () => ipcRenderer.invoke('instances:import'), + createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName), + serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName), + // Start über eine Desktop-Verknüpfung ("--launch=", optional "--server=") + onLaunchRequest: (cb) => { + const l = (_e, payload) => cb(payload); + ipcRenderer.on('instance:launch-request', l); + return () => ipcRenderer.removeListener('instance:launch-request', l); + }, + // Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist – + // verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht, + // weil er ankommt, bevor die Oberfläche fertig eingerichtet ist + notifyReady: () => ipcRenderer.send('renderer:ready'), + importModpack: () => ipcRenderer.invoke('modpack:import'), + // Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip) + filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt), + modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts), + modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project), + modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project), + modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id), + modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId), + onLaunchProgress: (cb) => { + const listener = (_e, data) => cb(data); + ipcRenderer.on('launch:progress', listener); + return () => ipcRenderer.removeListener('launch:progress', listener); + }, + + // eigenes Instanz-Bild + pickImage: () => ipcRenderer.invoke('dialog:pickImage'), + setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath), + clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id), + instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id), + fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p), + + // Microsoft-Login (Phase 3) + Mehrkonten + authStatus: () => ipcRenderer.invoke('auth:status'), + authAccounts: () => ipcRenderer.invoke('auth:accounts'), + authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id), + skinProfile: () => ipcRenderer.invoke('skin:profile'), + skinChoose: () => ipcRenderer.invoke('skin:choose'), + skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante), + skinReset: () => ipcRenderer.invoke('skin:reset'), + authRemove: (id) => ipcRenderer.invoke('auth:remove', id), + authLogin: () => ipcRenderer.invoke('auth:login'), + authLogout: () => ipcRenderer.invoke('auth:logout'), + authHealth: () => ipcRenderer.invoke('auth:health'), + authRecheck: () => ipcRenderer.invoke('auth:recheck'), + onAuthHealth: (cb) => { + const listener = (_e, data) => cb(data); + ipcRenderer.on('auth:health', listener); + return () => ipcRenderer.removeListener('auth:health', listener); + }, + // meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen + onAccountsChanged: (cb) => { + const listener = () => cb(); + ipcRenderer.on('accounts:changed', listener); + return () => ipcRenderer.removeListener('accounts:changed', listener); + }, + onAuthRefreshed: (cb) => { + const listener = () => cb(); + ipcRenderer.on('auth:refreshed', listener); + return () => ipcRenderer.removeListener('auth:refreshed', listener); + }, + onAuthCode: (cb) => { + const listener = (_e, data) => cb(data); + ipcRenderer.on('auth:code', listener); + return () => ipcRenderer.removeListener('auth:code', listener); + }, + + // Dialoge / App + // MultiMC-Import + mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'), + mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder), + mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData), + onMmcProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('mmc:progress', l); + return () => ipcRenderer.removeListener('mmc:progress', l); + }, + cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'), + cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder), + cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData), + onCfProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('cf:progress', l); + return () => ipcRenderer.removeListener('cf:progress', l); + }, + mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'), + mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder), + mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData), + onMcProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('mc:progress', l); + return () => ipcRenderer.removeListener('mc:progress', l); + }, + technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'), + technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder), + technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData), + onTechnicProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('technic:progress', l); + return () => ipcRenderer.removeListener('technic:progress', l); + }, + technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query), + technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug), + onTechnicPlatformProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('technicPlatform:progress', l); + return () => ipcRenderer.removeListener('technicPlatform:progress', l); + }, + + // Bedrock (Windows-Store-Ausgabe) + bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'), + bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'), + bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'), + bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind), + bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name), + bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind), + bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'), + bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir), + + // echte Minecraft-Icons + iconsList: () => ipcRenderer.invoke('icons:list'), + iconsExtract: () => ipcRenderer.invoke('icons:extract'), + + // Updates + updateCheck: () => ipcRenderer.invoke('update:check'), + updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'), + updateInstall: (asset) => ipcRenderer.invoke('update:install', asset), + onUpdateAvailable: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('update:available', l); + return () => ipcRenderer.removeListener('update:available', l); + }, + // Ergebnis des automatischen Checks beim Start – auch wenn kein Update + // verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei + // verfügbarem Update feuert) + onUpdateChecked: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('update:checked', l); + return () => ipcRenderer.removeListener('update:checked', l); + }, + onUpdateProgress: (cb) => { + const l = (_e, d) => cb(d); + ipcRenderer.on('update:progress', l); + return () => ipcRenderer.removeListener('update:progress', l); + }, + + openExternal: (url) => ipcRenderer.invoke('app:openExternal', url), + pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title), + pickJava: () => ipcRenderer.invoke('dialog:pickJava'), + appInfo: () => ipcRenderer.invoke('app:info'), + appLicense: () => ipcRenderer.invoke('app:license'), + + // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster + popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params), + popoutHide: () => ipcRenderer.send('popout:hide'), + onPopoutReopen: (cb) => { + const l = (_e, params) => cb(params); + ipcRenderer.on('popout:reopen', l); + return () => ipcRenderer.removeListener('popout:reopen', l); + }, + onSettingsChanged: (cb) => { + const l = (_e, s) => cb(s); + ipcRenderer.on('settings:changed', l); + return () => ipcRenderer.removeListener('settings:changed', l); + }, + onInstancesChanged: (cb) => { + const l = () => cb(); + ipcRenderer.on('instances:changed', l); + return () => ipcRenderer.removeListener('instances:changed', l); + }, + onSkinChanged: (cb) => { + const l = () => cb(); + ipcRenderer.on('skin:changed', l); + return () => ipcRenderer.removeListener('skin:changed', l); + }, + updateGetPending: () => ipcRenderer.invoke('update:getPending'), + + // Spielaufzeichnung + recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'), + recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'), + recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps), + recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName), + recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName), + recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname), + recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname), + recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname), + + // Freundesystem + friendsEnabled: () => ipcRenderer.invoke('friends:enabled'), + friendsList: () => ipcRenderer.invoke('friends:list'), + friendsRequests: () => ipcRenderer.invoke('friends:requests'), + friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername), + friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept), + friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid), + friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'), + friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid), + friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname), + friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'), + clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId), + clipSave: (data) => ipcRenderer.invoke('clip:save', data), + onClipHotkey: (cb) => { + const l = () => cb(); + ipcRenderer.on('clip:hotkey', l); + return () => ipcRenderer.removeListener('clip:hotkey', l); + }, + onUiToast: (cb) => { + const l = (_e, t) => cb(t); + ipcRenderer.on('ui:toast', l); + return () => ipcRenderer.removeListener('ui:toast', l); + }, + friendsJoin: (server, version) => ipcRenderer.invoke('friends:join', server, version), + friendsJoinNew: (server, version) => ipcRenderer.invoke('friends:joinNew', server, version), + friendsMyServer: () => ipcRenderer.invoke('friends:myServer'), + friendsInvite: (targetUuid) => ipcRenderer.invoke('friends:invite', targetUuid), + friendsInvites: () => ipcRenderer.invoke('friends:invites'), + friendsDismissInvite: (id) => ipcRenderer.invoke('friends:dismissInvite', id), + onFriendsInvite: (cb) => { + const l = (_e, einladung) => cb(einladung); + ipcRenderer.on('friends:invite', l); + return () => ipcRenderer.removeListener('friends:invite', l); + }, + onFriendsRequestCount: (cb) => { + const l = (_e, zahl) => cb(zahl); + ipcRenderer.on('friends:requestCount', l); + return () => ipcRenderer.removeListener('friends:requestCount', l); + }, + recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId), + onRecordingServerDetected: (cb) => { + const l = (_e, payload) => cb(payload); + ipcRenderer.on('recording:serverDetected', l); + return () => ipcRenderer.removeListener('recording:serverDetected', l); + }, + recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId), + recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId), + recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel), + recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid), + recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'), + onGameAudioChunk: (cb) => { + const l = (_e, chunk) => cb(chunk); + ipcRenderer.on('recording:gameAudioChunk', l); + return () => ipcRenderer.removeListener('recording:gameAudioChunk', l); + }, + recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active), + onRecordCloseAttempt: (cb) => { + const l = () => cb(); + ipcRenderer.on('record:closeAttempt', l); + return () => ipcRenderer.removeListener('record:closeAttempt', l); + }, +}); diff --git a/src/renderer.js b/src/renderer.js index 3e4d5b3..d708f24 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1,5607 +1,5955 @@ -'use strict'; - -/* renderer.js – UI-Logik für AeroMC */ - -// ---------- Instanz-Icons: eigene Pixel-Grafiken im Minecraft-Stil ---------- -// Jedes Icon ist ein 16x16-Raster; jedes Zeichen verweist auf eine Farbe der Palette. -const PIX = { - grass: { p: { a: '#7cbd56', b: '#63a343', c: '#8b6b4a', d: '#6f5238' }, r: [ - 'aaaaaaaaaaaaaaaa', 'abaabaaabaabaaab', 'aaaaaaaaaaaaaaaa', 'babaabaabaaabaab', - 'cccccccccccccccc', 'cdcccdcccccdccdc', 'ccccccccdcccccdc', 'cccdccccccccdccc', - 'dcccccdcccccdccc', 'ccccdccccdccccdc', 'cdccccccccccccdc', 'ccccccdcccccdccc', - 'ccdcccccccdccccc', 'cccccccdccccccdc', 'cdccccccccccdccc', 'cccccccccccccccc', - ] }, - creeper: { p: { a: '#7cbd56', b: '#5f9c42', k: '#0d0f0d' }, r: [ - 'abbaabbaabbaabba', 'baabbaabbaabbaab', 'abbaabbaabbaabba', 'aakkaaaaaaaakkaa', - 'aakkaaaaaaaakkaa', 'aaaaaakkkkaaaaaa', 'aaaaakkkkkkaaaaa', 'aaaaakkkkkkaaaaa', - 'aaaaakkaakkaaaaa', 'aaaaakkaakkaaaaa', 'abbaabbaabbaabba', 'baabbaabbaabbaab', - 'abbaabbaabbaabba', 'baabbaabbaabbaab', 'abbaabbaabbaabba', 'baabbaabbaabbaab', - ] }, - diamond: { p: { a: '#5ce4e0', b: '#3ec6c9', c: '#2aa3ab', w: '#c9fbff', t: null }, r: [ - 'tttttttttttttttt', 'tttttttttttttttt', 'tttttaaaaaattttt', 'ttttawwwaaaatttt', - 'tttawwbbbaaaattt', 'ttawwbbbbbaaaatt', 'tawwbbbbbbbaaaat', 'abbbbbbbbbbbccca', - 'abbbbbbbbbbbccca', 'tabbbbbbbbbcccat', 'ttabbbbbbbcccatt', 'tttabbbbbcccattt', - 'ttttabbbcccatttt', 'tttttabcccattttt', 'ttttttaccatttttt', 'tttttttaattttttt', - ] }, - tnt: { p: { r: '#c0392b', w: '#f2f2f2', k: '#2b2b2b', d: '#8e2a20' }, r: [ - 'rrrrrrrrrrrrrrrr', 'rdrrrdrrrrdrrrdr', 'wwwwwwwwwwwwwwww', 'wkkwkkwkkwkkwkkw', - 'wkwkwkwkwkwkwkwk', 'wwwwwwwwwwwwwwww', 'rrrrrrrrrrrrrrrr', 'rdrrrrrdrrrrrdrr', - 'rrrrdrrrrrdrrrrr', 'rrdrrrrrdrrrrrdr', 'rrrrrdrrrrrdrrrr', 'rdrrrrrrdrrrrrrr', - 'rrrrdrrrrrrrdrrr', 'rrdrrrrdrrrrrrdr', 'rrrrrrrrrrrrrrrr', 'rrrrrrrrrrrrrrrr', - ] }, - chest: { p: { a: '#a97142', b: '#8a5a33', c: '#6b4423', k: '#3a2716', y: '#e0c060' }, r: [ - 'cccccccccccccccc', 'cabababababababc', 'cbababababababac', 'cabababababababc', - 'ckkkkkkkkkkkkkkc', 'cabababababababc', 'cababyyyyyybabac', 'cababykkkkybabac', - 'cababyyyyyybabac', 'cabababababababc', 'cabababababababc', 'cabababababababc', - 'cabababababababc', 'cabababababababc', 'cabababababababc', 'cccccccccccccccc', - ] }, - sword: { p: { s: '#d8dde6', l: '#f4f7fb', d: '#9aa3b0', h: '#8a5a33', g: '#e0c060', t: null }, r: [ - 'tttttttttttttsst', 'ttttttttttttslls', 'tttttttttttslldt', 'ttttttttttslldtt', - 'tttttttttslldttt', 'ttttttttslldtttt', 'tttttttslldttttt', 'ttttttslldtttttt', - 'tttttslldttttttt', 'ttttslldtttttttt', 'tttgllgdtttttttt', 'tthhghhttttttttt', - 'ttthhhtttttttttt', 'tttthhtttttttttt', 'ttttthtttttttttt', 'tttttttttttttttt', - ] }, - pickaxe: { p: { s: '#c9ced6', l: '#eef2f7', d: '#8e959f', h: '#8a5a33', b: '#6b4423', t: null }, r: [ - 'tttssssttssssttt', 'ttslllsddslllstt', 'tsllddsttsddllst', 'tsldttttttttdlst', - 'ttdttttsstttttdt', 'tttttthhtttttttt', 'ttttttbhtttttttt', 'ttttttthbttttttt', - 'tttttttbhttttttt', 'ttttttttbhtttttt', 'ttttttttthbttttt', 'ttttttttttbhtttt', - 'tttttttttttbhttt', 'ttttttttttttbhtt', 'tttttttttttttbht', 'tttttttttttttttt', - ] }, - potion: { p: { g: '#cfd8e3', l: '#eef3fa', v: '#c0392b', d: '#8e2a20', k: '#5a4632', t: null }, r: [ - 'ttttttkkkktttttt', 'tttttkllkktttttt', 'tttttkllkttttttt', 'tttttkllkttttttt', - 'ttttkglllgkttttt', 'tttkgllllllgkttt', 'ttkglvvvvvvlgktt', 'tkglvvvvvvvvlgkt', - 'tkglvvvvvvvvlgkt', 'tkglvvvvvvvvlgkt', 'tkgdvvvvvvvvdgkt', 'tkgdvvvvvvvvdgkt', - 'ttkgdvvvvvvdgktt', 'tttkgdvvvvdgkttt', 'ttttkkgggggkkttt', 'tttttkkkkkkkkttt', - ] }, - nether: { p: { a: '#e07b39', b: '#c9542a', c: '#8f2f18', y: '#f7c948', t: null }, r: [ - 'tttttttyyttttttt', 'ttttttyaaytttttt', 'tttttyaaaayttttt', 'ttttyaaaaaaytttt', - 'tttyaabbaaaayttt', 'ttyaabbbbaaayttt', 'tyaabbbbbbaaaytt', 'yaabbbcccbbbaayt', - 'yabbbcccccbbbayt', 'yabbcccccccbbayt', 'yabbcccccccbbayt', 'tyabcccccccbaytt', - 'ttyabcccccbayttt', 'tttyabcccbaytttt', 'ttttyaabbayttttt', 'tttttyyyyytttttt', - ] }, - end: { p: { a: '#2f6f66', b: '#3f8f83', c: '#1f4a45', w: '#bff5ea', t: null }, r: [ - 'tttttbbbbbbttttt', 'tttbbaaaaaabbttt', 'ttbaaaawwaaaabtt', 'tbaaawwwwwwaaabt', - 'baaawwbbbbwwaaab', 'baawwbbaabbwwaab', 'baawbbaaaabbwaab', 'bawbbaaaaaabbwab', - 'bawbbaaaaaabbwab', 'baawbbaaaabbwaab', 'baawwbbaabbwwaab', 'baaawwbbbbwwaaab', - 'tbaaawwwwwwaaabt', 'ttbaaaawwaaaabtt', 'tttbbaaaaaabbttt', 'tttttbbbbbbttttt', - ] }, - redstone: { p: { a: '#c0392b', b: '#8e2a20', c: '#5c1a13', g: '#3a3a3a', t: null }, r: [ - 'tttttttggttttttt', 'ttttttgaagtttttt', 'tttttgaaaagttttt', 'ttttgaabbaagtttt', - 'tttgaabbbbaagttt', 'ttgaabbbbbbaagtt', 'tgaabbbccbbbaagt', 'gaabbbcccccbbaag', - 'gaabbbcccccbbaag', 'tgaabbbccbbbaagt', 'ttgaabbbbbbaagtt', 'tttgaabbbbaagttt', - 'ttttgaabbaagtttt', 'tttttgaaaagttttt', 'ttttttgaagtttttt', 'tttttttggttttttt', - ] }, - gold: { p: { a: '#f7d774', b: '#e0b53c', c: '#b8891f', t: null }, r: [ - 'tttttttttttttttt', 'tttttttttttttttt', 'tttaaaaaaaaaattt', 'ttaaaaaaaaaaaatt', - 'taaabbbbbbbbaaat', 'taabbbbbbbbbbaat', 'taabbbbbbbbbbaat', 'tabbbbbbbbbbbbat', - 'tabbbbbbbbbbbbat', 'tabbbcccccbbbbat', 'taccccccccccccat', 'taccccccccccccat', - 'ttcccccccccccctt', 'tttccccccccccttt', 'tttttttttttttttt', 'tttttttttttttttt', - ] }, - book: { p: { a: '#8a5a33', b: '#6b4423', w: '#f0e6d2', y: '#e0c060', t: null }, r: [ - 'tbbbbbbbbbbbbbbt', 'bawwwwwwwwwwwwab', 'bawwwwwwwwwwwwab', 'bawwbbbwwbbbwwab', - 'bawwwwwwwwwwwwab', 'bawwbbbbwbbbbwab', 'bawwwwwwwwwwwwab', 'bawwbbbwwbbbwwab', - 'bawwwwwwwwwwwwab', 'bawwbbbbwbbbbwab', 'bawwwwwwwwwwwwab', 'bawwwwyywwwwwwab', - 'bawwwwyywwwwwwab', 'bawwwwwwwwwwwwab', 'bawwwwwwwwwwwwab', 'tbbbbbbbbbbbbbbt', - ] }, - map: { p: { a: '#e8dcc0', b: '#cdbb95', c: '#8a5a33', g: '#7cbd56', u: '#4a90d9' }, r: [ - 'cccccccccccccccc', 'caaaaaaaaaaaaaac', 'cabbaaaaaaaaaabc', 'caaauuuaaggaaaac', - 'caaauuuaagggaaac', 'caaaauaaaggaaaac', 'caaaaaaaaaaaaaac', 'caagggaaaaauuaac', - 'caaggggaaaauuaac', 'caagggaaaaauuaac', 'caaaaaaaaaaaaaac', 'caaaaaabbaaaaaac', - 'caaaaabbbbaaaaac', 'caaaaaabbaaaaaac', 'caaaaaaaaaaaaaac', 'cccccccccccccccc', - ] }, - star: { p: { a: '#ffffff', b: '#dfe6f2', c: '#9fb0cc', t: null }, r: [ - 'tttttttaattttttt', 'tttttttaattttttt', 'ttttttbaabtttttt', 'ttttttbaabtttttt', - 'tttttbbaabbttttt', 'ttattbbaabbttatt', 'tabaabbaabbaabat', 'taaaaaaaaaaaaaat', - 'taaaaaaaaaaaaaat', 'tabaabbaabbaabat', 'ttcttbbaabbttctt', 'tttttbbaabbttttt', - 'ttttttbaabtttttt', 'ttttttbaabtttttt', 'tttttttaattttttt', 'tttttttaattttttt', - ] }, -}; - -// baut aus dem Raster ein SVG -function pixelIconRects(key, scale = 1, offsetX = 0, offsetY = 0) { - const def = PIX[key] || PIX.grass; - let rects = ''; - def.r.forEach((row, y) => { - for (let x = 0; x < 16 && x < row.length; x++) { - const col = def.p[row[x]]; - if (!col || col === '#00000000') continue; - rects += ``; - } - }); - return rects; -} - -function pixelIconSvg(key, size) { - const rects = pixelIconRects(key); - return `${rects}`; -} - -const HD_ICON_THEMES = { - grass: { glyph: 'loop', bg: '#46b96c', shade: '#1f5b33', glow: '#95f08a' }, - dirt: { glyph: 'cube', bg: '#a96d43', shade: '#58311a', glow: '#d9a56e' }, - stone: { glyph: 'cube', bg: '#98a3b3', shade: '#495364', glow: '#dfe5ef' }, - planks: { glyph: 'cube', bg: '#cf9b61', shade: '#704521', glow: '#f2cb97' }, - tnt: { glyph: 'crate', bg: '#db4d45', shade: '#6d1d18', glow: '#fff2d2' }, - obsidian: { glyph: 'cube', bg: '#6f5cd7', shade: '#25184d', glow: '#b8abff' }, - netherrack: { glyph: 'cube', bg: '#c14f4f', shade: '#5d1717', glow: '#ff9a7b' }, - crafting: { glyph: 'grid', bg: '#bf8750', shade: '#613a20', glow: '#f5d5a4' }, - furnace: { glyph: 'forge', bg: '#7e8997', shade: '#38414c', glow: '#ffbd67' }, - bookshelf: { glyph: 'books', bg: '#b77a4d', shade: '#5b331f', glow: '#ffd966' }, - diamondblock: { glyph: 'cube', bg: '#42d0d7', shade: '#14606d', glow: '#dfffff' }, - goldblock: { glyph: 'cube', bg: '#f0bf3c', shade: '#8b5a09', glow: '#fff1a1' }, - emeraldblock: { glyph: 'cube', bg: '#2ec97b', shade: '#0b5a35', glow: '#b4ffd7' }, - redstoneblock: { glyph: 'cube', bg: '#e14d51', shade: '#75191d', glow: '#ffb1b4' }, - diamond: { glyph: 'gem', bg: '#47dce0', shade: '#126b79', glow: '#defcff' }, - emerald: { glyph: 'gem', bg: '#2fd485', shade: '#0d683c', glow: '#d8ffe8' }, - gold: { glyph: 'ingot', bg: '#f2c44e', shade: '#8d6208', glow: '#fff3b4' }, - iron: { glyph: 'ingot', bg: '#b9c3d1', shade: '#57606d', glow: '#f5f8fc' }, - redstone: { glyph: 'spark', bg: '#ea5956', shade: '#7e1717', glow: '#ffc8c6' }, - coal: { glyph: 'coal', bg: '#59606b', shade: '#1f242b', glow: '#cfd5df' }, - sword: { glyph: 'sword', bg: '#5d9ff4', shade: '#1f477c', glow: '#eef6ff' }, - pickaxe: { glyph: 'pickaxe', bg: '#57a8de', shade: '#1f5276', glow: '#ebf8ff' }, - axe: { glyph: 'axe', bg: '#6997df', shade: '#27456c', glow: '#eef4ff' }, - bow: { glyph: 'bow', bg: '#b97a58', shade: '#5c3522', glow: '#ffe1cb' }, - apple: { glyph: 'apple', bg: '#ea5353', shade: '#7e1717', glow: '#ffd3d3' }, - goldenapple: { glyph: 'apple', bg: '#f0c44f', shade: '#8c6208', glow: '#fff1b2' }, - book: { glyph: 'book', bg: '#7a5ad3', shade: '#33205f', glow: '#ece4ff' }, - enchantedbook: { glyph: 'bookSpark', bg: '#6f63e7', shade: '#272266', glow: '#d9fcff' }, - map: { glyph: 'map', bg: '#5aa67d', shade: '#1d563f', glow: '#f0f1c9' }, - star: { glyph: 'star', bg: '#5f7ce0', shade: '#22357c', glow: '#ffffff' }, - endereye: { glyph: 'eye', bg: '#3da984', shade: '#124a40', glow: '#ccfff0' }, - enderpearl: { glyph: 'pearl', bg: '#4cc0c7', shade: '#155b62', glow: '#e6ffff' }, - blaze: { glyph: 'flame', bg: '#ef8b38', shade: '#7c3a06', glow: '#ffe3a3' }, - xp: { glyph: 'bottle', bg: '#3ea977', shade: '#15503a', glow: '#dcffe0' }, - cake: { glyph: 'cake', bg: '#f08cab', shade: '#8a3453', glow: '#fff6dc' }, - bucket: { glyph: 'bucket', bg: '#5b94dc', shade: '#1f4a79', glow: '#d8f1ff' }, - lava: { glyph: 'bucket', bg: '#ec7134', shade: '#862d08', glow: '#ffd4ab' }, - tnt_item: { glyph: 'powder', bg: '#9ba2af', shade: '#404753', glow: '#fff4ce' }, - creeper: { glyph: 'creeper', bg: '#67c35b', shade: '#234f1c', glow: '#d7ffd0' }, - chest: { glyph: 'chest', bg: '#c4884f', shade: '#653a1d', glow: '#f5d28f' }, - potion: { glyph: 'potion', bg: '#8d63d9', shade: '#3b2466', glow: '#ffd7f5' }, - nether: { glyph: 'flame', bg: '#d65a3b', shade: '#6b2111', glow: '#ffd1a0' }, - end: { glyph: 'eye', bg: '#3f8d7d', shade: '#153f39', glow: '#d6fff4' }, -}; - -const LEGACY_REAL_ICON_MAP = { - chest: 'chest', - creeper: 'creeper', - potion: 'potion', - nether: 'blaze', - end: 'endereye', - tnt_item: 'tnt_item', -}; - -function hdIconTheme(key) { - return HD_ICON_THEMES[key] || { glyph: 'letter', bg: '#5b6474', shade: '#252b34', glow: '#e8edf7' }; -} - -function hdIconGlyph(key, theme) { - switch (theme.glyph) { - case 'cube': - return ` - - - - `; - case 'loop': - return ` - - `; - case 'crate': - return ` - - - `; - case 'grid': - return ` - - - `; - case 'forge': - return ` - - - `; - case 'books': - return ` - - - - `; - case 'gem': - return ` - - - `; - case 'ingot': - return ` - - - `; - case 'spark': - return ` - - `; - case 'coal': - return ` - - - `; - case 'sword': - return ` - - - `; - case 'pickaxe': - return ` - - - `; - case 'axe': - return ` - - - `; - case 'bow': - return ` - - - `; - case 'apple': - return ` - - - `; - case 'book': - return ` - - - `; - case 'bookSpark': - return ` - - - - `; - case 'map': - return ` - - - `; - case 'star': - return ` - - `; - case 'eye': - return ` - - - `; - case 'pearl': - return ` - - - `; - case 'flame': - return ` - - `; - case 'bottle': - return ` - - - `; - case 'cake': - return ` - - - `; - case 'bucket': - return ` - - - `; - case 'powder': - return ` - - - - `; - case 'creeper': - return ` - - - - - `; - case 'chest': - return ` - - - `; - case 'potion': - return ` - - - `; - case 'letter': - return `${escapeHtml((key || '?').slice(0, 1).toUpperCase())}`; - default: - return pixelIconSvg(key, 44); - } -} - -function hdIconSvg(key, size = 44) { - const theme = hdIconTheme(key); - const gid = `g-${String(key || 'icon').replace(/[^a-z0-9_-]/gi, '-')}-${size}`; - return ` - `; -} - -const ICON_KEYS = Object.keys(PIX); -const ICON_LABELS = { - grass: 'Grasblock', creeper: 'Creeper', diamond: 'Diamant', tnt: 'TNT', chest: 'Truhe', - sword: 'Schwert', pickaxe: 'Spitzhacke', potion: 'Trank', nether: 'Feuer', end: 'Endauge', - redstone: 'Redstone', gold: 'Goldbarren', book: 'Buch', map: 'Karte', star: 'Netherstern', -}; -// echte Minecraft-Icons (aus den Spieldateien extrahiert), key -> dataUri -let mcIcons = {}; -async function loadMcIcons() { - try { - let list = await window.api.iconsList(); - const needRefresh = !list.length || !['potion', 'chest', 'creeper', 'paper', 'slimeball', 'cookie'].every((key) => list.some((i) => i.key === key)); - if (needRefresh) { - const res = await window.api.iconsExtract(); - if (res && res.ok && Array.isArray(res.icons) && res.icons.length) list = res.icons; - } - mcIcons = Object.fromEntries(list.map((i) => [i.key, i])); - await buildIsoCubes(); // räumliche Block-Symbole vorbereiten - } catch { mcIcons = {}; } -} - -function resolveRealIconKey(key) { - if (mcIcons[key]) return key; - const mapped = LEGACY_REAL_ICON_MAP[key]; - if (mapped && mcIcons[mapped]) return mapped; - return ''; -} - -function realItemIconSvg(key, size = 44) { - const realKey = resolveRealIconKey(key); - if (!realKey) return ''; - const real = mcIcons[realKey]; - if (!real) return ''; - const theme = hdIconTheme(realKey); - const src = iso3dEnabled && BLOCK_KEYS.has(realKey) && isoCache[realKey] ? isoCache[realKey] : real.dataUri; - const plate = BLOCK_KEYS.has(realKey) - ? `` - : ``; - return ` - `; -} - -// erzeugt die Würfel-Darstellungen einmalig im Voraus -async function buildIsoCubes() { - const jobs = Object.values(mcIcons) - .filter((i) => BLOCK_KEYS.has(i.key)) - .map((i) => new Promise((resolve) => { - const img = new Image(); - img.onload = () => { isoSources[i.key] = img; resolve(); }; - img.onerror = () => resolve(); - img.src = i.dataUri; - })); - await Promise.all(jobs); - for (const key of Object.keys(isoSources)) renderIsoCube(key); -} - -// Block-Texturen, die als räumlicher Würfel dargestellt werden -const BLOCK_KEYS = new Set(['grass', 'dirt', 'stone', 'planks', 'tnt', 'obsidian', 'netherrack', - 'crafting', 'furnace', 'bookshelf', 'diamondblock', 'goldblock', 'emeraldblock', 'redstoneblock']); -let iso3dEnabled = true; -const isoCache = {}; // key -> fertiges Würfelbild -const isoSources = {}; // key -> geladenes Textur-Bild - -// zeichnet aus einer 16x16-Textur einen isometrischen Würfel (Canvas -> data:-URI) -function renderIsoCube(key) { - const img = isoSources[key]; - if (!img) return null; - - const S = 96, c = document.createElement('canvas'); - c.width = S; c.height = S; - const g = c.getContext('2d'); - g.imageSmoothingEnabled = true; - if ('imageSmoothingQuality' in g) g.imageSmoothingQuality = 'high'; - const w = 26, h = 15, cx = S / 2, top = 8; - - const face = (m, bright) => { - g.save(); - g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]); - g.drawImage(img, 0, 0, 16, 16); - g.restore(); - if (bright !== 1) { // Fläche abdunkeln/aufhellen - g.save(); - g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]); - g.globalCompositeOperation = bright < 1 ? 'source-atop' : 'lighter'; - g.fillStyle = bright < 1 ? `rgba(0,0,0,${(1 - bright).toFixed(2)})` : 'rgba(255,255,255,.12)'; - g.fillRect(0, 0, 16, 16); - g.restore(); - } - }; - // Oberseite (Raute), linke und rechte Seitenfläche - face([w / 16, h / 16, -w / 16, h / 16, cx, top], 1); - face([w / 16, h / 16, 0, 30 / 16, cx - w, top + h], 0.72); - face([w / 16, -h / 16, 0, 30 / 16, cx, top + h * 2], 0.55); - - const out = c.toDataURL('image/png'); - isoCache[key] = out; - return out; -} - -// liefert das Icon-Markup als skalierbares HD-SVG; eigene Bilder der Instanz bleiben separat erhalten -function iconOf(key, size = 44) { - const real = realItemIconSvg(key, size); - if (real) return real; - if (PIX[key]) return pixelIconSvg(key, size); - return hdIconSvg(key, size); -} - -const LOADER_LABEL = { - vanilla: 'Vanilla', fabric: 'Fabric', forge: 'Forge', - neoforge: 'NeoForge', quilt: 'Quilt', optifine: 'OptiFine', -}; - -// Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren -// bei größeren Modpacks spürbar GC-bedingte Ruckler. Wirkt erst ab genug RAM -// sinnvoll, schadet aber auch bei wenig RAM nicht. -const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 ' - + '-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch ' - + '-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M ' - + '-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 ' - + '-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 ' - + '-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem ' - + '-XX:MaxTenuringThreshold=1'; - -// ---------- Zustand ---------- -let instances = []; -let currentDetailId = null; -let selectedInstanceIds = new Set(); -let editingId = null; -let confirmAction = null; -let mcVersionData = null; // gecachte Mojang-Versionsliste -let currentModsInstalled = new Set(); // projectIds im offenen Mod-Dialog -let pendingImagePath = null; // im Dialog gewähltes eigenes Bild (noch nicht gespeichert) -let pendingClearImage = false; // eigenes Bild soll entfernt werden -let loginVerificationUri = null; // aktueller Device-Code-Link -const collapsedGroups = new Set(); // eingeklappte Gruppen -let consoleBuffer = ''; // gesammelte Log-Ausgabe (roh, für Kopieren) -let consolePending = ''; // noch nicht abgeschlossene Zeile - -// ---------- Popup-Fenster ---------- -// In einem eigenen Popup-Fenster (aktuell nur Einstellungen) steht in der URL -// "?popout=settings" – dann blendet die Oberfläche die Haupt-UI aus und zeigt -// nur dieses eine Modal vollflächig (siehe styles.css ".is-popout"/".popout-host"). -const POPOUT = new URLSearchParams(location.search).get('popout'); - -// ---------- DOM-Helfer ---------- -const el = (id) => document.getElementById(id); -function show(id) { el(id).classList.remove('hidden'); } -function hide(id) { el(id).classList.add('hidden'); } - -function toast(msg, isError = false) { - const t = el('toast'); - t.textContent = msg; - t.classList.toggle('err', isError); - t.classList.remove('hidden'); - clearTimeout(toast._t); - toast._t = setTimeout(() => t.classList.add('hidden'), 2800); -} - -function fmtDate(iso) { - if (!iso) return '–'; - try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; } -} -function status(text) { el('status-left').textContent = text; } - -function fmtDuration(s) { - s = s || 0; - if (s < 60) return s + ' Sek.'; - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - return h ? `${h} Std. ${m} Min.` : `${m} Min.`; -} - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ( - { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] - )); -} -function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; } - -function applyTheme(theme) { - document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark'); -} - -// ---------- Instanzen aus MultiMC übernehmen ---------- -let mmcFound = []; -let cfFound = []; -let mcFound = []; -let technicFound = []; -let importKind = 'zip'; - -async function ensureDefaultMcScan() { - if (importKind !== 'mc') return; - if (el('mc-path').value.trim()) return; - if (mcFound.length) return; - await scanMc(); -} - -function setImportKind(kind) { - importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip'; - ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => { - el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind); - el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind); - }); - el('import-zip-do').classList.toggle('hidden', importKind !== 'zip'); - el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack'); - el('mc-do').classList.toggle('hidden', importKind !== 'mc'); - el('cf-do').classList.toggle('hidden', importKind !== 'cf'); - el('mmc-do').classList.toggle('hidden', importKind !== 'mmc'); - el('technic-do').classList.toggle('hidden', importKind !== 'technic'); - el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent); - el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent); - el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent); - el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent); -} - -function openImportDialog(kind = 'zip') { - if (POPOUT !== 'import') { window.api.popoutOpen('import', { kind }); return; } - mcFound = []; - el('mc-result').classList.add('hidden'); - el('mc-do').disabled = true; - el('mc-progress').textContent = ''; - el('mc-progress').classList.add('hidden'); - el('mc-status').textContent = 'Leer lassen nutzt automatisch den Standardpfad unter Windows.'; - el('cf-result').classList.add('hidden'); - el('cf-do').disabled = true; - el('cf-progress').textContent = ''; - el('cf-progress').classList.add('hidden'); - el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances'; - el('mmc-result').classList.add('hidden'); - el('mmc-do').disabled = true; - el('mmc-progress').textContent = ''; - el('mmc-progress').classList.add('hidden'); - el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.'; - setImportKind(kind); - show('modal-import'); - if (importKind === 'mc') { - el('mc-path').focus(); - ensureDefaultMcScan(); - } - if (importKind === 'cf') el('cf-path').focus(); - if (importKind === 'mmc') el('mmc-path').focus(); -} - -async function runZipImport() { - el('import-zip-do').disabled = true; - try { - const res = await window.api.importInstance(); - if (res.canceled) return; - if (res.ok) { - await reload(); - hide('modal-import'); - toast('Importiert: ' + res.instance.name); - return; - } - toast(res.message || 'Import fehlgeschlagen.', true); - } finally { - el('import-zip-do').disabled = false; - } -} - -async function runModpackImport() { - el('import-modpack-do').disabled = true; - status('Importiere Modpack …'); - try { - const res = await window.api.importModpack(); - status('Bereit'); el('status-right').textContent = ''; - if (res.canceled) return; - if (res.ok) { - await reload(); - hide('modal-import'); - toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`); - // CurseForge gibt nicht jede Datei heraus – ehrlich benennen statt verschweigen - if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true); - return; - } - toast(res.message || 'Modpack-Import fehlgeschlagen.', true); - if (res.needsKey) { hide('modal-import'); openSettings('pane-network'); } - } finally { - status('Bereit'); el('status-right').textContent = ''; - el('import-modpack-do').disabled = false; - } -} - -function fmtDownloads(n) { - n = Number(n) || 0; - if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k'; - return String(n); -} - -async function searchModpacks() { - const query = el('mp-query').value.trim(); - const gameVersion = el('mp-mc').value.trim(); - const sort = el('mp-sort').value || 'relevance'; - const source = (el('mp-source') && el('mp-source').value) || 'modrinth'; - const box = el('mp-results'); - const st = el('mp-status'); - st.textContent = 'Suche …'; - box.innerHTML = '
Suche läuft …
'; - try { - const res = await window.api.modpackSearch({ - query, gameVersion, sort, limit: 30, offset: 0, art: 'modpack', source, - }); - if (res.error) { - st.textContent = 'Fehler: ' + res.error; - box.innerHTML = '
Suche fehlgeschlagen.
'; - if (res.needKey) { - const wrap = document.createElement('div'); - wrap.className = 'mods-empty'; - const btn = document.createElement('button'); - btn.className = 'btn btn-mini'; - btn.textContent = 'Zu den Netzwerk-Einstellungen'; - btn.addEventListener('click', () => { hide('modal-import'); openSettings('pane-network'); }); - wrap.appendChild(btn); - box.appendChild(wrap); - } - return; - } - const hits = res.hits || []; - st.textContent = hits.length - ? (res.total + ' Treffer' + (gameVersion ? ' für ' + gameVersion : '')) - : 'Keine Treffer.'; - box.innerHTML = ''; - if (!hits.length) { - box.innerHTML = '
Keine Modpacks gefunden.
'; - return; - } - for (const h of hits) { - const item = document.createElement('div'); - item.className = 'mod-item'; - const meta = [ - h.author ? ('von ' + h.author) : '', - h.downloads != null ? (fmtDownloads(h.downloads) + ' Downloads') : '', - ].filter(Boolean).join(' · '); - const cats = (h.displayCategories || h.categories || []).slice(0, 4).join(', '); - item.innerHTML = - `
${h.iconUrl ? `` : '📦'}
-
-
${escapeHtml(h.title || h.slug || h.projectId)}
-
${escapeHtml(meta)}${cats ? ' · ' + escapeHtml(cats) : ''}
-
${escapeHtml((h.description || '').slice(0, 120))}${(h.description || '').length > 120 ? '…' : ''}
-
-
`; - const btn = document.createElement('button'); - btn.className = 'btn btn-primary btn-mini'; - btn.textContent = 'Installieren'; - btn.addEventListener('click', () => installModpackFromSearch(h, btn)); - item.querySelector('.mod-action').appendChild(btn); - box.appendChild(item); - } - } catch (err) { - st.textContent = 'Fehler: ' + (err.message || err); - box.innerHTML = '
Suche fehlgeschlagen.
'; - } -} - -async function installModpackFromSearch(hit, btn) { - if (!hit || !hit.projectId) return; - const gameVersion = el('mp-mc').value.trim(); - const prev = btn ? btn.textContent : ''; - if (btn) { btn.disabled = true; btn.textContent = '…'; } - status('Installiere Modpack: ' + (hit.title || hit.slug || '') + ' …'); - try { - const res = hit.source === 'curseforge' - ? await window.api.modpackInstallFromCurseForge({ - cfId: hit.cfId, projectId: hit.projectId, title: hit.title || hit.slug, gameVersion, - }) - : await window.api.modpackInstallFromModrinth({ - projectId: hit.projectId, - title: hit.title || hit.slug, - gameVersion, - }); - status('Bereit'); el('status-right').textContent = ''; - if (res.ok) { - await reload(); - hide('modal-import'); - toast(`Modpack installiert: ${res.instance.name} (${res.fileCount || 0} Dateien)`); - if (currentDetailId !== res.instance.id) { - try { await selectInstance(res.instance.id); } catch { /* egal */ } - } - return; - } - toast(res.message || 'Installation fehlgeschlagen.', true); - } catch (err) { - status('Bereit'); el('status-right').textContent = ''; - toast('Installation fehlgeschlagen: ' + (err.message || err), true); - } finally { - if (btn) { btn.disabled = false; btn.textContent = prev || 'Installieren'; } - } -} - -async function scanMc() { - const folder = el('mc-path').value.trim(); - el('mc-status').textContent = 'Suche Launcher-Profile …'; - const res = await window.api.mcScan(folder); - if (!res.ok) { - el('mc-status').textContent = '✖ Dort wurde kein launcher_profiles.json gefunden.'; - el('mc-result').classList.add('hidden'); - el('mc-do').disabled = true; - return; - } - mcFound = res.instances; - if (!folder && res.rootDir) el('mc-path').value = res.rootDir; - el('mc-status').textContent = `${res.count} Profil${res.count === 1 ? '' : 'e'} gefunden.`; - el('mc-result').classList.remove('hidden'); - el('mc-all').checked = true; - renderMcList(); -} - -function renderMcList() { - const box = el('mc-list'); - box.innerHTML = ''; - if (!mcFound.length) { box.innerHTML = '
Keine Profile gefunden.
'; return; } - mcFound.forEach((inst, idx) => { - const row = document.createElement('label'); - row.className = 'mmc-item'; - const loaderTxt = inst.loader === 'vanilla' - ? 'Vanilla' - : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); - const meta = [inst.version || 'ohne Version', loaderTxt, inst.usesDefaultGameDir ? 'Standard-Spielordner' : 'eigener Spielordner'] - .filter(Boolean).join(' · '); - row.innerHTML = - ` -
-
${escapeHtml(inst.name)}
-
${escapeHtml(meta)}
-
`; - box.appendChild(row); - }); - updateMcButton(); -} - -function selectedMc() { - return [...document.querySelectorAll('#mc-list input[type=checkbox]')] - .filter((c) => c.checked) - .map((c) => mcFound[Number(c.dataset.idx)]); -} - -function updateMcButton() { - const n = selectedMc().length; - el('mc-do').disabled = n === 0; - el('mc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; -} - -async function runMcImport() { - const entries = selectedMc(); - if (!entries.length) return; - const copy = el('mc-copy').checked; - el('mc-do').disabled = true; - el('mc-progress').textContent = 'Übernehme …'; - el('mc-progress').classList.remove('hidden'); - - const res = await window.api.mcImport(entries, copy); - await reload(); - hide('modal-import'); - const n = res.imported.length; - const f = res.failed.length; - toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); - if (instances.length) await selectInstance(instances[0].id); -} - -async function scanCf() { - const folder = el('cf-path').value.trim(); - if (!folder) { el('cf-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; } - el('cf-status').textContent = 'Suche Instanzen …'; - const res = await window.api.cfScan(folder); - if (!res.ok) { - el('cf-status').textContent = '✖ Dort wurde kein CurseForge-Instances-Ordner gefunden.'; - el('cf-result').classList.add('hidden'); - el('cf-do').disabled = true; - return; - } - cfFound = res.instances; - el('cf-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`; - el('cf-result').classList.remove('hidden'); - el('cf-all').checked = true; - renderCfList(); -} - -function renderCfList() { - const box = el('cf-list'); - box.innerHTML = ''; - if (!cfFound.length) { box.innerHTML = '
Keine Instanzen gefunden.
'; return; } - cfFound.forEach((inst, idx) => { - const row = document.createElement('label'); - row.className = 'mmc-item'; - const loaderTxt = inst.loader === 'vanilla' - ? 'Vanilla' - : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); - const meta = [inst.version || 'ohne Version', loaderTxt] - .filter(Boolean).join(' · '); - row.innerHTML = - ` -
-
${escapeHtml(inst.name)}
-
${escapeHtml(meta)}
-
`; - box.appendChild(row); - }); - updateCfButton(); -} - -function selectedCf() { - return [...document.querySelectorAll('#cf-list input[type=checkbox]')] - .filter((c) => c.checked) - .map((c) => cfFound[Number(c.dataset.idx)]); -} - -function updateCfButton() { - const n = selectedCf().length; - el('cf-do').disabled = n === 0; - el('cf-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; -} - -async function runCfImport() { - const entries = selectedCf(); - if (!entries.length) return; - const copy = el('cf-copy').checked; - el('cf-do').disabled = true; - el('cf-progress').textContent = 'Übernehme …'; - el('cf-progress').classList.remove('hidden'); - - const res = await window.api.cfImport(entries, copy); - await reload(); - hide('modal-import'); - const n = res.imported.length; - const f = res.failed.length; - toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); - if (instances.length) await selectInstance(instances[0].id); -} - -async function scanMmc() { - const folder = el('mmc-path').value.trim(); - if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; } - el('mmc-status').textContent = 'Suche Instanzen …'; - const res = await window.api.mmcScan(folder); - if (!res.ok) { - el('mmc-status').textContent = '✖ Dort wurde kein instances-Ordner gefunden.'; - el('mmc-result').classList.add('hidden'); - el('mmc-do').disabled = true; - return; - } - mmcFound = res.instances; - el('mmc-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`; - el('mmc-result').classList.remove('hidden'); - el('mmc-all').checked = true; - renderMmcList(); -} - -function renderMmcList() { - const box = el('mmc-list'); - box.innerHTML = ''; - if (!mmcFound.length) { box.innerHTML = '
Keine Instanzen gefunden.
'; return; } - mmcFound.forEach((inst, idx) => { - const row = document.createElement('label'); - row.className = 'mmc-item'; - const loaderTxt = inst.loader === 'vanilla' - ? 'Vanilla' - : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); - const meta = [inst.version || 'ohne Version', loaderTxt, inst.group ? 'Gruppe: ' + inst.group : ''] - .filter(Boolean).join(' · '); - row.innerHTML = - ` -
-
${escapeHtml(inst.name)}
-
${escapeHtml(meta)}
-
`; - box.appendChild(row); - }); - updateMmcButton(); -} - -function selectedMmc() { - return [...document.querySelectorAll('#mmc-list input[type=checkbox]')] - .filter((c) => c.checked) - .map((c) => mmcFound[Number(c.dataset.idx)]); -} - -function updateMmcButton() { - const n = selectedMmc().length; - el('mmc-do').disabled = n === 0; - el('mmc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; -} - -async function runMmcImport() { - const entries = selectedMmc(); - if (!entries.length) return; - const copy = el('mmc-copy').checked; - el('mmc-do').disabled = true; - el('mmc-progress').textContent = 'Übernehme …'; - el('mmc-progress').classList.remove('hidden'); - - const res = await window.api.mmcImport(entries, copy); - await reload(); - hide('modal-import'); - const n = res.imported.length; - const f = res.failed.length; - toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); - if (instances.length) await selectInstance(instances[0].id); -} - -// ---------- Technic-Plattform: Modpack-Katalog ---------- - -async function searchTechnicPlatform() { - const query = el('tp-query').value.trim(); - const box = el('tp-results'); - const st = el('tp-status'); - st.textContent = 'Suche …'; - box.innerHTML = '
Suche läuft …
'; - const res = await window.api.technicPlatformSearch(query); - if (!res.ok) { - st.textContent = 'Fehler: ' + (res.message || 'Suche fehlgeschlagen.'); - box.innerHTML = '
Suche fehlgeschlagen.
'; - return; - } - const hits = res.hits || []; - st.textContent = hits.length ? `${hits.length} Treffer` : 'Keine Treffer.'; - box.innerHTML = ''; - if (!hits.length) { box.innerHTML = '
Keine Modpacks gefunden.
'; return; } - for (const h of hits) { - const item = document.createElement('div'); - item.className = 'mod-item'; - item.innerHTML = - `
${h.iconUrl ? `` : '📦'}
-
${escapeHtml(h.name)}
-
`; - const btn = document.createElement('button'); - btn.className = 'btn btn-primary btn-mini'; - btn.textContent = 'Installieren'; - btn.addEventListener('click', () => installTechnicFromSearch(h, btn)); - item.querySelector('.mod-action').appendChild(btn); - box.appendChild(item); - } -} - -async function installTechnicFromSearch(hit, btn) { - if (!hit || !hit.slug) return; - const prev = btn ? btn.textContent : ''; - if (btn) { btn.disabled = true; btn.textContent = '…'; } - const st = el('tp-status'); - st.textContent = `Installiere „${hit.name}" …`; - try { - const res = await window.api.technicPlatformInstall(hit.slug); - if (res.ok) { - await reload(); - hide('modal-import'); - toast(`Modpack installiert: ${res.instance.name}`); - await selectInstance(res.instance.id); - } else { - st.textContent = '✖ ' + (res.message || 'Installation fehlgeschlagen.'); - toast(res.message || 'Installation fehlgeschlagen.', true); - } - } finally { - if (btn) { btn.disabled = false; btn.textContent = prev; } - } -} - -async function scanTechnic() { - const folder = el('technic-path').value.trim(); - el('technic-status').textContent = 'Suche Modpacks …'; - const res = await window.api.technicScan(folder); - if (!res.ok) { - el('technic-status').textContent = '✖ Dort wurde kein modpacks-Ordner gefunden.'; - el('technic-result').classList.add('hidden'); - el('technic-do').disabled = true; - return; - } - technicFound = res.instances; - if (!folder && res.instancesDir) el('technic-path').value = res.instancesDir; - el('technic-status').textContent = `${res.count} Modpack${res.count === 1 ? '' : 's'} gefunden.`; - el('technic-result').classList.remove('hidden'); - el('technic-all').checked = true; - renderTechnicList(); -} - -function renderTechnicList() { - const box = el('technic-list'); - box.innerHTML = ''; - if (!technicFound.length) { box.innerHTML = '
Keine Modpacks gefunden.
'; return; } - technicFound.forEach((inst, idx) => { - const row = document.createElement('label'); - row.className = 'mmc-item'; - const loaderTxt = inst.loader === 'vanilla' - ? 'Vanilla' - : (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : ''); - const meta = [inst.version || 'Version unbekannt', loaderTxt] - .filter(Boolean).join(' · '); - row.innerHTML = - ` -
-
${escapeHtml(inst.name)}
-
${escapeHtml(meta)}
-
`; - box.appendChild(row); - }); - updateTechnicButton(); -} - -function selectedTechnic() { - return [...document.querySelectorAll('#technic-list input[type=checkbox]')] - .filter((c) => c.checked) - .map((c) => technicFound[Number(c.dataset.idx)]); -} - -function updateTechnicButton() { - const n = selectedTechnic().length; - el('technic-do').disabled = n === 0; - el('technic-do').textContent = n ? `${n} übernehmen` : 'Übernehmen'; -} - -async function runTechnicImport() { - const entries = selectedTechnic(); - if (!entries.length) return; - const copy = el('technic-copy').checked; - el('technic-do').disabled = true; - el('technic-progress').textContent = 'Übernehme …'; - el('technic-progress').classList.remove('hidden'); - - const res = await window.api.technicImport(entries, copy); - await reload(); - hide('modal-import'); - const n = res.imported.length; - const f = res.failed.length; - toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); - if (instances.length) await selectInstance(instances[0].id); -} - -// ---------- Bedrock (Windows-Store-Ausgabe) ---------- -let bedrockKind = 'worlds'; - -// Bedrock-Knopf nur zeigen, wenn aktiviert UND tatsächlich installiert -async function applyBedrockVisibility() { - const btn = el('btn-bedrock'); - let show = false; - try { - const s = await window.api.getSettings(); - if (s.bedrockEnabled !== false) { - const info = await window.api.bedrockDetect(); - show = !!info.installed; - const hint = el('s-bedrock-hint'); - if (hint) { - hint.textContent = info.installed - ? 'Bedrock erkannt (Version ' + info.version + ').' - : 'Bedrock ist auf diesem PC nicht installiert – der Bereich bleibt ausgeblendet.'; - } - } - } catch { /* im Zweifel ausblenden */ } - btn.classList.toggle('hidden', !show); -} - -async function openBedrock() { - if (POPOUT !== 'bedrock') { window.api.popoutOpen('bedrock'); return; } - show('modal-bedrock'); - el('bd-sub').textContent = 'Wird geprüft …'; - const info = await window.api.bedrockDetect(); - if (!info.installed) { - el('bd-sub').textContent = 'nicht installiert'; - el('bd-missing').classList.remove('hidden'); - el('bd-content').classList.add('hidden'); - return; - } - el('bd-missing').classList.add('hidden'); - el('bd-content').classList.remove('hidden'); - el('bd-sub').textContent = 'Version ' + info.version; - switchBedrockTab('worlds'); -} - -function switchBedrockTab(kind) { - bedrockKind = kind; - document.querySelectorAll('.btab').forEach((t) => t.classList.toggle('on', t.dataset.kind === kind)); - renderBedrockList(); -} - -function fmtBytes(n) { - if (!n) return ''; - if (n < 1024) return n + ' B'; - if (n < 1048576) return (n / 1024).toFixed(0) + ' KB'; - return (n / 1048576).toFixed(1) + ' MB'; -} - -async function renderBedrockList() { - const box = el('bd-list'); - box.innerHTML = '
Lade …
'; - const kind = bedrockKind; - const items = kind === 'worlds' ? await window.api.bedrockWorlds() : await window.api.bedrockList(kind); - - box.innerHTML = ''; - if (!items.length) { - const leer = { - worlds: 'Noch keine Welten vorhanden.', - resource_packs: 'Keine Ressourcenpakete installiert.', - behavior_packs: 'Keine Verhaltenspakete installiert.', - skin_packs: 'Keine Skin-Pakete installiert.', - screenshots: 'Noch keine Bildschirmfotos.', - }; - box.innerHTML = `
${leer[kind] || 'Nichts vorhanden.'}
`; - return; - } - - for (const it of items) { - const name = it.name || it.id; - const item = document.createElement('div'); - item.className = 'mod-item'; - const meta = [fmtBytes(it.bytes), it.modified ? fmtDate(it.modified) : ''].filter(Boolean).join(' · '); - item.innerHTML = - `
${kind === 'worlds' ? '🌍' : kind === 'screenshots' ? '🖼' : '📦'}
-
-
${escapeHtml(name)}
-
${escapeHtml(meta)}
-
-
`; - - const actions = item.querySelector('.mod-action'); - if (kind === 'worlds') { - const open = document.createElement('button'); - open.className = 'btn btn-mini'; - open.textContent = 'Ordner'; - open.addEventListener('click', () => window.api.bedrockOpenWorld(it.dir)); - actions.appendChild(open); - } - const del = document.createElement('button'); - del.className = 'btn btn-danger btn-mini'; - del.textContent = 'Löschen'; - del.addEventListener('click', () => askConfirm( - 'Löschen', `„${name}" wird unwiderruflich gelöscht. Fortfahren?`, 'Endgültig löschen', - async () => { - await window.api.bedrockRemove(kind, kind === 'worlds' ? it.id : it.name); - await renderBedrockList(); - toast('Gelöscht: ' + name); - })); - actions.appendChild(del); - box.appendChild(item); - } -} - -// ---------- Kontextmenü (Rechtsklick auf eine Instanz) ---------- -function openContextMenu(x, y) { - const menu = el('ctx-menu'); - menu.classList.remove('hidden'); - // erst anzeigen, dann Größe messen und am Fensterrand ausrichten - const r = menu.getBoundingClientRect(); - const px = Math.min(x, window.innerWidth - r.width - 6); - const py = Math.min(y, window.innerHeight - r.height - 6); - menu.style.left = Math.max(6, px) + 'px'; - menu.style.top = Math.max(6, py) + 'px'; -} - -function closeContextMenu() { - el('ctx-menu').classList.add('hidden'); -} - -function formatVersionLabel(version) { - return String(version || '') - .replace(/^v/i, '') - .replace(/-beta$/i, ' Beta') - .trim() || '–'; -} - -// ---------- Automatische Updates ---------- -let pendingUpdate = null; - -const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' }; - -function showUpdateDialog(info) { - if (POPOUT !== 'update') { window.api.popoutOpen('update'); return; } - if (!info) return; // Popup geöffnet, aber (aktuell) kein Update-Ergebnis vorhanden - pendingUpdate = info; - const channel = CHANNEL_LABEL[info.installKind] || null; - el('upd-current').textContent = formatVersionLabel(info.currentVersion); - el('upd-new').textContent = formatVersionLabel(info.latestVersion); - el('upd-channel').textContent = channel - ? `Update-Kanal: ${channel}` - : 'Update-Kanal: unbekannt – automatisches Update nicht möglich'; - el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.'; - el('upd-progress').classList.add('hidden'); - el('upd-bar-fill').style.width = '0%'; - - // Ohne passendes Paket wird bewusst nichts installiert – ein Portable-Launcher - // darf nie einen Installer bekommen und umgekehrt. Statt eines toten Knopfes - // führen wir dann zum Release, damit der Nutzer nicht in der Sackgasse steht. - const hatPaket = !!info.asset; - el('upd-install').classList.toggle('hidden', !hatPaket); - el('upd-install').disabled = !hatPaket; - el('upd-install').textContent = 'Installieren'; - el('upd-open').classList.toggle('hidden', hatPaket || !info.url); - el('upd-hint').textContent = hatPaket - ? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.' - : (channel - ? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.` - : 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.'); - show('modal-update'); -} - -function openReleasePage() { - if (pendingUpdate && pendingUpdate.url) window.api.openExternal(pendingUpdate.url); -} - -async function startUpdate() { - if (!pendingUpdate || !pendingUpdate.asset) return; - const btn = el('upd-install'); - btn.disabled = true; - btn.textContent = 'Wird installiert …'; - el('upd-progress').classList.remove('hidden'); - el('upd-status').textContent = 'Wird heruntergeladen …'; - - const res = await window.api.updateInstall(pendingUpdate.asset); - if (res.ok) { - el('upd-status').textContent = res.kind === 'portable' - ? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.' - : 'Installation wird gestartet – der Launcher schließt sich.'; - } else { - el('upd-status').textContent = '✖ ' + (res.message || 'Update fehlgeschlagen.'); - btn.disabled = false; - btn.textContent = 'Erneut versuchen'; - } -} - -function onUpdateProgress(p) { - if (p.phase === 'download') { - el('upd-bar-fill').style.width = p.percent + '%'; - const mb = (n) => (n / 1048576).toFixed(1); - el('upd-status').textContent = `Wird heruntergeladen … ${p.percent}% (${mb(p.loaded)} / ${mb(p.total)} MB)`; - } else if (p.phase === 'ready') { - el('upd-bar-fill').style.width = '100%'; - el('upd-status').textContent = 'Download fertig – Installation startet …'; - } -} - -// Aktualisiert die Statuszeile im Info-Bereich – genutzt sowohl vom manuellen -// "Nach Updates suchen"-Knopf als auch vom automatischen Check beim Start. -function renderUpdateStatus(info) { - const st = el('s-update-status'); - if (!st) return; - st.classList.remove('status-ok', 'status-bad'); - if (info.checking) { - st.textContent = 'Suche nach Updates …'; - } else if (info.error) { - st.classList.add('status-bad'); - st.textContent = '✖ ' + info.error; - } else if (info.noReleases) { - st.textContent = 'Noch keine Veröffentlichung vorhanden.'; - } else if (info.available) { - st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`; - } else { - st.classList.add('status-ok'); - st.textContent = `✓ Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`; - } -} - -// ---------- Zustand der Anmelde-Dienste in der Statusleiste ---------- -function showAuthHealth(h) { - const box = el('status-auth'); - if (!h || h.ok) { box.classList.add('hidden'); box.textContent = ''; return; } - const list = (h.down || []).join(', '); - box.textContent = (h.down && h.down.length === AUTH_SERVICE_COUNT) - ? 'Anmelde-Server nicht erreichbar – Login/Start derzeit nicht möglich' - : 'Anmelde-Dienst gestört: ' + list; - box.title = 'Zuletzt geprüft: ' + (h.checked ? new Date(h.checked).toLocaleTimeString('de-DE') : '–') + ' · Klicken zum erneut prüfen'; - box.classList.remove('hidden'); -} -const AUTH_SERVICE_COUNT = 3; - -// ---------- Skin-Hintergrund (wie MultiMCs Katze, nur mit eigenem Skin) ---------- -// uuidOverride: wird vom Konten-Wechsler (siehe unten) genutzt, um gezielt -// einen bestimmten Skin zu zeigen, ohne die eigentliche Einstellung anzufassen -async function applySkinBackground(uuidOverride) { - const s = await window.api.getSettings(); - const root = document.documentElement; - let uuid = ''; - if (uuidOverride) { - uuid = uuidOverride; - } else if (s.bgSkin === 'active') { - const acc = await window.api.authStatus(); - uuid = acc && acc.profile ? acc.profile.id : ''; - } else if (s.bgSkin) { - uuid = s.bgSkin; - } - if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; } - - root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100)); - - // Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht), - // damit die Auswahl immer sichtbar wirkt – auch wenn der Pose-Dienst offline ist. - // 1) Skin lokal in 3D rendern – unabhängig von Render-Diensten - const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking'); - if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; } - - // 2) Rückfall: externe Render-Dienste - for (const url of [ - `https://nmsr.nickac.dev/fullbody/${uuid}?yaw=25&pitch=8`, - `https://visage.surgeplay.com/full/512/${uuid}`, - `https://crafthead.net/armor/body/${uuid}?size=512`, - ]) { - const ok = await new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve(true); - img.onerror = () => resolve(false); - img.src = url; - }); - if (ok) { root.style.setProperty('--bg-skin', `url("${url}")`); return; } - } - root.style.setProperty('--bg-skin', 'none'); -} - -// ---------- Skin-Hintergrund: automatischer Kontenwechsel ---------- -let skinCycleTimer = null; -let skinCycleIndex = 0; - -function stopSkinCycle() { - if (skinCycleTimer) { clearInterval(skinCycleTimer); skinCycleTimer = null; } -} - -async function skinCycleTick() { - const accs = await window.api.authAccounts(); - if (accs.length < 2) { stopSkinCycle(); await applySkinBackground(); return; } - skinCycleIndex = (skinCycleIndex + 1) % accs.length; // am Ende wieder von vorn - await applySkinBackground(accs[skinCycleIndex].id); -} - -/* - * Einstiegspunkt für alles, was den Skin-Hintergrund neu anzeigen soll. - * Ersetzt die frühere direkte applySkinBackground()-Aufrufe: kümmert sich - * zusätzlich darum, den Kontenwechsel-Timer je nach Einstellung zu - * starten/stoppen, statt dass jede Aufrufstelle das selbst tun müsste. - */ -async function refreshSkinBackground() { - stopSkinCycle(); - const s = await window.api.getSettings(); - if (s.bgSkinCycle) { - const accs = await window.api.authAccounts(); - if (accs.length >= 2) { - skinCycleIndex = 0; - await applySkinBackground(accs[0].id); - const seconds = Math.max(5, Number(s.bgSkinCycleSeconds) || 30); - skinCycleTimer = setInterval(skinCycleTick, seconds * 1000); - return; - } - // weniger als 2 Konten angemeldet -> normal weiter wie ohne Wechsel - } - await applySkinBackground(); -} - -// Pose-Vorlagen für den lokalen 3D-Renderer (eigene Nachbildungen bekannter Motive) -const SKIN_POSES = { - walking: { label: 'Laufend', fov: 36, zoom: 0.62, apply: (p) => { - p.rotation.set(0.05, -0.62, 0); - p.skin.leftArm.rotation.set(-0.95, 0, 0.06); - p.skin.rightArm.rotation.set(0.85, 0, -0.06); - p.skin.leftLeg.rotation.set(0.8, 0, 0); - p.skin.rightLeg.rotation.set(-0.8, 0, 0); - } }, - standing: { label: 'Stehend', fov: 36, zoom: 0.6, apply: (p) => { - p.rotation.set(0, -0.35, 0); - p.skin.leftArm.rotation.set(0, 0, 0.08); - p.skin.rightArm.rotation.set(0, 0, -0.08); - p.skin.leftLeg.rotation.set(0, 0, 0.02); - p.skin.rightLeg.rotation.set(0, 0, -0.02); - } }, - legends: { label: 'Heldenhaft', fov: 34, zoom: 0.58, apply: (p) => { - p.rotation.set(-0.10, -0.5, 0); - p.skin.leftArm.rotation.set(-0.25, 0, 0.35); - p.skin.rightArm.rotation.set(-0.15, 0, -0.42); - p.skin.leftLeg.rotation.set(0.12, 0, 0.16); - p.skin.rightLeg.rotation.set(-0.12, 0, -0.16); - } }, - bedwars: { label: 'Kampfhaltung', fov: 36, zoom: 0.6, apply: (p) => { - p.rotation.set(0.06, -0.85, 0); - p.skin.rightArm.rotation.set(-1.45, 0, -0.18); // Arm vorgestreckt - p.skin.leftArm.rotation.set(0.45, 0, 0.30); - p.skin.leftLeg.rotation.set(0.45, 0, 0.10); - p.skin.rightLeg.rotation.set(-0.35, 0, -0.10); - } }, - hoverboard: { label: 'Schwebend', fov: 38, zoom: 0.6, apply: (p) => { - p.rotation.set(0.28, -0.7, 0.12); // vorgebeugt, gekippt - p.skin.leftArm.rotation.set(-0.35, 0, 1.05); // Arme zum Ausbalancieren - p.skin.rightArm.rotation.set(-0.35, 0, -1.05); - p.skin.leftLeg.rotation.set(0.5, 0, 0.18); - p.skin.rightLeg.rotation.set(0.15, 0, -0.18); - } }, - sitting: { label: 'Sitzend', fov: 36, zoom: 0.62, apply: (p) => { - p.rotation.set(0.05, -0.55, 0); - p.skin.leftArm.rotation.set(-0.55, 0, 0.18); - p.skin.rightArm.rotation.set(-0.55, 0, -0.18); - p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt - p.skin.rightLeg.rotation.set(-1.5, 0, -0.12); - } }, - spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => { - p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht - p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt - p.skin.leftArm.rotation.set(-1.35, 0, 0.12); // Arme nach vorn ausgestreckt - p.skin.rightArm.rotation.set(-1.25, 0, -0.16); - p.skin.leftLeg.rotation.set(0.18, 0, 0.06); - p.skin.rightLeg.rotation.set(-0.12, 0, -0.06); - } }, -}; - -// Rendert den Skin lokal mit WebGL in der gewählten Pose -/* - * Rendert eine Skin-Textur als Spielfigur und gibt ein Bild zurück. - * quelle: Adresse ODER data:-URI – so lässt sich auch eine Datei anzeigen, - * die noch gar nicht hochgeladen ist. - * modell: 'default' (breite Arme) oder 'slim' (Alex) - */ -async function renderSkin3D(quelle, poseKey, opts = {}) { - if (typeof skinview3d === 'undefined') return null; - const canvas = el('skin-canvas'); - const breite = opts.breite || 420; - const hoehe = opts.hoehe || 700; - try { - const viewer = new skinview3d.SkinViewer({ - canvas, width: breite, height: hoehe, - skin: quelle, - model: opts.modell === 'slim' ? 'slim' : 'default', - preserveDrawingBuffer: true, - }); - // Auf das Laden der Skin-Textur warten - await new Promise((resolve, reject) => { - const t = setTimeout(reject, 8000); - const check = () => { - if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); } - else setTimeout(check, 60); - }; - check(); - }); - - const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking; - viewer.fov = pose.fov; - viewer.zoom = pose.zoom; - viewer.autoRotate = false; - viewer.animation = null; - pose.apply(viewer.playerObject); - - viewer.render(); - const data = canvas.toDataURL('image/png'); - viewer.dispose(); - return data && data.length > 2000 ? data : null; - } catch { - return null; - } -} - -// ---------- Icon-System (SVG statt Emoji) ---------- -const ICONS_SVG = { - plus: '', - import: '', - package: '', - folder: '', - terminal: '', - user: '', - users: '', - settings: '', - play: '', - blocks: '', - globe: '', - image: '', - edit: '', - copy: '', - export: '', - trash: '', -}; -function svgIcon(name) { - const p = ICONS_SVG[name]; - if (!p) return ''; - const filled = name === 'play'; - const attrs = filled - ? 'fill="currentColor" stroke="none"' - : 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; - return `${p}`; -} -function applyIcons(root = document) { - root.querySelectorAll('[data-ic]').forEach((b) => { - if (b.querySelector('svg')) return; - b.insertAdjacentHTML('afterbegin', svgIcon(b.dataset.ic)); - }); -} - -// ---------- Instanz-Kacheln ---------- -const SINGLE_SELECTION_BUTTON_IDS = [ - 'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens', 'p-logs', 'p-servers', 'p-modpack', - 'p-mcfolder', 'p-configfolder', 'p-instfolder', 'p-shortcut', 'p-export', 'p-copy', -]; - -function orderedSelectedInstanceIds() { - return instances.map((inst) => inst.id).filter((id) => selectedInstanceIds.has(id)); -} - -function syncCardSelectionUi() { - document.querySelectorAll('.card').forEach((card) => { - card.classList.toggle('sel', selectedInstanceIds.has(card.dataset.id)); - }); -} - -function setSingleSelectionActionsDisabled(disabled) { - SINGLE_SELECTION_BUTTON_IDS.forEach((id) => { el(id).disabled = disabled; }); - const groupLine = el('p-groupline'); - groupLine.style.pointerEvents = disabled ? 'none' : ''; - groupLine.style.color = disabled ? 'var(--dim)' : ''; -} - -async function refreshSelectionPanel() { - const selectedIds = orderedSelectedInstanceIds(); - const count = selectedIds.length; - const panelC = el('p-content'); - const panelE = el('p-empty'); - syncCardSelectionUi(); - - if (!count) { - currentDetailId = null; - setSingleSelectionActionsDisabled(false); - el('p-delete').disabled = false; - el('p-delete').textContent = 'Löschen'; - panelC.classList.add('hidden'); - panelE.classList.remove('hidden'); - el('status-left').textContent = 'Bereit'; - el('status-play').textContent = 'Keine Instanz ausgewählt'; - el('status-total').textContent = ''; - return; - } - - if (count > 1) { - currentDetailId = null; - setSingleSelectionActionsDisabled(true); - el('p-delete').disabled = false; - el('p-delete').textContent = `${count} Instanzen löschen`; - panelE.classList.add('hidden'); - panelC.classList.remove('hidden'); - el('p-icon').innerHTML = iconOf('chest', 60); - el('p-name').textContent = `${count} Instanzen ausgewählt`; - el('p-groupline').textContent = 'Mehrfachauswahl aktiv'; - el('status-left').textContent = `${count} Instanzen ausgewählt`; - el('status-play').textContent = 'Strg+Klick wählt weitere Instanzen aus oder ab'; - el('status-total').textContent = 'Sammellöschen verfügbar'; - return; - } - - const id = selectedIds[0]; - currentDetailId = id; - setSingleSelectionActionsDisabled(false); - el('p-delete').disabled = false; - el('p-delete').textContent = 'Löschen'; - window.api.saveSettings({ lastSelectedId: id || '' }); - - const inst = id ? await window.api.getInstance(id) : null; - if (!inst) { - selectedInstanceIds.delete(id); - panelC.classList.add('hidden'); - panelE.classList.remove('hidden'); - el('status-left').textContent = 'Bereit'; - el('status-play').textContent = 'Keine Instanz ausgewählt'; - el('status-total').textContent = ''; - return; - } - - panelE.classList.add('hidden'); - panelC.classList.remove('hidden'); - - // "Modpack aktualisieren" nur bei Instanzen, die aus einem Modrinth-Paket stammen - el('p-modpack').classList.toggle('hidden', !(inst.modpack && inst.modpack.projectId)); - - const iconData = inst.customImage ? await window.api.instanceIcon(id) : null; - if (iconData) el('p-icon').innerHTML = ``; - else { el('p-icon').innerHTML = iconOf(inst.icon, 60); } - el('p-name').textContent = inst.name; - el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe'; - - const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla' - ? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : ''; - el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')'; - el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie'); - el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds); -} - -async function applySelection(ids) { - const valid = new Set(instances.map((inst) => inst.id)); - selectedInstanceIds = new Set((ids || []).filter((id) => valid.has(id))); - await refreshSelectionPanel(); -} - -async function toggleInstanceSelection(id) { - const next = new Set(selectedInstanceIds); - if (next.has(id)) next.delete(id); - else next.add(id); - await applySelection([...next]); -} - -function buildCard(inst) { - const card = document.createElement('div'); - card.className = 'card' + (selectedInstanceIds.has(inst.id) ? ' sel' : ''); - card.dataset.id = inst.id; - - const iconHtml = inst.iconData - ? `` - : `${iconOf(inst.icon, 60)}`; - // Tooltip: Name, Version/Loader und – falls vorhanden – die Notizen - const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla' - ? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : ''; - let tip = inst.name + '\n' + (inst.minecraft.version || 'ohne Version') + loaderTxt; - if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim(); - card.title = tip; - - card.innerHTML = ` - ${iconHtml} -
${escapeHtml(inst.name)}
`; - - // einfacher Klick = auswählen (MultiMC), Doppelklick = starten - card.addEventListener('click', (e) => { - if (e.ctrlKey || e.metaKey) toggleInstanceSelection(inst.id); - else selectInstance(inst.id); - }); - card.addEventListener('dblclick', () => launch(inst.id)); - - // Rechtsklick: Instanz auswählen und Kontextmenü öffnen - card.addEventListener('contextmenu', async (e) => { - e.preventDefault(); - if (!selectedInstanceIds.has(inst.id)) await selectInstance(inst.id); - openContextMenu(e.clientX, e.clientY); // sofort anzeigen - }); - - // Drag & Drop in Gruppen - card.draggable = true; - card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', inst.id); card.classList.add('dragging'); }); - card.addEventListener('dragend', () => card.classList.remove('dragging')); - return card; -} - -function render() { - const q = el('search').value.trim().toLowerCase(); - const grid = el('grid'); - grid.innerHTML = ''; - - const filtered = instances.filter((i) => - !q || (i.name + ' ' + i.minecraft.version + ' ' + i.minecraft.loader + ' ' + (i.group || '')).toLowerCase().includes(q)); - - el('empty').classList.toggle('hidden', instances.length !== 0); - grid.classList.toggle('hidden', instances.length === 0); - - // nach Gruppen bündeln - const groups = new Map(); - for (const inst of filtered) { - const g = (inst.group && inst.group.trim()) || ''; - if (!groups.has(g)) groups.set(g, []); - groups.get(g).push(inst); - } - // benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt - const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))); - const showHeaders = names.some((n) => n !== ''); - - for (const g of names) { - const list = groups.get(g); - const section = document.createElement('div'); - section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : ''); - - if (showHeaders) { - const header = document.createElement('div'); - header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : ''); - header.innerHTML = `${collapsedGroups.has(g) ? '▸' : '▾'} ${escapeHtml(g || 'Ohne Gruppe')} (${list.length})`; - header.addEventListener('click', () => { - if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g); - render(); - }); - section.appendChild(header); - } - const gridEl = document.createElement('div'); - gridEl.className = 'group-grid'; - for (const inst of list) gridEl.appendChild(buildCard(inst)); - section.appendChild(gridEl); - - // Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben - section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); }); - section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); }); - section.addEventListener('drop', async (e) => { - e.preventDefault(); - section.classList.remove('drop-hover'); - const dropId = e.dataTransfer.getData('text/plain'); - const dragged = instances.find((i) => i.id === dropId); - if (dragged && (dragged.group || '') !== g) { - await window.api.updateInstance(dropId, { group: g }); - await reload(); - toast('Verschoben nach: ' + (g || 'Ohne Gruppe')); - } - }); - - grid.appendChild(section); - } - status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`); -} - -function fillGroupDatalist() { - const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort(); - el('group-list').innerHTML = groups.map((g) => ``).join(''); -} - -async function reload() { - instances = await window.api.listInstances(); - const valid = new Set(instances.map((inst) => inst.id)); - selectedInstanceIds = new Set([...selectedInstanceIds].filter((id) => valid.has(id))); - render(); - await refreshSelectionPanel(); -} - -// ---------- Minecraft-Versionen ---------- -async function loadVersions() { - if (mcVersionData) return mcVersionData; - try { - mcVersionData = await window.api.mcVersions(); - } catch (e) { - mcVersionData = { offline: true, versions: [], latest: {} }; - } - return mcVersionData; -} - -function fillVersionSelect(selectedId) { - const type = el('f-version-type').value; - const sel = el('f-version'); - const hint = el('f-version-hint'); - sel.innerHTML = ''; - - const data = mcVersionData || { versions: [], latest: {} }; - let list = data.versions || []; - if (type !== 'all') list = list.filter((v) => v.type === type); - - // gewählte Version muss vorhanden sein (auch wenn Filter sie ausblendet) - if (selectedId && !list.some((v) => v.id === selectedId)) { - const found = (data.versions || []).find((v) => v.id === selectedId); - list = [found || { id: selectedId, type: 'unknown' }, ...list]; - } - - if (!list.length) { - const o = document.createElement('option'); - o.value = selectedId || ''; - o.textContent = selectedId || '(keine Versionsliste – offline?)'; - sel.appendChild(o); - } else { - for (const v of list.slice(0, 1200)) { - const o = document.createElement('option'); - o.value = v.id; - o.textContent = v.id; - if (v.id === selectedId) o.selected = true; - sel.appendChild(o); - } - } - - if (data.offline) hint.textContent = '⚠ Offline – Versionsliste evtl. veraltet.'; - else if (data.latest) hint.textContent = `Neuestes Release: ${data.latest.release || '?'} · Snapshot: ${data.latest.snapshot || '?'}`; -} - -// Loader-Versionen (Fabric/Quilt) passend zur MC-Version laden -async function updateLoaderVersions(preselect) { - const loader = el('f-loader').value; - const mc = el('f-version').value; - const sel = el('f-loader-version'); - const hint = el('f-loader-hint'); - sel.innerHTML = ''; - - const ofTools = el('f-optifine-tools'); - if (ofTools) ofTools.classList.toggle('hidden', loader !== 'optifine'); - - if (loader === 'vanilla') { sel.disabled = true; hint.textContent = 'Vanilla hat keinen Loader.'; return; } - - if (loader === 'optifine') { - // Kein Loader-API bei OptiFine – die Liste zeigt bereits eingerichtete - // Fassungen (siehe f-optifine-tools weiter unten), keine automatisch - // ladbaren Versionen. - sel.innerHTML = ''; - sel.disabled = false; - if (el('f-optifine-status')) el('f-optifine-status').textContent = ''; - if (el('f-optifine-confirm')) el('f-optifine-confirm').classList.add('hidden'); - if (!editingId) { - hint.textContent = 'Erst speichern, dann lässt sich OptiFine im Bearbeiten-Dialog einrichten.'; - if (el('f-optifine-install')) el('f-optifine-install').disabled = true; - return; - } - if (el('f-optifine-install')) el('f-optifine-install').disabled = false; - if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; } - hint.textContent = 'Suche bereits eingerichtete OptiFine-Fassungen …'; - try { - const found = await window.api.optifineFind(mc); - for (const v of found) { - const o = document.createElement('option'); - o.value = v; o.textContent = v; - if (v === preselect) o.selected = true; - sel.appendChild(o); - } - hint.textContent = found.length - ? `${found.length} eingerichtete OptiFine-Fassung(en) für ${mc}.` - : 'Noch keine OptiFine-Installation für diese Version – über den Knopf unten einrichten.'; - } catch { - hint.textContent = 'Suche fehlgeschlagen.'; - } - return; - } - - sel.disabled = false; - if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; } - - hint.textContent = 'Lade Loader-Versionen …'; - try { - const versions = await window.api.loaderVersions(loader, mc); - for (const v of versions.slice(0, 80)) { - const o = document.createElement('option'); - o.value = v.version; - o.textContent = v.version + (v.stable ? '' : ' (beta)'); - if (v.version === preselect) o.selected = true; - sel.appendChild(o); - } - hint.textContent = versions.length ? `${versions.length} Version(en) verfügbar` : 'Keine Loader-Version für diese MC-Version.'; - } catch { - hint.textContent = 'Loader-Versionen konnten nicht geladen werden.'; - } -} - -// ---------- Instanz-Dialog ---------- -// alle wählbaren Icons: echte Minecraft-Texturen zuerst, dann die eigenen -function availableIcons() { - const real = Object.values(mcIcons).map((i) => ({ key: i.key, label: i.label, real: true })); - if (real.length) return real; - const own = ICON_KEYS - .filter((k) => !mcIcons[k]) - .map((k) => ({ key: k, label: ICON_LABELS[k] || k, real: false })); - return real.concat(own); -} - -function fillIconSelect(selected) { - const sel = el('f-icon'); - sel.innerHTML = ''; - for (const it of availableIcons()) { - const o = document.createElement('option'); - o.value = it.key; - o.textContent = it.label; - if (it.key === selected) o.selected = true; - sel.appendChild(o); - } - renderIconChoices(selected); -} - -// klickbare Item-Vorschau unter der Auswahl -function renderIconChoices(selected) { - const box = el('f-icon-grid'); - if (!box) return; - box.innerHTML = ''; - for (const it of availableIcons()) { - const b = document.createElement('button'); - b.type = 'button'; - b.className = 'icon-choice' + (it.key === selected ? ' on' : ''); - b.title = it.label; - b.innerHTML = iconOf(it.key, 24); - b.addEventListener('click', () => { - el('f-icon').value = it.key; - renderIconChoices(it.key); - }); - box.appendChild(b); - } -} - -// zeigt/versteckt die Bildvorschau im Dialog -function setIconPreview(dataUri) { - const box = el('f-icon-preview'); - const clr = el('f-clear-image'); - if (dataUri) { - box.innerHTML = ``; - box.classList.remove('hidden'); - clr.classList.remove('hidden'); - } else { - box.innerHTML = ''; - box.classList.add('hidden'); - clr.classList.add('hidden'); - } -} - -function resetIconControls() { - pendingImagePath = null; - pendingClearImage = false; - setIconPreview(null); -} - -async function openCreate() { - if (POPOUT !== 'instance') { window.api.popoutOpen('instance'); return; } - editingId = null; - el('instance-modal-title').textContent = 'Neue Instanz'; - el('f-name').value = ''; - el('f-group').value = ''; - fillGroupDatalist(); - el('f-loader').value = 'vanilla'; - el('f-loader-version').value = ''; - el('f-notes').value = ''; - fillIconSelect('grass'); - resetIconControls(); - el('f-advanced').classList.add('hidden'); - show('modal-instance'); - el('f-name').focus(); - - el('f-version-hint').textContent = 'Lade Versionsliste …'; - await loadVersions(); - el('f-version-type').value = 'release'; - fillVersionSelect(mcVersionData.latest && mcVersionData.latest.release); - updateLoaderVersions(); -} - -async function openEdit(inst) { - if (POPOUT !== 'instance') { window.api.popoutOpen('instance', { instanceId: inst.id }); return; } - editingId = inst.id; - el('instance-modal-title').textContent = 'Instanz bearbeiten'; - el('f-name').value = inst.name; - el('f-group').value = inst.group || ''; - fillGroupDatalist(); - el('f-loader').value = inst.minecraft.loader || 'vanilla'; - el('f-loader-version').value = inst.minecraft.loaderVersion || ''; - el('f-notes').value = inst.notes || ''; - fillIconSelect(inst.icon); - resetIconControls(); - if (inst.customImage) { - const data = await window.api.instanceIcon(inst.id); - if (data) setIconPreview(data); - } - el('f-min-mem').value = inst.java.minMemMb || ''; - el('f-max-mem').value = inst.java.maxMemMb || ''; - zeigeSpeicherHinweis(); - fuelleInstanzJava(inst); - el('f-extra-args').value = inst.java.extraArgs || ''; - el('f-prelaunch').value = inst.preLaunchCommand || ''; - el('f-postexit').value = inst.postExitCommand || ''; - el('f-advanced').classList.remove('hidden'); - show('modal-instance'); - el('f-name').focus(); - - el('f-version-hint').textContent = 'Lade Versionsliste …'; - await loadVersions(); - el('f-version-type').value = inst.minecraft.version ? 'all' : 'release'; - fillVersionSelect(inst.minecraft.version || (mcVersionData.latest && mcVersionData.latest.release)); - await updateLoaderVersions(inst.minecraft.loaderVersion); -} - -async function saveInstance() { - const name = el('f-name').value.trim(); - if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; } - - const payload = { - name, - group: el('f-group').value.trim(), - icon: el('f-icon').value, - notes: el('f-notes').value.trim(), - minecraft: { - version: el('f-version').value.trim(), - loader: el('f-loader').value, - loaderVersion: el('f-loader-version').value.trim(), - }, - }; - // Speicherangaben prüfen, bevor gespeichert wird - const minMem = numOrNull(el('f-min-mem').value); - const maxMem = numOrNull(el('f-max-mem').value); - if (minMem || maxMem) { - const pruefung = await window.api.checkMemory(minMem, maxMem); - if (!pruefung.ok) { toast(pruefung.message, true); return; } - if (pruefung.warnung) toast(pruefung.warnung, false); - } - - try { - let targetId = editingId; - if (editingId) { - payload.java = { - path: el('f-java').value.trim(), - minMemMb: minMem, - maxMemMb: maxMem, - extraArgs: el('f-extra-args').value.trim(), - }; - payload.preLaunchCommand = el('f-prelaunch').value.trim(); - payload.postExitCommand = el('f-postexit').value.trim(); - await window.api.updateInstance(editingId, payload); - } else { - const created = await window.api.createInstance(payload); - targetId = created.id; - } - // eigenes Bild anwenden bzw. entfernen - if (pendingImagePath) await window.api.setInstanceImage(targetId, pendingImagePath); - else if (pendingClearImage && editingId) await window.api.clearInstanceImage(targetId); - - toast(editingId ? 'Instanz gespeichert.' : 'Instanz angelegt.'); - hide('modal-instance'); - await reload(); - await selectInstance(targetId); - } catch (e) { - toast('Fehler: ' + e.message, true); - } -} - -// ---------- Auswahl + rechte Aktionsleiste (MultiMC-Stil) ---------- -async function selectInstance(id) { - await applySelection(id ? [id] : []); -} - -let launching = false; - -async function launch(id, accountId, launchOpts) { - if (launching) return; - launching = true; - status('Vorbereiten …'); - el('status-right').textContent = ''; - appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n'); - try { - const res = await window.api.launchInstance(id, accountId, launchOpts || null); - if (res.ok) { - status('Läuft (PID ' + res.pid + ')'); - toast('Minecraft gestartet.'); - const s = await window.api.getSettings(); - if (s.showConsoleOnLaunch) openConsole(); // nur wenn gewünscht - } else if (res.needAuth) { - status('Bereit'); el('status-right').textContent = ''; - toast(res.message, false); - } else { - status('Bereit'); el('status-right').textContent = ''; - toast(res.message || 'Start fehlgeschlagen.', true); - } - } catch (e) { - status('Bereit'); toast('Fehler: ' + e.message, true); - } finally { - launching = false; - } -} - -// ---------- Java je Instanz ---------- - -async function fuelleInstanzJava(inst) { - const feld = el('f-java'); - if (!feld) return; - feld.value = (inst && inst.java && inst.java.path) || ''; - - const sel = el('f-java-detected'); - sel.innerHTML = ''; - try { - for (const j of await window.api.listJava()) { - const o = document.createElement('option'); - o.value = j.path; - o.textContent = `Java ${j.major} (${j.arch}) – ${j.path}`; - sel.appendChild(o); - } - } catch { /* Liste bleibt leer */ } - sel.value = feld.value && [...sel.options].some((o) => o.value === feld.value) ? feld.value : ''; - - // Der häufigste Stolperstein: global ist eine feste Fassung eingetragen, - // die dann auch für alte Minecraft-Versionen genommen wird. - try { - const s = await window.api.getSettings(); - el('f-java-hint').textContent = s.javaPath - ? `Achtung: In den Einstellungen ist ${s.javaPath} fest vorgegeben und gilt für alle Instanzen. ` - + 'Trage hier etwas ein, wenn diese Instanz eine andere Fassung braucht – ältere Minecraft-Versionen verlangen oft Java 8.' - : 'Leer lassen: Der Launcher nimmt die Fassung, die diese Minecraft-Version verlangt, und lädt sie bei Bedarf selbst.'; - } catch { /* egal */ } -} - -// ---------- Arbeitsspeicher ---------- - -let systemSpeicher = null; - -async function zeigeSpeicherHinweis() { - const feld = el('f-mem-hint'); - if (!feld) return; - try { - if (!systemSpeicher) systemSpeicher = await window.api.systemMemory(); - } catch { return; } - const gesamt = systemSpeicher.totalMb; - const empfohlen = Math.floor(gesamt * 0.5 / 256) * 256; - feld.textContent = `Im Rechner stecken ${gesamt} MB. Leer lassen übernimmt die globale Vorgabe. ` - + `Für die meisten Modpacks reichen 4096–8192 MB; mehr als ${Math.floor(gesamt * 0.8 / 256) * 256} MB sollten es nicht sein ` - + `(Vorschlag: ${empfohlen} MB).`; -} - -// ---------- Modpack aktualisieren ---------- - -let modpackNeueVersion = null; - -async function openModpackUpdate() { - if (!currentDetailId) return; - modpackNeueVersion = null; - el('mp-status').textContent = 'Suche nach einer neueren Fassung …'; - el('mp-kern').classList.add('hidden'); - el('mp-do').classList.add('hidden'); - show('modal-modpack'); - - const res = await window.api.modpackCheck(currentDetailId); - if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Suche fehlgeschlagen.'); return; } - if (!res.verfuegbar) { - el('mp-status').textContent = `${res.name || 'Das Modpack'} ist auf dem neuesten Stand (${res.aktuelleNummer}).`; - return; - } - - modpackNeueVersion = res.neueVersionId; - el('mp-status').textContent = `Für ${res.name || 'dieses Modpack'} gibt es eine neuere Fassung.`; - const zeile = (t, w) => (w ? `
${t}
${escapeHtml(w)}
` : ''); - el('mp-kern').innerHTML = zeile('Installiert', res.aktuelleNummer) + zeile('Neu', res.neueNummer) - + zeile('Minecraft', res.spielVersion) - + (res.hinweise ? zeile('Änderungen', res.hinweise.slice(0, 400)) : ''); - el('mp-kern').classList.remove('hidden'); - el('mp-do').classList.remove('hidden'); -} - -async function doModpackUpdate() { - if (!modpackNeueVersion) return; - const btn = el('mp-do'); - btn.disabled = true; - el('mp-status').textContent = 'Wird aktualisiert – das kann einige Minuten dauern …'; - const res = await window.api.modpackUpdate(currentDetailId, modpackNeueVersion); - btn.disabled = false; - if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.'); return; } - - el('mp-status').textContent = `✔ Auf ${res.neueNummer} aktualisiert: ${res.eingespielt} Dateien eingespielt` - + (res.entfernt ? `, ${res.entfernt} entfernt` : '') + '.'; - el('mp-do').classList.add('hidden'); - toast('Modpack aktualisiert.'); - await reload(); - await selectInstance(currentDetailId); -} - -// ---------- Serverliste ---------- - -let zuUebertragenderServer = null; - -async function openServers() { - if (!currentDetailId) return; - if (POPOUT !== 'servers') { window.api.popoutOpen('servers', { instanceId: currentDetailId }); return; } - const inst = await window.api.getInstance(currentDetailId); - el('servers-title').textContent = 'Server – ' + (inst ? inst.name : ''); - el('srv-name').value = ''; - el('srv-ip').value = ''; - show('modal-servers'); - await renderServers(); -} - -async function renderServers() { - const box = el('srv-list'); - box.innerHTML = '
Lade …
'; - const res = await window.api.serversList(currentDetailId); - box.innerHTML = ''; - - if (!res.ok) { box.innerHTML = `
${escapeHtml(res.message || 'Liste nicht lesbar.')}
`; return; } - if (!res.server.length) { - box.innerHTML = '
Noch keine Server eingetragen.
'; - return; - } - - for (const s of res.server) { - const item = document.createElement('div'); - item.className = 'mod-item'; - item.innerHTML = - `
🖥
-
-
${escapeHtml(s.name || s.ip)}
-
${escapeHtml(s.ip)}
-
Wird geprüft …
-
-
`; - const aktionen = item.querySelector('.mod-action'); - pingServerRow(s.ip, item.querySelector('.srv-status')); - - const starten = document.createElement('button'); - starten.className = 'btn btn-primary btn-mini'; - starten.textContent = 'Starten'; - starten.title = 'Instanz starten und direkt diesem Server beitreten (Quick Play, ab MC 1.20)'; - starten.addEventListener('click', () => { - hide('modal-servers'); - launch(currentDetailId, null, { quickPlayServer: s.ip }); - }); - aktionen.appendChild(starten); - - const favorit = document.createElement('button'); - favorit.className = 'btn btn-mini'; - favorit.textContent = '★ Verknüpfung'; - favorit.title = 'Desktop-Verknüpfung erstellen, die Minecraft startet und direkt mit diesem Server verbindet (Quick Play, ab MC 1.20)'; - favorit.addEventListener('click', async () => { - const inst = await window.api.getInstance(currentDetailId); - const vorschlag = `${inst ? inst.name : 'Instanz'} - ${s.name || s.ip}`; - const eingabe = await askPrompt({ - title: 'Server-Verknüpfung erstellen', - text: 'Name der Desktop-Verknüpfung (ohne .lnk):', - label: 'Name', - value: vorschlag, - okLabel: 'Erstellen', - }); - if (eingabe == null) return; // abgebrochen - const res = await window.api.serversShortcut(currentDetailId, s.ip, s.name, eingabe.trim()); - if (res.ok) toast('Verknüpfung auf dem Desktop erstellt: ' + (s.name || s.ip)); - else toast(res.message || 'Verknüpfung fehlgeschlagen.', true); - }); - aktionen.appendChild(favorit); - - const umbenennen = document.createElement('button'); - umbenennen.className = 'btn btn-mini'; - umbenennen.textContent = 'Umbenennen'; - umbenennen.title = 'Anzeigenamen ändern'; - umbenennen.addEventListener('click', async () => { - const aktuell = s.name || s.ip; - const eingabe = await askPrompt({ - title: 'Server umbenennen', - text: 'Adresse: ' + s.ip, - label: 'Name', - value: aktuell, - okLabel: 'Speichern', - }); - if (eingabe == null) return; // abgebrochen - const res = await window.api.serversRename(currentDetailId, s.ip, eingabe); - if (!res.ok) { toast(res.message || 'Umbenennen fehlgeschlagen.', true); return; } - if (!res.unveraendert) toast('Umbenannt: ' + eingabe.trim()); - await renderServers(); - }); - aktionen.appendChild(umbenennen); - - const kopieren = document.createElement('button'); - kopieren.className = 'btn btn-mini'; - kopieren.textContent = 'Übertragen…'; - kopieren.title = 'Diesen Server in andere Instanzen eintragen'; - kopieren.addEventListener('click', () => openServerCopy(s)); - aktionen.appendChild(kopieren); - - const weg = document.createElement('button'); - weg.className = 'btn btn-danger btn-mini'; - weg.textContent = 'Entfernen'; - weg.addEventListener('click', async () => { - const r = await window.api.serversRemove(currentDetailId, s.ip); - if (r.ok) toast('Entfernt: ' + (s.name || s.ip)); - else toast(r.message || 'Entfernen fehlgeschlagen.', true); - await renderServers(); - }); - aktionen.appendChild(weg); - box.appendChild(item); - } -} - -// Fragt Live-Status (MOTD/Spieler/Version) ab und trägt ihn in die Zeile ein. -// Läuft unabhängig für jede Zeile, ohne die Liste selbst zu blockieren. -async function pingServerRow(ip, statusEl) { - if (!statusEl) return; - const res = await window.api.serversPing(ip); - // Zeile kann inzwischen aus dem DOM entfernt worden sein (Liste neu aufgebaut) - if (!statusEl.isConnected) return; - if (!res.ok) { - statusEl.textContent = '⚠ ' + (res.message || 'nicht erreichbar'); - statusEl.classList.add('status-bad'); - return; - } - const spieler = res.max != null ? `${res.online ?? '?'}/${res.max} Spieler` : ''; - statusEl.textContent = [res.motd, spieler, res.version, res.latencyMs + ' ms'] - .filter(Boolean).join(' · '); - statusEl.classList.add('status-ok'); -} - -async function addServer() { - const eintrag = { name: el('srv-name').value.trim(), ip: el('srv-ip').value.trim() }; - const res = await window.api.serversAdd(currentDetailId, eintrag); - if (!res.ok) { toast(res.message || 'Hinzufügen fehlgeschlagen.', true); return; } - el('srv-name').value = ''; - el('srv-ip').value = ''; - toast('Server hinzugefügt.'); - await renderServers(); -} - -async function openServerCopy(server) { - zuUebertragenderServer = server; - el('srv-copy-sub').textContent = `„${server.name || server.ip}" (${server.ip}) in diese Instanzen eintragen:`; - const box = el('srv-copy-list'); - box.innerHTML = ''; - const alle = await window.api.listInstances(); - const andere = alle.filter((i) => i.id !== currentDetailId); - if (!andere.length) { - box.innerHTML = '
Es gibt keine weitere Instanz.
'; - } - for (const i of andere) { - const zeile = document.createElement('label'); - zeile.className = 'checkbox-field'; - zeile.style.padding = '6px 2px'; - zeile.innerHTML = ` ${escapeHtml(i.name)}`; - box.appendChild(zeile); - } - show('modal-srv-copy'); -} - -async function doServerCopy() { - if (!zuUebertragenderServer) return; - const ids = [...el('srv-copy-list').querySelectorAll('input:checked')].map((c) => c.value); - if (!ids.length) { toast('Keine Instanz ausgewählt.', true); return; } - - const res = await window.api.serversCopyTo(ids, zuUebertragenderServer); - hide('modal-srv-copy'); - if (res.erledigt.length) toast(`Eingetragen in: ${res.erledigt.join(', ')}`); - for (const f of res.fehler) toast(`${f.name || f.id}: ${f.message}`, true); -} - -// ---------- Skin ändern ---------- - -let gewaehlterSkin = null; - -// Textur einer Seite merken, damit bei einem Wechsel der Armform beide -// Figuren neu gerendert werden können -let skinTexturAktuell = ''; -let skinTexturNeu = ''; - -// laufende Viewer je Canvas -- vor einem Neuaufbau erst abräumen, sonst -// stapeln sich bei jedem Wechsel (Datei, Armform, ...) weitere WebGL-Kontexte -const skinViewers = {}; -function schliesseSkinViewer(bildId) { - if (skinViewers[bildId]) { - try { skinViewers[bildId].dispose(); } catch { /* egal */ } - delete skinViewers[bildId]; - } -} - -/* - * Skin-Textur als frei mit der Maus drehbare 3D-Figur zeigen (Ziehen dreht, - * Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht, - * wird stattdessen ein Hinweistext gezeigt. - */ -async function zeigeSkinFigur(bildId, leerId, textur, leerText) { - schliesseSkinViewer(bildId); - const canvas = el(bildId); - const leer = el(leerId); - canvas.classList.add('hidden'); - if (!textur) { leer.textContent = leerText; leer.classList.remove('hidden'); return; } - if (typeof skinview3d === 'undefined') { - leer.textContent = 'Vorschau nicht verfügbar'; - leer.classList.remove('hidden'); - return; - } - leer.textContent = 'wird gerendert …'; - leer.classList.remove('hidden'); - const modell = el('skin-variante').value === 'slim' ? 'slim' : 'default'; - try { - const viewer = new skinview3d.SkinViewer({ - canvas, - width: canvas.clientWidth || 260, - height: canvas.clientHeight || 288, - skin: textur, - model: modell, - }); - // Auf das Laden der Skin-Textur warten, bevor Pose/Kamera gesetzt werden - await new Promise((resolve, reject) => { - const t = setTimeout(reject, 8000); - const check = () => { - if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); } - else setTimeout(check, 60); - }; - check(); - }); - const pose = SKIN_POSES.standing; - viewer.fov = pose.fov; - viewer.zoom = pose.zoom; - viewer.autoRotate = false; - viewer.animation = null; - pose.apply(viewer.playerObject); - viewer.controls.enableRotate = true; - viewer.controls.enableZoom = true; - viewer.controls.enablePan = false; - skinViewers[bildId] = viewer; - canvas.classList.remove('hidden'); - leer.classList.add('hidden'); - } catch { - leer.textContent = 'Vorschau nicht ladbar'; - leer.classList.remove('hidden'); - } -} - -async function openSkin() { - if (POPOUT !== 'skin') { window.api.popoutOpen('skin'); return; } - gewaehlterSkin = null; - skinTexturAktuell = ''; - skinTexturNeu = ''; - el('skin-pfad').value = ''; - el('skin-apply').disabled = true; - el('skin-status').textContent = ''; - await zeigeSkinFigur('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt'); - await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …'); - el('skin-konto').textContent = 'Profil wird geladen …'; - show('modal-skin'); - - const p = await window.api.skinProfile(); - if (!p.ok) { - el('skin-konto').textContent = p.message || 'Profil konnte nicht geladen werden.'; - await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen'); - return; - } - el('skin-konto').textContent = `Angemeldet als ${p.name}. Die Änderung gilt für dieses Konto.`; - // Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird - if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic'; - skinTexturAktuell = p.skinUrl || ''; - await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, - 'Standard-Skin (keine eigene Datei hinterlegt)'); -} - -// Armform gewechselt -> beide Figuren neu zeichnen -async function skinVarianteGewechselt() { - if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, ''); - if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, ''); -} - -async function waehleSkinDatei() { - const res = await window.api.skinChoose(); - if (res.canceled) return; - if (!res.ok) { el('skin-status').textContent = '✖ ' + res.message; return; } - - gewaehlterSkin = res.pfad; - el('skin-pfad').value = res.pfad; - skinTexturNeu = res.dataUri || ''; - await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar'); - el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`; - el('skin-apply').disabled = false; -} - -async function setzeSkin() { - if (!gewaehlterSkin) return; - const btn = el('skin-apply'); - btn.disabled = true; - el('skin-status').textContent = 'Wird hochgeladen …'; - const res = await window.api.skinApply(gewaehlterSkin, el('skin-variante').value); - if (res.ok) { - el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.'; - toast('Skin geändert.'); - await openSkin(); // Vorschau mit dem neuen Stand auffrischen - await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin - } else { - el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); - btn.disabled = false; - } -} - -async function setzeSkinZurueck() { - const btn = el('skin-reset'); - btn.disabled = true; - el('skin-status').textContent = 'Wird zurückgesetzt …'; - const res = await window.api.skinReset(); - if (res.ok) { - toast('Skin auf Standard zurückgesetzt.'); - await openSkin(); - await refreshSkinBackground(); - } else { - el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); - } - btn.disabled = false; -} - -// ---------- Logdateien ---------- - -let aktuellesLog = ''; - -async function openLogs() { - if (!currentDetailId) return; - if (POPOUT !== 'logs') { window.api.popoutOpen('logs', { instanceId: currentDetailId }); return; } - const inst = await window.api.getInstance(currentDetailId); - el('logs-title').textContent = 'Logdateien – ' + (inst ? inst.name : ''); - const auswahl = el('logs-select'); - auswahl.innerHTML = ''; - el('logs-text').textContent = 'Lade …'; - el('logs-info').textContent = ''; - aktuellesLog = ''; - show('modal-logs'); - - const dateien = await window.api.logsList(currentDetailId); - if (!dateien.length) { - el('logs-text').textContent = 'Für diese Instanz gibt es noch keine Logdateien. ' - + 'Sie entstehen beim ersten Spielstart.'; - return; - } - for (const d of dateien) { - const o = document.createElement('option'); - o.value = d.name; - const kb = Math.max(1, Math.round(d.groesse / 1024)); - o.textContent = `${d.name} · ${kb} KB · ${new Date(d.geaendert).toLocaleString('de-DE')}` - + (d.gepackt ? ' · gepackt' : ''); - auswahl.appendChild(o); - } - await ladeLog(dateien[0].name); -} - -async function ladeLog(name) { - el('logs-text').textContent = 'Lade …'; - const res = await window.api.logsRead(currentDetailId, name); - if (!res.ok) { - el('logs-text').textContent = 'Datei konnte nicht gelesen werden.'; - el('logs-info').textContent = ''; - aktuellesLog = ''; - return; - } - aktuellesLog = res.text; - el('logs-text').textContent = res.text || '(leer)'; - el('logs-info').textContent = res.gekuerzt - ? `${res.zeilenGesamt} Zeilen – die letzten 5000 werden gezeigt` - : `${res.zeilenGesamt} Zeilen`; - // ans Ende springen: dort steht, was zuletzt passierte - const feld = el('logs-text'); - feld.scrollTop = feld.scrollHeight; -} - -// ---------- Absturzberichte ---------- - -let letzterStartMs = 0; -let aktuellerCrash = null; - -async function zeigeAbsturzbericht(instanzId) { - const id = instanzId || currentDetailId; - if (!id) return; - if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id }); return; } - // Minecraft schreibt den Bericht erst kurz nach dem Ende - await new Promise((r) => setTimeout(r, 900)); - - let res; - try { res = await window.api.crashLatest(id, letzterStartMs); } - catch { return; } - if (!res || !res.ok) return; // kein Bericht -> Konsole muss reichen - - aktuellerCrash = res; - const inst = await window.api.getInstance(id); - el('crash-sub').textContent = `${inst ? inst.name + ' · ' : ''}${res.datei} · ` - + new Date(res.zeitpunkt).toLocaleString('de-DE'); - - const zeile = (titel, wert) => wert - ? `
${titel}
${escapeHtml(wert)}
` - : ''; - let html = zeile('Diagnose', res.diagnose) + zeile('Empfohlene Lösung', res.empfehlung) - + zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler) - + zeile('Verdächtige Mods', res.verdaechtigeMods); - if (res.verlauf && res.verlauf.length) { - html += `
Aufrufverlauf
` - + `
${escapeHtml(res.verlauf.join('\n'))}
`; - } - el('crash-kern').innerHTML = html || '
Der Bericht enthält keine der üblichen Kennzeichen.
'; - el('crash-voll').textContent = res.text || ''; - show('modal-crash'); -} - -// Fortschrittsmeldungen der Download-/Start-Pipeline -function onLaunchEvt(evt) { - const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' }; - if (evt.phase === 'log') { - appendConsole(evt.line); - return; - } - if (evt.phase === 'exit') { - status('Minecraft beendet (Code ' + evt.code + ')'); - el('status-right').textContent = ''; - appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n'); - // War vorher "Stopping!" im Log (regulärer Client-Shutdown), zählt ein - // nachträglicher harter Exit-Code nicht als Absturz während des Spiels – - // z. B. wenn Minecrafts interner Shutdown-Watchdog wegen hängender - // Mod-Threads (z. B. JourneyMap WebMap) die JVM nach Ablauf einer - // Wartezeit zwangsweise beendet. Der Bericht bleibt trotzdem gespeichert. - const crashed = evt.code !== 0 && evt.code !== null && !evt.cleanShutdown; - if (evt.cleanShutdown && evt.code !== 0 && evt.code !== null) { - appendConsole('[Hinweis] Der Client wurde regulär beendet (Stopping!), aber danach vom ' - + 'internen Shutdown-Watchdog hart terminiert (z. B. durch einen Mod, dessen Threads ' - + 'nicht rechtzeitig beenden – etwa JourneyMap WebMap). Kein Absturz während des Spiels.\n'); - } - // Absturzbericht suchen, bevor über die Konsole entschieden wird – ein - // Bericht sagt mehr als der Java-Auswurf. Das entscheidet nur das - // Hauptfenster – sonst würde z. B. das Konsolen-Popup-Fenster bei einem - // Absturz zusätzlich einen eigenen Absturzbericht aufmachen. - if (!POPOUT) { - if (crashed) zeigeAbsturzbericht(evt.id); - window.api.getSettings().then((s) => { - if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; } - if (s.autoCloseConsole) hide('modal-console'); - }).catch(() => {}); - } - return; - } - // Startzeitpunkt merken: nur Berichte ab hier gehören zu diesem Lauf - if (evt.phase === 'launch') letzterStartMs = Date.now(); - const label = labels[evt.phase] || evt.phase; - if (evt.total) { - status(`${label} … ${evt.current}/${evt.total}`); - el('status-right').textContent = Math.round((evt.current / evt.total) * 100) + '%'; - } else { - status(evt.detail || label + ' …'); - el('status-right').textContent = ''; - appendConsole('[' + label + '] ' + (evt.detail || '') + '\n'); - } -} - -// ---------- Konsole ---------- -function classifyLine(l) { - if (/\/(ERROR|FATAL|SEVERE)\]/.test(l) || /Exception(:| in )|^\s+at\s|Caused by:/.test(l)) return 'log-error'; - if (/\/WARN(ING)?\]/.test(l) || /\bWARN\b/.test(l)) return 'log-warn'; - if (/\[Pre-Launch\]|\[Post-Exit\]|=== Start:|\[Prozess beendet/.test(l)) return 'log-info'; - return ''; -} -function appendLineEl(out, line) { - const div = document.createElement('div'); - div.className = 'log-line ' + classifyLine(line); - div.textContent = line; - out.appendChild(div); -} -let consoleMaxChars = 300000; // aus den Einstellungen abgeleitet (Zeilen x ~80 Zeichen) -function appendConsole(text) { - consoleBuffer += text; - if (consoleBuffer.length > consoleMaxChars) consoleBuffer = consoleBuffer.slice(-Math.round(consoleMaxChars * 0.75)); - consolePending += text; - const lines = consolePending.split('\n'); - consolePending = lines.pop(); - const out = el('console-out'); - if (!el('modal-console').classList.contains('hidden')) { - for (const line of lines) appendLineEl(out, line); - out.scrollTop = out.scrollHeight; - } -} -function openConsole() { - if (POPOUT !== 'console') { window.api.popoutOpen('console'); return; } - const out = el('console-out'); - out.innerHTML = ''; - const lines = (consoleBuffer || '(noch keine Ausgabe)').split('\n'); - for (const line of lines) appendLineEl(out, line); - consolePending = ''; - show('modal-console'); - out.scrollTop = out.scrollHeight; -} - -// ---------- Welten & Ressourcenpakete ---------- -async function openContent() { - if (POPOUT !== 'content') { window.api.popoutOpen('content', { instanceId: currentDetailId }); return; } - const inst = await window.api.getInstance(currentDetailId); - el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete – ' + inst.name; - await renderContentLists(); - show('modal-content'); -} -async function renderContentLists() { - renderContentList('content-worlds', await window.api.worlds(currentDetailId), 'saves', '🌍'); - renderContentList('content-rp', await window.api.resourcepacks(currentDetailId), 'resourcepacks', '🎨'); - renderContentList('content-dp', await window.api.datapacks(currentDetailId), 'datapacks', '📜'); -} -// ---------- Screenshots ---------- -let screenSelected = new Set(); - -function updateScreensSelectionUi() { - const n = screenSelected.size; - const btn = el('screens-delete-sel'); - if (btn) { - btn.disabled = n === 0; - btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen'; - } - const hint = el('screens-sel-hint'); - if (hint) hint.textContent = n ? (n + ' ausgewählt – Doppelklick öffnet das Bild') : 'Klick auf Vorschaubild zum Auswählen · Doppelklick öffnet'; - document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => { - const name = t.dataset.name; - const on = name && screenSelected.has(name); - t.classList.toggle('selected', !!on); - const cb = t.querySelector('.screen-check'); - if (cb) cb.checked = !!on; - }); -} - -// ---------- Spielaufzeichnung ---------- -let mcVideoStream = null; -let mcSearchTimer = null; -let mcSearchElapsed = 0; -let mcCurrentSource = null; // zuletzt erfasste Fensterquelle - für ein Neu-Erfassen bei Auflösungswechsel - -// Auflösung/Qualität/Bildrate der Aufnahme (bleiben über mehrere Aufnahmen in -// diesem Fenster erhalten, wie schon bei den Audioeinstellungen) -let mcResolution = 'native'; -let mcQuality = 'medium'; -let mcFramerate = 30; -let mcActualSize = null; // {width,height} der zuletzt tatsächlich erfassten Auflösung -let mcMicSelectedIds = []; // gespeicherte Mikrofon-Auswahl (deviceIds) - für den Aufbau der Mikrofonliste -let mcCursorAusgeblendet = false; // true, wenn die zuletzt erfasste Vorschau ohne Mauszeiger läuft -let mcRohStream = null; // native, unskalierte Bildschirm-Erfassung, falls mcVideoStream ein daraus erzeugter Canvas-Stream ist -let mcCanvasRAF = null; // requestAnimationFrame-Kennung der Zuschnitt-/Skalierungs-Zeichenschleife -let mcCanvasVideoEl = null; // verstecktes