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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Noch keine Instanzen
-
Lege deine erste Minecraft-Instanz an – eigene Version, eigene Mods.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Neue Instanz
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- OptiFine einrichten
-
- OptiFine bietet kein automatisches Download-API (Downloads laufen bei OptiFine bewusst über eine Werbeseite).
- Lade den Installer zuerst selbst von optifine.net herunter, wähle ihn dann hier aus – AeroMC bereitet
- die passende Minecraft-Version vor und startet den echten OptiFine-Installer. Wähle darin als Zielordner den
- automatisch in die Zwischenablage kopierten Pfad, klicke „Install" und bestätige danach hier.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Einstellungen
-
-
-
-
-
-
-
-
Allgemein
-
Globale Launcher-Optionen, Instanzverwaltung und sichtbare Bereiche.
-
-
-
-
Oberfläche
-
-
-
-
-
-
Instanzen
-
-
-
-
-
Bereiche
-
- Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.
-
-
-
-
Benachrichtigungen
-
- Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.
-
-
-
-
Freundesystem
-
- Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.
-
-
-
-
Autostart
-
- Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden – über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.
-
-
-
-
-
-
-
Darstellung
-
Optik der Oberfläche und visuelle Extras für die Instanzansicht.
-
-
-
-
Icons
-
-
-
-
-
Skin-Hintergrund
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Spiel
-
Standardwerte für neue und bestehende Instanzen ohne eigene Überschreibung.
-
-
-
-
Speicher
-
-
-
-
- Gilt für alle Instanzen ohne eigene RAM-Einstellung.
-
-
-
-
Fenster
-
-
-
-
-
-
-
-
-
-
-
-
Start & Java
-
Runtime-Auswahl, Startverhalten und globale Befehle für alle Instanzen.
-
-
-
-
Java
-
-
- Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.
-
-
-
-
Konsole
-
-
-
-
-
-
-
-
Globale Befehle
-
-
- Instanzspezifische Befehle aus dem Bearbeiten-Dialog haben Vorrang.
-
-
-
-
Externe Programme
-
- Startet Java über dieses Programm. Java-Pfad und Argumente werden automatisch angehängt.
-
-
-
-
-
-
-
Netzwerk
-
Verbindungsoptionen für Downloads und externe Dienste.
-
-
-
-
Proxy
-
-
-
-
-
-
-
-
-
-
-
-
-
CurseForge
-
-
-
-
-
- Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.
-
-
-
-
-
-
-
Konten
-
Microsoft-Konten verwalten und das aktive Spielkonto wechseln.
-
-
-
-
Verknüpfte Konten
-
-
-
-
-
-
-
-
-
-
-
-
Backup
-
Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.
-
-
-
-
Globales Backup
- Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.
-
-
- Noch kein Backup erstellt.
-
-
-
-
0 %
-
-
-
-
-
Automatisch sichern
-
-
-
-
-
-
- Eine Sicherung umfasst alle Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.
- Noch keine automatische Sicherung.
-
-
-
-
Wiederherstellen
- Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.
-
-
Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten – laufende Spiele behalten ihr Konto.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Konsole / Log
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Welten & Ressourcenpakete
-
-
-
-
-
-
Welten
-
-
-
-
Ressourcenpakete
-
-
-
-
Datenpakete
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Spielaufzeichnung
-
-
-
-
-
Suche Spielfenster …
-
-
-
-
-
-
-
-
- Video: Nativ · Mittel
-
-
-
- Audio: Systemton
-
-
-
-
-
- Aufnahme läuft · 00:00
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Video-Einstellungen
-
-
-
-
-
-
-
-
-
Gilt erst für die nächste Aufnahme – während einer laufenden Aufnahme nicht änderbar.
-
-
-
-
-
-
-
-
-
-
-
-
-
Audioquellen wählen
-
-
-
-
Mehrfachauswahl möglich – alle angehakten Quellen werden zu einer Tonspur gemischt, Lautstärke je Quelle einstellbar.
-
-
-
-
-
-
-
-
-
Kein laufender Minecraft-Prozess erkannt – bitte über den Launcher starten.
-
-
Systemton ist immer der komplette Mix (Spiel, Discord, Musik, Benachrichtigungen usw.). "Nur Minecraft" nimmt ausschließlich den Ton des Spiels auf (z. B. wenn nebenbei Musik läuft, die nicht mit rein soll) – beides lässt sich auch kombiniert mit einem Mikrofon anhaken.
-
-
-
-
-
-
-
-
-
-
-
-
-
Screenshots
-
-
-
-
-
-
-
-
-
-
-
-
-
- Klick auf Vorschaubild zum Auswählen
-
-
-
-
-
-
-
-
-
- Klick auf Vorschaubild zum Auswählen, Doppelklick zum Abspielen
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Freunde
-
-
-
-
Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Modpack aktualisieren
-
-
-
-
Suche …
-
-
Welten, Bildschirmfotos und deine Optionen bleiben unangetastet. Mods und Konfigurationsdateien des Pakets werden ersetzt; was die neue Fassung nicht mehr enthält, wird entfernt.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Server
-
-
-
-
Diese Liste steht in der Datei servers.dat der Instanz und erscheint im Spiel unter „Mehrspieler". Änderungen wirken erst beim nächsten Start.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Server übertragen
-
-
-
-
–
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Skin ändern
-
-
-
-
–
-
-
-
Aktuell
-
-
- wird geladen …
-
-
-
-
Neu
-
-
- noch keine Datei gewählt
-
-
-
- Mit der Maus ziehen dreht die Figur, Mausrad zoomt. Die Armform wirkt sich auf die Darstellung aus.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Logdateien
-
-
-
-
-
-
-
-
Wähle eine Datei …
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Minecraft ist abgestürzt
-
-
-
-
–
-
-
- Vollständigen Bericht anzeigen
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Lizenz
-
-
-
-
Lade …
-
-
-
-
-
-
-
-
-
-
-
-
Update verfügbar
-
-
-
-
- –
- →
- –
-
-
Update-Kanal: –
-
-
-
-
Wird heruntergeladen …
-
-
Der Launcher lädt das Update herunter und startet die Installation automatisch.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Importieren
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Importiert eine einzelne Instanz aus einem ZIP-Archiv.
-
Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.
-
-
-
-
Modpack aus dem Katalog installieren oder als Datei importieren.
-
Katalog-Suche läuft über Modrinth oder CurseForge. Für CurseForge wird ein eigener API-Key in den Netzwerk-Einstellungen gebraucht (die Weiterleitung reicht nur für den Datei-Import). Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json).
-
-
-
-
-
-
-
-
-
-
-
Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Modpack direkt von der Technic-Plattform installieren oder eine bereits vorhandene Technic-Installation importieren.
-
-
-
-
-
-
Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.
-
-
-
-
-
-
-
-
-
-
-
-
-
Minecraft-Version und Mod-Loader werden nach bestem Wissen aus dem Pack erkannt – bitte nach dem Import kurz prüfen, falls ein Pack ungewöhnlich aufgebaut ist.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 🧱
-
-
Minecraft Bedrock
-
Wird geprüft …
-
-
-
-
-
-
- ⚠ Bedrock ist nicht installiert. Es wird über den Microsoft Store bezogen – der Launcher kann es nicht selbst installieren.
-
Lege deine erste Minecraft-Instanz an – eigene Version, eigene Mods.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Neue Instanz
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OptiFine einrichten
+
+ OptiFine bietet kein automatisches Download-API (Downloads laufen bei OptiFine bewusst über eine Werbeseite).
+ Lade den Installer zuerst selbst von optifine.net herunter, wähle ihn dann hier aus – AeroMC bereitet
+ die passende Minecraft-Version vor und startet den echten OptiFine-Installer. Wähle darin als Zielordner den
+ automatisch in die Zwischenablage kopierten Pfad, klicke „Install" und bestätige danach hier.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Einstellungen
+
+
+
+
+
+
+
+
Allgemein
+
Globale Launcher-Optionen, Instanzverwaltung und sichtbare Bereiche.
+
+
+
+
Oberfläche
+
+
+
+
+
+
Instanzen
+
+
+
+
+
Bereiche
+
+ Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.
+
+
+
+
Benachrichtigungen
+
+ Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.
+
+
+
+
Freundesystem
+
+ Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.
+
+ Ausgeschaltet sehen Freunde nur, dass du spielst – nicht wo. „Beitreten“ ist dann bei dir nicht möglich, Einladungen kannst du aber weiterhin verschicken.
+
+
+
+
Autostart
+
+ Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden – über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.
+
+
+
+
+
+
+
Darstellung
+
Optik der Oberfläche und visuelle Extras für die Instanzansicht.
+
+
+
+
Icons
+
+
+
+
+
Skin-Hintergrund
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Spiel
+
Standardwerte für neue und bestehende Instanzen ohne eigene Überschreibung.
+
+
+
+
Speicher
+
+
+
+
+ Gilt für alle Instanzen ohne eigene RAM-Einstellung.
+
+
+
+
Fenster
+
+
+
+
+
+
+
+
+
Mods
+
+ Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.
+
+
+
+
+
+
+
Start & Java
+
Runtime-Auswahl, Startverhalten und globale Befehle für alle Instanzen.
+
+
+
+
Java
+
+
+ Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.
+
+
+
+
Konsole
+
+
+
+
+
+
+
+
Globale Befehle
+
+
+ Instanzspezifische Befehle aus dem Bearbeiten-Dialog haben Vorrang.
+
+
+
+
Externe Programme
+
+ Startet Java über dieses Programm. Java-Pfad und Argumente werden automatisch angehängt.
+
+
+
+
+
+
+
Netzwerk
+
Verbindungsoptionen für Downloads und externe Dienste.
+
+
+
+
Proxy
+
+
+
+
+
+
+
+
+
+
+
+
+
CurseForge
+
+
+
+
+
+ Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.
+
+
+
+
+
+
+
Konten
+
Microsoft-Konten verwalten und das aktive Spielkonto wechseln.
+
+
+
+
Verknüpfte Konten
+
+
+
+
+
+
+
+
+
+
+
+
Backup
+
Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.
+
+
+
+
Globales Backup
+ Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.
+
+
+ Noch kein Backup erstellt.
+
+
+
+
0 %
+
+
+
+
+
Automatisch sichern
+
+
+
+
+
+
+ Eine Sicherung umfasst alle Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.
+ Noch keine automatische Sicherung.
+
+
+
+
Wiederherstellen
+ Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.
+
+
Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten – laufende Spiele behalten ihr Konto.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Konsole / Log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welten & Ressourcenpakete
+
+
+
+
+
+
Welten
+
+
+
+
Ressourcenpakete
+
+
+
+
Datenpakete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Spielaufzeichnung
+
+
+
+
+
Suche Spielfenster …
+
+
+
+
+
+
+
+
+ Video: Nativ · Mittel
+
+
+
+ Audio: Systemton
+
+
+
+
+
+
+ Speichert auf Tastendruck die letzten Sekunden – auch bei geschlossenem Fenster.
+
+
+
+
+
+ Aufnahme läuft · 00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Video-Einstellungen
+
+
+
+
+
+
+
+
+
Gilt erst für die nächste Aufnahme – während einer laufenden Aufnahme nicht änderbar.
+
+
+
+
+
+
+
+
+
+
+
+
+
Audioquellen wählen
+
+
+
+
Mehrfachauswahl möglich – alle angehakten Quellen werden zu einer Tonspur gemischt, Lautstärke je Quelle einstellbar.
+
+
+
+
+
+
+
+
+
Kein laufender Minecraft-Prozess erkannt – bitte über den Launcher starten.
+
+
Systemton ist immer der komplette Mix (Spiel, Discord, Musik, Benachrichtigungen usw.). "Nur Minecraft" nimmt ausschließlich den Ton des Spiels auf (z. B. wenn nebenbei Musik läuft, die nicht mit rein soll) – beides lässt sich auch kombiniert mit einem Mikrofon anhaken.
+
+
+
+
+
+
+
+
+
+
+
+
+
Screenshots
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Klick auf Vorschaubild zum Auswählen
+
+
+
+
+
+
+
+
+
+ Klick auf Vorschaubild zum Auswählen, Doppelklick zum Abspielen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Spielzeit-Statistik
+
+
+
+
+
Spielzeit pro Instanz
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Freunde
+
+
+
+
Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Modpack aktualisieren
+
+
+
+
Suche …
+
+
Welten, Bildschirmfotos und deine Optionen bleiben unangetastet. Mods und Konfigurationsdateien des Pakets werden ersetzt; was die neue Fassung nicht mehr enthält, wird entfernt.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Server
+
+
+
+
Diese Liste steht in der Datei servers.dat der Instanz und erscheint im Spiel unter „Mehrspieler". Änderungen wirken erst beim nächsten Start.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Server übertragen
+
+
+
+
–
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Skin ändern
+
+
+
+
–
+
+
+
Aktuell
+
+
+ wird geladen …
+
+
+
+
Neu
+
+
+ noch keine Datei gewählt
+
+
+
+ Mit der Maus ziehen dreht die Figur, Mausrad zoomt. Die Armform wirkt sich auf die Darstellung aus.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Logdateien
+
+
+
+
+
+
+
+
Wähle eine Datei …
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Minecraft ist abgestürzt
+
+
+
+
–
+
+
+ Vollständigen Bericht anzeigen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lizenz
+
+
+
+
Lade …
+
+
+
+
+
+
+
+
+
+
+
+
Update verfügbar
+
+
+
+
+ –
+ →
+ –
+
+
Update-Kanal: –
+
+
+
+
Wird heruntergeladen …
+
+
Der Launcher lädt das Update herunter und startet die Installation automatisch.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Importieren
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Importiert eine einzelne Instanz aus einem ZIP-Archiv.
+
Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.
+
+
+
+
Modpack aus dem Katalog installieren oder als Datei importieren.
+
Katalog-Suche läuft über Modrinth oder CurseForge. Für CurseForge wird ein eigener API-Key in den Netzwerk-Einstellungen gebraucht (die Weiterleitung reicht nur für den Datei-Import). Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json).
+
+
+
+
+
+
+
+
+
+
+
Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Modpack direkt von der Technic-Plattform installieren oder eine bereits vorhandene Technic-Installation importieren.
+
+
+
+
+
+
Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.
+
+
+
+
+
+
+
+
+
+
+
+
+
Minecraft-Version und Mod-Loader werden nach bestem Wissen aus dem Pack erkannt – bitte nach dem Import kurz prüfen, falls ein Pack ungewöhnlich aufgebaut ist.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🧱
+
+
Minecraft Bedrock
+
Wird geprüft …
+
+
+
+
+
+
+ ⚠ Bedrock ist nicht installiert. Es wird über den Microsoft Store bezogen – der Launcher kann es nicht selbst installieren.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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(`
+