5 Commits
1.0.5 ... main

Author SHA1 Message Date
Git Manager GUI
fc57dfa186 Upload via GUI (42 Dateien) 2026-08-06 21:26:45 +02:00
Git Manager GUI
bafad25059 Upload via GUI (43 Dateien) 2026-08-05 09:04:48 +02:00
ec4992c5c7 README.md aktualisiert 2026-08-05 07:03:48 +00:00
3e28b50f1b README.md aktualisiert 2026-08-03 19:10:09 +00:00
Git Manager GUI
54d248133c Upload via GUI (39 Dateien) 2026-08-03 21:00:24 +02:00
16 changed files with 1942 additions and 158 deletions

103
README.md
View File

@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
Aktueller Stand: Version 1.0.4
Aktueller Stand: Version 1.0.6
</div>
@@ -21,23 +21,30 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Beliebig viele getrennte Minecraft-Instanzen
- Unterstützung für Vanilla, Fabric, Quilt, Forge und NeoForge
- Versionsliste mit Filter: Releases, Snapshots oder alle
- Modrinth-Integration für **Mods, Ressourcenpakete und Shader** (inkl. Pflicht-Abhängigkeiten und Update-Prüfung)
- Modrinth- **und CurseForge**-Integration für **Mods, Ressourcenpakete und Shader** (Suche wahlweise über beide Quellen, Installation inkl. rekursiver Pflicht-Abhängigkeiten, Update-Prüfung samt Sammel-Update)
- Modpacks direkt von Modrinth **oder von der Technic-Plattform** durchsuchen und mit einem Klick als neue Instanz installieren ohne vorherigen manuellen Download
- Microsoft-Login mit mehreren Konten
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge sowie MultiMC/Prism
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, MultiMC/Prism sowie Technic
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen
- Export, Kopieren, Gruppieren, Löschen und Desktop-Verknüpfung pro Instanz
- Mehrfachauswahl mit Strg-Klick für Sammellöschen
- Globales Backup inkl. automatischer Zeitplan-Sicherungen
- Welten, Ressourcenpakete, Screenshots, Serverliste und Logdateien verwalten
- Mehrfachauswahl mit Strg-Klick für Sammellöschen, Tastenkürzel für die wichtigsten Aktionen (Strg+F, Strg+N, Entf)
- Mods/Modpacks/Instanzen auch per Drag & Drop aufs Fenster ziehen
- Mods zusätzlich als eigene Datei hinzufügen, wenn sie nicht über Modrinth/CurseForge zu finden sind
- Warnt beim Entfernen eines Mods, falls ein anderer installierter Mod ihn noch als Pflicht-Abhängigkeit braucht
- Globales Backup inkl. automatischer Zeitplan-Sicherungen, dazu Schnellsicherung einzelner Welten
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
- Serverliste mit Live-Status (Spieleranzahl, MOTD, Version) und Übernahme einzelner Einträge in andere Instanzen
- Online- und Offline-Start
- Eingebaute Konsole/Log-Ansicht
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, einstellbare Deckkraft)
- Eigene Icons (inkl. eigenes Bild) und Notizen pro Instanz
- Eingebaute Konsole/Log-Ansicht inkl. Absturzberichte pro Instanz
- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird
- Eigenen Minecraft-Skin direkt im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, einstellbare Deckkraft) wechselt bei mehreren angemeldeten Konten optional automatisch durch
- Eigene Icons pro Instanz vorgefertigte Symbole, direkt aus Minecraft extrahierte Block-/Item-Icons oder ein eigenes Bild plus Notizen
- Java pro Instanz oder global, inkl. automatischem Download fehlender Versionen (Eclipse Temurin)
- RAM-, JVM-, Pre-Launch- und Post-Exit-Einstellungen (global und pro Instanz)
- RAM-, JVM-, Pre-Launch- und Post-Exit-Einstellungen (global und pro Instanz), inkl. Ein-Klick-Knopf für empfohlene GC-Flags
- Optionaler Wrapper-Befehl und HTTP-Proxy für Downloads
- Bedrock-Bereich für vorhandene Bedrock-Installation unter Windows
- Update-Funktion für neue Launcher-Versionen
- Bedrock-Bereich für vorhandene Bedrock-Installation unter Windows, inkl. Installation zusätzlicher Add-ons
- Update-Suche läuft automatisch beim Start, dazu manuelle Prüfung in den Einstellungen
- Design Hell/Dunkel und Sortierung der Instanzen
## Installation
@@ -71,18 +78,18 @@ Jede Instanz hat:
- Namen und optionale Gruppe
- Minecraft-Version und optionalen Mod-Loader
- optionales Icon (vorgegeben oder eigenes Bild) und Notizen
- eigene Mods, Ressourcenpakete, Shader, Welten, Screenshots und Serverliste
- eigene Java-, RAM-, JVM-, Pre-Launch- und Post-Exit-Einstellungen (leer = globale Vorgabe)
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
- eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots und Serverliste
- eigene Java-, RAM-, JVM-, Pre-Launch- und Post-Exit-Einstellungen (leer = globale Vorgabe), inkl. Knopf für empfohlene GC-Flags bei den JVM-Argumenten
Über die Seitenleiste einer ausgewählten Instanz erreichst du u. a.:
- Starten / Offline starten
- Instanz und Notizen bearbeiten
- Mods, Ressourcenpakete und Shader
- Welten und Bildschirmfotos
- Server verwalten
- Logdateien ansehen
- Welten (inkl. Schnellsicherung einzelner Welten), Datenpakete und Bildschirmfotos
- Server verwalten (Live-Status, Übernahme eines Eintrags in andere Instanzen)
- Logdateien und Absturzberichte ansehen
- Minecraft-, Konfigurations- und Instanz-Ordner öffnen
- Desktop-Verknüpfung erstellen
- Exportieren, Kopieren, Gruppe ändern, Löschen
@@ -90,6 +97,17 @@ Jede Instanz hat:
In der Instanzliste: Suche, Sortierung und **Strg + Klick** für Mehrfachauswahl.
### Tastenkürzel
| Taste | Wirkung |
|---|---|
| Strg+F | Suche fokussieren |
| Strg+N | Neue Instanz anlegen |
| Entf | Markierte Instanz(en) löschen |
| Esc | Aktuellen Dialog schließen |
Greifen nicht, während ein Textfeld fokussiert ist oder ein Dialog mit eigener Suche offen ist.
## Import
Über **Importieren**:
@@ -97,26 +115,43 @@ In der Instanzliste: Suche, Sortierung und **Strg + Klick** für Mehrfachauswahl
| Quelle | Typische Nutzung |
|---|---|
| ZIP-Archiv | AeroMC-Export (`instance.json`) sowie einzelne MultiMC/Prism-Instanzen (auch gezippt) |
| Modpack | Modrinth `.mrpack` |
| Modpack | Modrinth-Katalog direkt durchsuchen und installieren, oder Datei importieren: `.mrpack` sowie CurseForge-ZIP (`manifest.json`) |
| Minecraft Launcher | Profile aus dem offiziellen Launcher |
| CurseForge | Instanzen aus dem CurseForge-Ordner |
| MultiMC / Prism | Vorhandene MultiMC- oder Prism-Instanzen |
| Technic | Technic-Katalog direkt durchsuchen und installieren, oder eine vorhandene Technic-Installation importieren (`%APPDATA%\.technic\modpacks`) |
Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache.
Im Reiter **Modpack** desselben Dialogs lässt sich zusätzlich direkt im Modrinth-Katalog suchen und ein Modpack mit einem Klick installieren ganz ohne vorherigen manuellen Download der `.mrpack`-Datei. Im Reiter **Technic** gibt es dasselbe für den Technic-Katalog.
Alternativ: `.jar`-, `.mrpack`- oder `.zip`-Dateien direkt aufs Launcher-Fenster ziehen. AeroMC erkennt anhand des Inhalts, ob es sich um einen Mod (landet in der gerade offenen/ausgewählten Instanz), ein Modpack oder einen Instanz-Export handelt.
## Mods, Ressourcenpakete und Shader
Im Inhalts-Dialog einer Instanz:
- Katalog durchsuchen (Sortierung, Kategorien)
- Installation inkl. Abhängigkeiten
- Installierte Einträge aktivieren, deaktivieren, entfernen
- Katalog durchsuchen (Quelle Modrinth oder CurseForge, Sortierung, Kategorien)
- Installation inkl. **automatischer** Installation aller Pflicht-Abhängigkeiten (rekursiv, auch quellübergreifend)
- Eigene Datei hinzufügen (`.jar`/`.zip`) für Mods, die nicht über Modrinth/CurseForge zu finden sind, oder per Drag & Drop aufs Fenster ziehen
- Installierte Einträge aktivieren, deaktivieren, entfernen beim Entfernen warnt AeroMC, falls ein anderer installierter Mod das noch als Pflicht-Abhängigkeit braucht
- Nach Updates suchen und alle aktualisieren
- Ordner der jeweiligen Inhaltsart öffnen
Ressourcenpakete und Shader funktionieren auch in Vanilla-Instanzen. Mods benötigen einen Mod-Loader.
Ressourcenpakete und Shader funktionieren auch in Vanilla-Instanzen. Mods benötigen einen Mod-Loader. Für CurseForge-Inhalte wird ein eigener API-Key oder eine Weiterleitungs-Adresse benötigt (siehe **Einstellungen → Netzwerk**).
## Skin ändern
Über **Konten → Skin ändern…** lässt sich der Skin des aktiven Microsoft-Kontos direkt im Launcher ändern:
- PNG-Datei auswählen, sofortige Prüfung auf gültige Maße
- Live-Vorschau am 3D-Modell vor dem Anwenden
- Modell wählen: klassisch oder schlank (slim)
- Auf einen früheren Standard-Skin zurücksetzen
Die Änderung wird direkt bei Mojang für das angemeldete Konto hinterlegt und gilt somit auch außerhalb von AeroMC.
## Backup und Wiederherstellung
@@ -138,10 +173,11 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
- Instanzen sortieren (nach letztem Start / nach Name)
- Instanz-Verzeichnis
- Bedrock-Bereich anzeigen
- Windows-Benachrichtigung bei fertigem Hintergrund-Download/-Backup (nur wenn das Fenster nicht im Vordergrund ist)
### Darstellung
- Block-Symbole räumlich (3D)
- Skin-Hintergrund: Quelle, Pose, Deckkraft
- Skin-Hintergrund: Quelle, Pose, Deckkraft, optional automatischer Wechsel zwischen mehreren angemeldeten Konten (mit einstellbarem Zeitabstand)
### Spiel
- Standard-RAM (min./max.)
@@ -167,11 +203,12 @@ Ohne eigenen Key und ohne Weiterleitung funktionieren Modrinth-Inhalte weiterhin
- Manuell, Zeitplan und Wiederherstellung
### Info
- Version und manuelle Update-Prüfung
- Version, Autor und Links
- Update-Suche läuft automatisch kurz nach dem Start (Status erscheint grün, wenn alles aktuell ist) sowie jederzeit manuell per Knopf
## Bedrock
Der Bedrock-Bereich startet die vorhandene Windows-Store-Version und erleichtert den Zugriff auf Welten, Pakete, Add-ons und Ordner. AeroMC installiert Bedrock nicht selbst. Fehlt die Installation, wird der Bereich automatisch ausgeblendet.
Der Bedrock-Bereich startet die vorhandene Windows-Store-Version und erleichtert den Zugriff auf Welten, Pakete, Add-ons und Ordner. Zusätzliche Add-on-Dateien lassen sich direkt über AeroMC installieren. AeroMC installiert Bedrock selbst (die App aus dem Microsoft Store) nicht. Fehlt die Installation, wird der Bereich automatisch ausgeblendet.
## Konsole, Logs und Crash-Reports
@@ -190,9 +227,17 @@ Nutzerdaten liegen lokal unter `%APPDATA%\aeromc`.
## Rechtliches
AeroMC ist kein offizielles Produkt von Mojang oder Microsoft.
AeroMC ist kein offizielles Produkt von Mojang oder Microsoft.
Minecraft ist eine Marke von Mojang AB.
## Lizenz
### Urheberrecht & Nutzungsbedingungen
MIT
AeroMC ist urheberrechtlich geschützt. © M_Viper Alle Rechte vorbehalten.
- ❌ Die Weitergabe dieses Launchers an Dritte ist strengstens untersagt
- ❌ Das Verändern, Dekompilieren oder Bearbeiten des Codes ist nicht erlaubt
- ❌ Eine Veröffentlichung auch in veränderter Form ist verboten
- ❌ Der Verkauf oder die kommerzielle Nutzung ist nicht gestattet
- ✅ Die Nutzung ist ausschließlich für den privaten, nicht-kommerziellen Gebrauch erlaubt
Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich belangt werden.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "aeromc",
"version": "1.0.4-beta",
"version": "1.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aeromc",
"version": "1.0.4-beta",
"version": "1.0.7",
"license": "MIT",
"dependencies": {
"archiver": "^7.0.1",

View File

@@ -1,15 +1,12 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.0.4",
"version": "1.0.7",
"description": "AeroMC Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
"main": "src/main.js",
"author": "M_Viper",
"repository": {
"type": "git",
"url": "https://git.viper.ipv64.net"
},
"license": "MIT",
"scripts": {
"start": "electron .",

View File

@@ -14,7 +14,12 @@ const path = require('path');
const { execFile, spawn } = require('child_process');
const PKG_FAMILY = 'Microsoft.MinecraftUWP_8wekyb3d8bbwe';
const APP_ID = PKG_FAMILY + '!App';
// Der Teil nach "!" (Application Id) ist NICHT stabil Microsoft hat ihn
// schon mindestens einmal geändert (früher "App", inzwischen "Game").
// Deshalb zur Laufzeit über Get-StartApps auflösen statt fest zu verdrahten;
// dieser Wert dient nur als letzter Rückfallwert, falls die Auflösung
// fehlschlägt (z. B. PowerShell blockiert).
const FALLBACK_APP_ID = PKG_FAMILY + '!Game';
function comMojangDir() {
return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY,
@@ -49,30 +54,87 @@ async function detect() {
// Start
// ---------------------------------------------------------------------------
let cachedAppId = null;
/*
* Ermittelt die aktuell gültige AUMID über Get-StartApps das ist derselbe
* Weg, den auch das Startmenü nutzt, und bildet daher immer die Application
* Id ab, die Windows gerade tatsächlich zum Starten akzeptiert.
*/
async function resolveAppId() {
if (cachedAppId) return cachedAppId;
const out = await runPwsh(
`(Get-StartApps | Where-Object { $_.AppID -like '${PKG_FAMILY}*' } `
+ `| Select-Object -First 1 -ExpandProperty AppID)`);
cachedAppId = out || FALLBACK_APP_ID;
return cachedAppId;
}
/*
* Startet Bedrock direkt.
* Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen sonst
* öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments.
*/
function launch() {
const target = 'shell:appsFolder\\' + APP_ID;
async function launch() {
const appId = await resolveAppId();
const target = 'shell:appsFolder\\' + appId;
// Wichtig: Es darf pro Klick nur EINE Aktivierung tatsächlich lostreten.
// Die App-Aktivierung lässt sich nicht abbrechen wenn wir bei einer
// Verzögerung (Xbox-Dienste, Kaltstart) automatisch einen zweiten Weg
// probieren, laufen im Zweifel beide an und Bedrock öffnet sich doppelt.
// Die anderen Wege dienen daher nur als Rückfall, falls das Spawnen
// selbst scheitert (Programm nicht gefunden o. Ä.), nicht als
// Reaktion auf einen bloß langsamen Start.
const versuche = [
// 1) genau der Aufruf, der sich unter Windows als zuverlässig erwiesen hat
// 1) explorer direkt, ohne zusätzliche Anführungszeichen entspricht
// exakt dem manuellen "explorer.exe shell:appsFolder\...", der
// nachweislich zuverlässig funktioniert
['explorer.exe', [target], { windowsVerbatimArguments: true }],
// 2) über die Eingabeaufforderung
['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }],
// 3) Rückfallweg über eine versteckte PowerShell aus einem
// windowless/detached Kindprozess heraus offenbar nicht überall
// zuverlässig, daher nur letzter Versuch
['powershell', ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
`Start-Process explorer.exe -ArgumentList '${target}'`], {}],
// 2) explorer direkt, ohne zusätzliche Anführungszeichen
['explorer.exe', [target], { windowsVerbatimArguments: true }],
// 3) über die Eingabeaufforderung
['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }],
];
let methode = null;
for (const [cmd, args, extra] of versuche) {
try {
const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra));
p.unref();
return { ok: true, method: cmd };
} catch { /* nächsten Weg versuchen */ }
methode = cmd;
break; // nur diesen einen Weg tatsächlich auslösen
} catch { /* Spawnen selbst fehlgeschlagen nächsten Weg versuchen */ }
}
if (!methode) return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' };
// Aktivierung kann dauern (v. a. beim ersten Start nach längerer Pause)
// hier nur zur ehrlichen Rückmeldung abwarten, OHNE bei Zeitüberschreitung
// einen weiteren Versuch zu starten (siehe Hinweis oben).
const bestaetigt = await waitForBedrockProcess(15000);
return { ok: true, method: methode, confirmed: bestaetigt };
}
function isBedrockRunning() {
return new Promise((resolve) => {
execFile('tasklist', ['/FI', 'IMAGENAME eq Minecraft.Windows.exe', '/FO', 'CSV', '/NH'],
{ windowsHide: true, timeout: 5000 },
(err, stdout) => resolve(!err && /Minecraft\.Windows\.exe/i.test(String(stdout || ''))));
});
}
function warten(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}
async function waitForBedrockProcess(timeoutMs) {
const start = Date.now();
// sofort prüfen (falls schon ein Fenster lief) und danach im Kurztakt
for (;;) {
if (await isBedrockRunning()) return true;
if (Date.now() - start >= timeoutMs) return false;
await warten(500);
}
return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' };
}
// ---------------------------------------------------------------------------

View File

@@ -47,11 +47,18 @@ function neuesterBericht(gameDir, seit) {
/*
* Zieht die aussagekräftigen Zeilen aus einem Bericht.
* -> { beschreibung, fehler, verdaechtigeMods, verlauf: [] }
* -> { beschreibung, fehler, verdaechtigeMods, diagnose, empfehlung, verlauf: [] }
*/
function auswerten(text) {
const zeilen = String(text || '').split(/\r?\n/);
const befund = { beschreibung: '', fehler: '', verdaechtigeMods: '', verlauf: [] };
const befund = {
beschreibung: '',
fehler: '',
verdaechtigeMods: '',
diagnose: '',
empfehlung: '',
verlauf: [],
};
for (let i = 0; i < zeilen.length; i++) {
const z = zeilen[i];
@@ -86,6 +93,22 @@ function auswerten(text) {
const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim()));
if (treffer) befund.fehler = treffer.trim();
}
const volltext = zeilen.join('\n');
const shutdownWatchdog = /Description:\s*Client shutdown from post-main/i.test(volltext)
&& /ClientShutdownWatchdog/i.test(volltext);
const webMapJetty = /journeymap_webmap:\s*JourneyMap WebMap\b/i.test(volltext)
&& /JettyServerThreadPool/i.test(volltext)
&& /ServerConnector.*\{0\.0\.0\.0:\d+\}/i.test(volltext);
if (shutdownWatchdog && webMapJetty) {
const version = volltext.match(/journeymap_webmap:\s*JourneyMap WebMap\s+([^\s\r\n]+)/i);
const name = `JourneyMap WebMap${version ? ` ${version[1]}` : ''}`;
befund.verdaechtigeMods = name;
befund.diagnose = `${name} hat seinen eingebetteten Jetty-Webserver beim Beenden nicht gestoppt. `
+ 'Dessen nicht beendete Server-Threads haben den Minecraft-Shutdown-Watchdog ausgelöst.';
befund.empfehlung = 'AeroMC beendet einen nach dem regulären Client-Shutdown verbliebenen Java-Prozess nun '
+ 'nach einer kurzen Schonfrist automatisch. JourneyMap WebMap kann installiert und aktiviert bleiben.';
}
return befund;
}

View File

@@ -10,7 +10,7 @@
<body>
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
<header class="toolbar">
<button id="btn-new" class="tbtn" data-ic="plus">Instanz hinzufügen</button>
<button id="btn-new" class="tbtn" data-ic="plus" title="Neue Instanz (Strg+N)">Instanz hinzufügen</button>
<button id="btn-import" class="tbtn" data-ic="import" title="Instanzen importieren">Importieren</button>
<button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button>
<span class="tbsep"></span>
@@ -19,7 +19,7 @@
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
<span class="tbgrow"></span>
<div class="search-wrap">
<input id="search" type="text" placeholder="Suchen…" autocomplete="off" />
<input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" />
</div>
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
@@ -61,7 +61,7 @@
<div class="hr"></div>
<button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button>
<button id="p-export" class="sbtn">Instanz exportieren</button>
<button id="p-delete" class="sbtn del">Löschen</button>
<button id="p-delete" class="sbtn del" title="Entf">Löschen</button>
<button id="p-copy" class="sbtn">Instanz kopieren</button>
</div>
</aside>
@@ -171,7 +171,11 @@
</label>
<label class="field">
<span>Zusätzliche JVM-Argumente</span>
<input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" />
<div class="path-row">
<input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" />
<button id="f-extra-args-aikar" class="btn btn-mini" type="button"
title="Fügt eine bewährte Sammlung von Garbage-Collector-Flags ein (bekannt als „Aikar's Flags"), die bei größeren Modpacks Ruckler durch Garbage Collection reduzieren kann.">GC-Flags einfügen</button>
</div>
</label>
<label class="field">
<span>Pre-Launch-Befehl <small class="opt">(läuft vor dem Start)</small></span>
@@ -253,6 +257,22 @@
</label>
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
</section>
<section class="settings-card">
<h5>Benachrichtigungen</h5>
<label class="checkbox-field">
<input id="s-notify" type="checkbox" /> <span>Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird</span>
</label>
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
</section>
<section class="settings-card">
<h5>Autostart</h5>
<label class="checkbox-field">
<input id="s-autostart" type="checkbox" /> <span>Mit Windows starten (im Infobereich)</span>
</label>
<small class="hint">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.</small>
</section>
</div>
</div>
@@ -294,6 +314,14 @@
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
</label>
</div>
<label class="checkbox-field">
<input id="s-bgskin-cycle" type="checkbox" /> <span>Bei mehreren angemeldeten Konten automatisch durchwechseln</span>
</label>
<label class="field">
<span>Wechsel alle <small class="opt">(Sekunden)</small></span>
<input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" />
<small class="hint">Läuft nur, wenn 2 oder mehr Konten angemeldet sind, und beginnt nach dem letzten Konto wieder von vorn.</small>
</label>
</section>
</div>
</div>
@@ -659,6 +687,7 @@
</div>
<div id="m-pane-installed" class="hidden">
<div class="mmc-bar">
<button id="m-add-file" class="btn btn-mini" data-ic="plus">Datei hinzufügen…</button>
<button id="m-check-updates" class="btn btn-mini">Nach Updates suchen</button>
<button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button>
<span id="m-update-status" class="hint"></span>
@@ -980,6 +1009,7 @@
<button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
<button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
<button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
</div>
<div id="import-pane-zip" class="import-pane">
@@ -1089,11 +1119,51 @@
<div id="mmc-list" class="mods-list"></div>
</div>
</div>
<div id="import-pane-technic" class="import-pane hidden">
<p class="import-copy">Modpack direkt von der Technic-Plattform installieren oder eine bereits vorhandene Technic-Installation importieren.</p>
<div class="field-row" style="align-items:flex-end">
<label class="field" style="flex:1">
<span>Technic-Katalog durchsuchen</span>
<input id="tp-query" type="text" placeholder="z. B. Hexxit, Tekxit …" autocomplete="off" />
</label>
<button id="tp-search-btn" class="btn btn-primary">Suchen</button>
</div>
<div id="tp-status" class="hint" style="margin-top:8px">Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.</div>
<div id="tp-results" class="mods-list" style="margin-top:10px;max-height:220px;overflow:auto"></div>
<div class="modal-sep" style="margin:16px 0"></div>
<label class="field">
<span>Oder: vorhandenen Technic-Ordner importieren</span>
<div class="path-row">
<input id="technic-path" type="text" placeholder="z. B. C:\Users\DeinName\AppData\Roaming\.technic" />
<button id="technic-pick" class="btn">Wählen…</button>
<button id="technic-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="technic-status" class="hint">Leer lassen nutzt automatisch den Standardpfad unter Windows (%APPDATA%\.technic).</small>
</label>
<div id="technic-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="technic-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="technic-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Mods, Welten, Konfiguration)</span>
</label>
</div>
<div id="technic-list" class="mods-list"></div>
<p class="hint" style="margin-top:8px">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.</p>
</div>
</div>
</div>
<div class="modal-foot">
<div id="mc-progress" class="hint hidden"></div>
<div id="mmc-progress" class="hint hidden"></div>
<div id="cf-progress" class="hint hidden"></div>
<div id="technic-progress" class="hint hidden"></div>
<div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button>
<button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button>
@@ -1101,6 +1171,7 @@
<button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="technic-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
</div>
</div>
</div>

View File

@@ -21,6 +21,7 @@ const { spawn, spawnSync } = require('child_process');
const services = require('./services');
const loaders = require('./loaders');
const forge = require('./forge');
const processwatchdog = require('./processwatchdog');
const UA = 'AeroMC-Launcher/0.2';
const RESOURCES = 'https://resources.download.minecraft.net';
@@ -253,20 +254,28 @@ async function prepareAssets(vj, sharedDir, onProgress) {
// ---------------------------------------------------------------------------
function pickJava(vj, preferredPath) {
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
let preferred = null;
if (preferredPath && preferredPath.trim()) {
const info = services.probeJava(preferredPath);
if (info) return { path: preferredPath, major: info.major, forced: true };
if (info) {
preferred = { path: preferredPath, major: info.major, forced: true };
if (info.major === required) return preferred;
}
}
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
const jvms = services.scanJava();
// exakt passende oder nächsthöhere 64-bit-Version
// Mod-Loader und Mods unterstützen neuere Java-Versionen nicht automatisch.
// Deshalb ausschließlich die von Mojang angegebene Major-Version verwenden.
const x64 = jvms.filter((j) => j.arch === 'x64');
const exact = x64.find((j) => j.major === required);
if (exact) return { path: exact.path, major: exact.major };
const higher = x64.filter((j) => j.major >= required).sort((a, b) => a.major - b.major)[0];
if (higher) return { path: higher.path, major: higher.major };
const any = jvms[0];
return any ? { path: any.path, major: any.major, mismatch: required } : null;
const fallback = preferred || x64[0] || jvms[0];
return fallback ? {
path: fallback.path,
major: fallback.major,
forced: !!fallback.forced,
mismatch: required,
} : null;
}
// ---------------------------------------------------------------------------
@@ -387,6 +396,14 @@ async function prepareAndLaunch(opts, onProgress) {
}
}
if (!java) return { ok: false, message: 'Keine Java-Installation gefunden. Bitte in den Einstellungen Java festlegen.' };
if (java.mismatch) {
return {
ok: false,
requiredJava: java.mismatch,
message: `Minecraft ${vj.id} benötigt Java ${java.mismatch}, gefunden wurde nur Java ${java.major || 'unbekannt'}. `
+ 'Bitte Auto-Java aktivieren oder eine passende Java-Installation auswählen.',
};
}
// Mod-Loader
let effectiveVj = vj;
@@ -503,14 +520,33 @@ async function prepareAndLaunch(opts, onProgress) {
spawnArgs = [...w.slice(1), java.path, ...args];
}
const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false });
child.stdout.on('data', (d) => emit('log', { line: d.toString() }));
child.stderr.on('data', (d) => emit('log', { line: d.toString() }));
const shutdownWatchdog = opts.wrapperCommand && opts.wrapperCommand.trim()
? null
: processwatchdog.createProcessWatchdog({
isRunning: () => child.exitCode === null && child.signalCode === null,
kill: () => child.kill(),
onForce: (graceMs) => emit('log', {
line: `[Launcher] Regulärer Client-Shutdown war nach ${graceMs / 1000} Sekunden noch nicht beendet; `
+ 'verbliebenen Java-Prozess beendet.\n',
}),
onError: () => emit('log', {
line: '[Launcher] Der nach dem regulären Client-Shutdown verbliebene Java-Prozess konnte nicht beendet werden.\n',
}),
});
child.stdout.on('data', (d) => {
emit('log', { line: d.toString() });
if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stdout');
});
child.stderr.on('data', (d) => {
emit('log', { line: d.toString() });
if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stderr');
});
// Ende genau einmal melden 'exit' und 'error' können beide kommen
let beendetGemeldet = false;
const meldeEnde = (code) => {
const meldeEnde = (code, cleanShutdown) => {
if (beendetGemeldet) return;
beendetGemeldet = true;
emit('exit', { code });
emit('exit', { code, cleanShutdown: !!cleanShutdown });
// Post-Exit-Befehl
if (opts.postExitCommand && opts.postExitCommand.trim()) {
emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' });
@@ -521,12 +557,19 @@ async function prepareAndLaunch(opts, onProgress) {
} catch { /* egal */ }
}
};
child.on('exit', (code) => meldeEnde(code));
child.on('exit', (code) => {
// vor end() lesen: sawCleanShutdown wird zwar nicht von end()/clear()
// zurückgesetzt, aber so bleibt die Reihenfolge unabhängig von internen Details
const cleanShutdown = !!(shutdownWatchdog && shutdownWatchdog.sawCleanShutdown);
if (shutdownWatchdog) shutdownWatchdog.end();
meldeEnde(shutdownWatchdog && shutdownWatchdog.forced ? 0 : code, cleanShutdown);
});
// ohne diesen Listener würde ein Fehlstart (z. B. falscher Java-Pfad) als
// unbehandeltes 'error'-Ereignis den Hauptprozess beenden
child.on('error', (err) => {
if (shutdownWatchdog) shutdownWatchdog.end();
emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' });
meldeEnde(-1);
meldeEnde(-1, false);
});
return { ok: true, pid: child.pid, java };

View File

@@ -6,7 +6,7 @@
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage } = require('electron');
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
@@ -25,10 +25,13 @@ const backup = require('./backup');
const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport');
const mcimport = require('./mcimport');
const technicimport = require('./technicimport');
const technicdiscover = require('./technicdiscover');
const javadl = require('./javadl');
const crashreports = require('./crashreports');
const skins = require('./skins');
const servers = require('./servers');
const mcping = require('./mcping');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
@@ -121,18 +124,29 @@ function instanzAusArgumenten(argv) {
return null;
}
// wie instanzAusArgumenten, nur für die optionale Server-Adresse einer
// Server-Verknüpfung ("--server=<ip>") 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) {
function starteInstanzImFenster(id, serverIp) {
if (!id || !mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('instance:launch-request', id);
mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null });
}
app.on('second-instance', (_e, argv) => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
starteInstanzImFenster(instanzAusArgumenten(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
@@ -144,6 +158,31 @@ function verknuepfungsZiel() {
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');
@@ -152,7 +191,7 @@ function windowIcon() {
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
}
function createWindow() {
function createWindow(startHidden) {
mainWindow = new BrowserWindow({
width: 1200,
height: 820,
@@ -162,6 +201,7 @@ function createWindow() {
title: 'AeroMC',
icon: windowIcon(),
autoHideMenuBar: true,
show: !startHidden,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -172,13 +212,69 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, 'index.html'));
// mit "--launch <id>" gestartet -> Instanz anwerfen, sobald die Oberfläche steht
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
// Infobereich, statt AeroMC wirklich zu beenden sonst wäre der
// Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist.
mainWindow.on('close', (e) => {
if (appIsQuitting || !store.getSettings().autostart) return;
e.preventDefault();
mainWindow.hide();
});
// mit "--launch <id>" 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) {
mainWindow.webContents.once('did-finish-load', () => starteInstanzImFenster(startId));
ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer));
}
}
let tray = null;
let appIsQuitting = false;
function showMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) { createWindow(false); return; }
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
function createTray() {
if (tray) return;
try {
const iconPfad = windowIcon();
const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty();
tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 }));
tray.setToolTip('AeroMC');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Öffnen', click: () => showMainWindow() },
{ type: 'separator' },
{ label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } },
]));
tray.on('click', () => showMainWindow());
} catch { /* z. B. wenn kein Icon vorhanden ist Autostart läuft dann ohne Tray-Symbol weiter */ }
}
function destroyTray() {
if (tray) { tray.destroy(); tray = null; }
}
// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden
function applyAutostart(enabled) {
try {
app.setLoginItemSettings({ openAtLogin: !!enabled, args: enabled ? ['--hidden'] : [] });
} catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ }
if (enabled) createTray();
else destroyTray();
}
app.whenReady().then(() => {
if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst
store.init(app.getPath('userData'));
@@ -192,7 +288,12 @@ app.whenReady().then(() => {
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
});
applyProxy(store.getSettings());
createWindow();
const autostartAn = !!store.getSettings().autostart;
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update)
applyAutostart(autostartAn);
const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden);
// Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung
setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800);
@@ -217,10 +318,18 @@ app.whenReady().then(() => {
setTimeout(async () => {
try {
const info = await updater.checkForUpdate(app.getVersion());
if (info.available && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:available', info);
if (mainWindow && !mainWindow.isDestroyed()) {
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
// kann nicht erst wenn der Nutzer manuell auf "Suchen" klickt
mainWindow.webContents.send('update:checked', info);
if (info.available) mainWindow.webContents.send('update:available', info);
}
} catch { /* offline o. Ä. still ignorieren */ }
} catch (err) {
// offline o. Ä. kein Absturz, aber die Info-Seite darf den Fehler zeigen
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) });
}
}
}, 4000);
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
@@ -244,6 +353,8 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => { appIsQuitting = true; });
// ---------------------------------------------------------------------------
// Java-Erkennung (für Anzeige in den Einstellungen)
// ---------------------------------------------------------------------------
@@ -266,7 +377,12 @@ function detectJava(javaPath) {
ipcMain.handle('settings:get', () => store.getSettings());
ipcMain.handle('settings:save', (_e, patch) => { const s = store.saveSettings(patch); applyProxy(s); return s; });
ipcMain.handle('settings:save', (_e, patch) => {
const s = store.saveSettings(patch);
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
return s;
});
ipcMain.handle('settings:instancesDir', () => store.getInstancesDir());
@@ -348,6 +464,10 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
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 || []) {
@@ -363,6 +483,8 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
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 || []) {
@@ -382,6 +504,52 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
});
/*
* Lokale Datei(en) direkt in den Inhaltsordner kopieren für Mods, die
* nicht über Modrinth/CurseForge zu finden sind (Dev-Build, privater Mod,
* per Hand zugeschickt). Die Datei taucht danach ganz normal in der
* "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt.
*/
ipcMain.handle('mods:addFile', async (_e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const filters = (art || 'mod') === 'mod'
? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }]
: [{ name: typ.label || 'Datei', extensions: ['zip'] }];
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Datei hinzufügen',
properties: ['openFile', 'multiSelections'],
filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]),
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const dir = store.inhaltsDir(id, art || 'mod');
fs.mkdirSync(dir, { recursive: true });
const added = [];
const failed = [];
for (const src of res.filePaths) {
try {
if (!/\.(jar|zip)$/i.test(src)) {
failed.push({ name: path.basename(src), message: 'Nur .jar/.zip-Dateien werden unterstützt.' });
continue;
}
const name = path.basename(src);
const ext = path.extname(name);
const base = name.slice(0, -ext.length);
let dest = path.join(dir, name);
// Namenskollision -> durchnummerieren statt eine vorhandene Datei zu überschreiben
let n = 2;
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext}`); n++; }
fs.copyFileSync(src, dest);
added.push(path.basename(dest));
} catch (err) {
failed.push({ name: path.basename(src), message: err.message });
}
}
return { ok: true, added, failed };
});
// verfügbare Loader-Versionen (Fabric/Quilt) für eine MC-Version
ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
try { return await loaders.listLoaderVersions(loader, mcVersion); }
@@ -389,6 +557,8 @@ ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
});
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() {
@@ -432,6 +602,18 @@ ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => {
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 = [];
@@ -737,6 +919,36 @@ ipcMain.handle('instances:export', async (_e, id) => {
}
});
/*
* 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) {
@@ -762,7 +974,11 @@ async function importMrpack(buf, zip) {
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 };
return {
ok: true,
instance: store.getInstance(created.id),
fileCount: eingespielt.dateien.length,
};
}
/*
@@ -921,7 +1137,9 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
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 */ }
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
@@ -941,6 +1159,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
}),
});
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
return {
ok: true,
neueNummer: version.version_number,
@@ -1085,12 +1304,47 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
store.updateInstance(inst.id, { modpack: mp });
result.instance = store.getInstance(inst.id);
}
if (result && result.ok && result.instance) {
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
} catch (err) {
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
}
});
/*
* Kern des Modpack-Datei-Imports, unabhängig davon ob der Pfad aus einem
* Dateidialog oder per Drag&Drop aufs Fenster kommt.
* notModpack: true heißt "kein Modpack-Format erkannt" der Aufrufer kann
* dann noch andere ZIP-Interpretationen probieren (z. B. Instanz-Export).
*/
async function importModpackFromPath(filePath) {
try {
const buf = fs.readFileSync(filePath);
const zip = forge.readZipIndex(buf);
let result = null;
if (zip['modrinth.index.json']) {
result = await importMrpack(buf, zip);
} else if (zip['manifest.json']) {
const zugang = services.curseforgeZugang(store.getSettings());
if (!zugang) {
return { ok: false, needsKey: true,
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
}
result = await importCurseForge(buf, zip, zugang);
} else {
return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
}
if (result && result.ok && result.instance) {
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
} catch (err) {
return { ok: false, notModpack: true, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
}
}
ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Modpack importieren',
@@ -1098,43 +1352,30 @@ ipcMain.handle('modpack:import', async () => {
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
try {
const buf = fs.readFileSync(res.filePaths[0]);
const zip = forge.readZipIndex(buf);
if (zip['modrinth.index.json']) return await importMrpack(buf, zip);
if (zip['manifest.json']) {
const zugang = services.curseforgeZugang(store.getSettings());
if (!zugang) {
return { ok: false, needsKey: true,
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
}
return await importCurseForge(buf, zip, zugang);
}
return { ok: false, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
} catch (err) {
return { ok: false, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
}
return importModpackFromPath(res.filePaths[0]);
});
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
ipcMain.handle('instances:shortcut', (_e, id) => {
// 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 name = String(inst.name || 'Instanz').replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz';
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: `--launch="${id}"`,
args: verknuepfungsArgs([`--launch="${id}"`]),
cwd: path.dirname(ziel),
description: 'AeroMC ' + inst.name,
icon: ziel,
icon: verknuepfungsIcon(ziel),
iconIndex: 0,
});
if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' };
@@ -1144,18 +1385,53 @@ ipcMain.handle('instances:shortcut', (_e, id) => {
}
});
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
/*
* 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: res.filePaths[0], VN_DST: tmp });
{ VN_SRC: filePath, VN_DST: tmp });
// instance.json finden (auch in Unterordner)
let base = tmp;
@@ -1197,8 +1473,88 @@ ipcMain.handle('instances:import', async () => {
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
}
}
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
return importInstanceZipFromPath(res.filePaths[0]);
});
/*
* Datei per Drag&Drop aufs Fenster gezogen. Anhand der Endung/des Inhalts
* wird entschieden, was damit passieren soll:
* .jar -> als Mod/Ressourcenpaket/Shader in die übergebene
* Instanz kopieren (targetId/targetArt vom Renderer,
* je nachdem was gerade offen bzw. ausgewählt ist)
* .mrpack / .zip -> zuerst als Modpack versuchen (Modrinth/CurseForge),
* sonst als AeroMC-/MultiMC-Instanz-Export
*/
ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
if (!filePath || !fs.existsSync(filePath)) return { ok: false, message: 'Datei nicht gefunden.' };
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jar') {
if (!targetId) {
return { ok: false, message: 'Bitte zuerst eine Instanz öffnen (Mods-Dialog), dann die Datei dort hineinziehen.' };
}
const inst = store.getInstance(targetId);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const art = targetArt || 'mod';
const dir = store.inhaltsDir(targetId, art);
fs.mkdirSync(dir, { recursive: true });
try {
const name = path.basename(filePath);
const ext2 = path.extname(name);
const base = name.slice(0, -ext2.length);
let dest = path.join(dir, name);
let n = 2;
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext2}`); n++; }
fs.copyFileSync(filePath, dest);
return { ok: true, kind: 'mod', added: [path.basename(dest)] };
} catch (err) {
return { ok: false, message: err.message };
}
}
if (ext === '.mrpack' || ext === '.zip') {
const modpack = await importModpackFromPath(filePath);
// .mrpack ist immer ein Modpack bei Fehlschlag direkt die Meldung
// zeigen, statt es zusätzlich noch als Instanz-Export zu probieren
if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack);
// .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen
const instanceRes = await importInstanceZipFromPath(filePath);
return Object.assign({ kind: 'instance' }, instanceRes);
}
return { ok: false, message: 'Nicht unterstützter Dateityp: ' + (ext || '(ohne Endung)') };
});
/*
* Windows-Benachrichtigung für abgeschlossene Hintergrund-Aktionen (Backup,
* größere Downloads) nur wenn die Einstellung an ist UND das Fenster
* gerade nicht im Vordergrund ist (sonst sieht man's eh schon live).
*/
function notify(title, body) {
try {
if (store.getSettings().desktopNotifications === false) return;
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return;
if (!Notification.isSupported()) return;
const n = new Notification({ title, body, silent: false });
n.on('click', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
n.show();
} catch { /* rein informativ darf nie einen Ablauf stören */ }
}
// ---------------------------------------------------------------------------
// Sicherungen: von Hand und nach Zeitplan
// ---------------------------------------------------------------------------
@@ -1226,6 +1582,7 @@ async function erstelleBackup({ still } = {}) {
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 };
@@ -1514,7 +1871,10 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try { done.push(mmcimport.importOne(store, entry, copyData !== false)); }
try {
const imported = mmcimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length });
@@ -1540,7 +1900,10 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try { done.push(cfimport.importOne(store, entry, copyData !== false)); }
try {
const imported = cfimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
@@ -1566,7 +1929,10 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try { done.push(mcimport.importOne(store, entry, copyData !== false)); }
try {
const imported = mcimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
@@ -1575,6 +1941,54 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('technic:scan', (_e, folder) => {
try { return technicimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('technic:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = technicimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
ipcMain.handle('technicPlatform:search', async (_e, query) => {
try { return { ok: true, hits: await technicdiscover.search(query) }; }
catch (err) { return { ok: false, message: err.message }; }
});
ipcMain.handle('technicPlatform:install', async (_e, slug) => {
try {
const res = await technicdiscover.installFromPlatform(store, slug, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p);
});
if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
return res;
} catch (err) {
return { ok: false, message: err.message };
}
});
// ---------------------------------------------------------------------------
// IPC Minecraft Bedrock (Windows-Store-App)
// ---------------------------------------------------------------------------
@@ -1665,5 +2079,5 @@ ipcMain.handle('app:info', () => ({
version: app.getVersion(),
author: pkg.author || '',
homepage: pkg.homepage || '',
gitpage: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''),
launcherWebsite: pkg.launcherWebsite || '',
}));

150
src/mcping.js Normal file
View File

@@ -0,0 +1,150 @@
'use strict';
/*
* mcping.js "Server List Ping" (SLP), das Protokoll, das auch der
* Vanilla-Mehrspieler-Bildschirm nutzt, um MOTD/Spielerzahl/Version
* abzufragen, ohne sich wirklich zu verbinden.
*
* Ablauf (siehe minecraft.wiki/w/Server_List_Ping):
* 1) Handshake-Paket senden (Protokollversion, Adresse, Port, next state=1)
* 2) Status-Request-Paket senden (leer)
* 3) Status-Response-Paket empfangen: enthält einen JSON-String
* Alle Pakete sind mit ihrer Länge als VarInt vorangestellt.
*/
const net = require('net');
function encodeVarInt(value) {
const bytes = [];
let v = value;
do {
let temp = v & 0b01111111;
v = v >>> 7;
if (v !== 0) temp |= 0b10000000;
bytes.push(temp);
} while (v !== 0);
return Buffer.from(bytes);
}
function encodeString(str) {
const strBuf = Buffer.from(str, 'utf8');
return Buffer.concat([encodeVarInt(strBuf.length), strBuf]);
}
function encodeUShort(n) {
const b = Buffer.alloc(2);
b.writeUInt16BE(n & 0xffff, 0);
return b;
}
// liest einen VarInt aus buf ab offset -> { value, next } oder null,
// wenn noch nicht genug Bytes angekommen sind
function decodeVarInt(buf, offset) {
let numRead = 0;
let result = 0;
let read;
do {
if (offset + numRead >= buf.length) return null;
read = buf[offset + numRead];
result |= (read & 0b01111111) << (7 * numRead);
numRead++;
if (numRead > 5) throw new Error('VarInt zu groß');
} while ((read & 0b10000000) !== 0);
return { value: result >>> 0, next: offset + numRead };
}
// "description" kann ein reiner String oder eine Chat-Komponente
// ({text, extra:[...]}) sein hier auf reinen Text reduzieren
function extractMotdText(desc) {
if (!desc) return '';
if (typeof desc === 'string') return desc;
let text = String(desc.text || '');
if (Array.isArray(desc.extra)) {
for (const part of desc.extra) text += extractMotdText(part);
}
return text;
}
// Formatierungscodes (§a, §l, …) sind fürs Spiel gedacht, nicht für unsere Liste
function stripFormatting(text) {
return String(text || '').replace(/§./g, '').replace(/\s+/g, ' ').trim();
}
/*
* Fragt einen Server ab.
* -> { ok, motd, version, protocol, online, max, sample, favicon, latencyMs }
* oder { ok: false, message }
*/
function pingServer(host, port = 25565, timeoutMs = 4000) {
return new Promise((resolve) => {
const start = Date.now();
const socket = new net.Socket();
let buffer = Buffer.alloc(0);
let finished = false;
const done = (result) => {
if (finished) return;
finished = true;
try { socket.destroy(); } catch { /* egal */ }
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.on('timeout', () => done({ ok: false, message: 'Zeitüberschreitung.' }));
socket.on('error', (err) => done({ ok: false, message: err.code === 'ENOTFOUND' ? 'Adresse nicht gefunden.' : err.message }));
socket.connect(port, host, () => {
const handshakeBody = Buffer.concat([
encodeVarInt(0x00),
encodeVarInt(-1), // Protokollversion "unbekannt" für den Status-Abfrage-Zweck egal
encodeString(host),
encodeUShort(port),
encodeVarInt(1), // next state: 1 = status
]);
const handshakePacket = Buffer.concat([encodeVarInt(handshakeBody.length), handshakeBody]);
const requestBody = encodeVarInt(0x00);
const requestPacket = Buffer.concat([encodeVarInt(requestBody.length), requestBody]);
socket.write(Buffer.concat([handshakePacket, requestPacket]));
});
socket.on('data', (chunk) => {
if (finished) return;
buffer = Buffer.concat([buffer, chunk]);
try {
const lenInfo = decodeVarInt(buffer, 0);
if (!lenInfo) return; // Länge noch nicht vollständig angekommen
const packetEnd = lenInfo.next + lenInfo.value;
if (buffer.length < packetEnd) return; // Paket noch nicht vollständig da
const idInfo = decodeVarInt(buffer, lenInfo.next);
if (!idInfo) return;
const jsonLenInfo = decodeVarInt(buffer, idInfo.next);
if (!jsonLenInfo) return;
const jsonStart = jsonLenInfo.next;
const jsonEnd = jsonStart + jsonLenInfo.value;
if (buffer.length < jsonEnd) return;
const data = JSON.parse(buffer.toString('utf8', jsonStart, jsonEnd));
done({
ok: true,
latencyMs: Date.now() - start,
motd: stripFormatting(extractMotdText(data.description)),
version: (data.version && data.version.name) || '',
protocol: data.version ? data.version.protocol : null,
online: data.players ? data.players.online : null,
max: data.players ? data.players.max : null,
sample: (data.players && Array.isArray(data.players.sample))
? data.players.sample.map((p) => String(p.name || '')).filter(Boolean)
: [],
favicon: typeof data.favicon === 'string' ? data.favicon : '',
});
} catch (err) {
done({ ok: false, message: 'Ungültige Antwort: ' + err.message });
}
});
});
}
module.exports = { pingServer };

View File

@@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld('api', {
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
systemMemory: () => ipcRenderer.invoke('system:memory'),
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
@@ -42,6 +44,7 @@ contextBridge.exposeInMainWorld('api', {
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
logsList: (id) => ipcRenderer.invoke('logs:list', id),
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
@@ -68,6 +71,7 @@ contextBridge.exposeInMainWorld('api', {
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
@@ -83,14 +87,21 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('backup:progress', l);
},
importInstance: () => ipcRenderer.invoke('instances:import'),
createShortcut: (id) => ipcRenderer.invoke('instances:shortcut', id),
// Start über eine Desktop-Verknüpfung ("--launch <id>")
createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
onLaunchRequest: (cb) => {
const l = (_e, id) => cb(id);
const l = (_e, payload) => cb(payload);
ipcRenderer.on('instance:launch-request', l);
return () => ipcRenderer.removeListener('instance:launch-request', l);
},
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
notifyReady: () => ipcRenderer.send('renderer:ready'),
importModpack: () => ipcRenderer.invoke('modpack:import'),
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
@@ -169,6 +180,21 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('mc:progress', l);
return () => ipcRenderer.removeListener('mc:progress', l);
},
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
onTechnicProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('technic:progress', l);
return () => ipcRenderer.removeListener('technic:progress', l);
},
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
onTechnicPlatformProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('technicPlatform:progress', l);
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
},
// Bedrock (Windows-Store-Ausgabe)
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
@@ -192,6 +218,14 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('update:available', l);
return () => ipcRenderer.removeListener('update:available', l);
},
// Ergebnis des automatischen Checks beim Start auch wenn kein Update
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
// verfügbarem Update feuert)
onUpdateChecked: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('update:checked', l);
return () => ipcRenderer.removeListener('update:checked', l);
},
onUpdateProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('update:progress', l);

101
src/processwatchdog.js Normal file
View File

@@ -0,0 +1,101 @@
'use strict';
const DEFAULT_GRACE_MS = 15000;
const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/;
const FATAL_OUTPUT = [
/---- Minecraft Crash Report ----/i,
/\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i,
/\b(?:This crash report has been saved to|Saving crash report to)\b/i,
/\bA fatal error has been detected by the Java Runtime Environment\b/i,
/Exception in thread "(?:Render thread|Client thread|main)"/i,
/\[(?:Render thread|Client thread|main)\/FATAL\]/i,
];
function createProcessWatchdog(options) {
const graceMs = options.graceMs || DEFAULT_GRACE_MS;
const schedule = options.setTimer || setTimeout;
const cancel = options.clearTimer || clearTimeout;
const buffers = { stdout: '', stderr: '' };
let timer = null;
let armed = false;
let crashed = false;
let ended = false;
let forced = false;
// bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen
// wurde im Gegensatz zu `armed` wird das NICHT durch markCrash()
// zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt
// weiterhin als "war ein regulärer Shutdown" erkennbar bleibt
let sawCleanShutdown = false;
function clear() {
if (timer !== null) cancel(timer);
timer = null;
armed = false;
}
function markCrash() {
crashed = true;
clear();
}
function scheduleKill() {
if (timer !== null) cancel(timer);
timer = schedule(() => {
timer = null;
if (ended || crashed || !armed || !options.isRunning()) return;
try {
forced = options.kill() === true;
} catch {
forced = false;
}
if (forced && options.onForce) options.onForce(graceMs);
else if (!forced && options.isRunning() && options.onError) options.onError();
}, graceMs);
if (timer && typeof timer.unref === 'function') timer.unref();
}
function inspectLine(line) {
const isCleanShutdown = CLEAN_SHUTDOWN.test(line);
if (isCleanShutdown) sawCleanShutdown = true;
if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) {
markCrash();
return;
}
if (ended || crashed || armed || !isCleanShutdown) return;
armed = true;
scheduleKill();
}
function feed(chunk, stream) {
if (ended) return;
const key = stream === 'stderr' ? 'stderr' : 'stdout';
const text = buffers[key] + String(chunk || '');
const lines = text.split(/\r?\n/);
buffers[key] = lines.pop() || '';
for (const line of lines) inspectLine(line);
if (armed && !crashed && !ended && lines.length) scheduleKill();
}
function end() {
ended = true;
clear();
}
return {
feed,
end,
get armed() { return armed; },
get crashed() { return crashed; },
get forced() { return forced; },
get sawCleanShutdown() { return sawCleanShutdown; },
};
}
module.exports = {
DEFAULT_GRACE_MS,
CLEAN_SHUTDOWN,
FATAL_OUTPUT,
createProcessWatchdog,
};

View File

@@ -470,6 +470,17 @@ const LOADER_LABEL = {
neoforge: 'NeoForge', quilt: 'Quilt',
};
// Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren
// bei größeren Modpacks spürbar GC-bedingte Ruckler. Wirkt erst ab genug RAM
// sinnvoll, schadet aber auch bei wenig RAM nicht.
const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 '
+ '-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch '
+ '-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M '
+ '-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 '
+ '-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 '
+ '-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem '
+ '-XX:MaxTenuringThreshold=1';
// ---------- Zustand ----------
let instances = [];
let currentDetailId = null;
@@ -528,6 +539,7 @@ function applyTheme(theme) {
let mmcFound = [];
let cfFound = [];
let mcFound = [];
let technicFound = [];
let importKind = 'zip';
async function ensureDefaultMcScan() {
@@ -538,8 +550,8 @@ async function ensureDefaultMcScan() {
}
function setImportKind(kind) {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc'].forEach((entryKind) => {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => {
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
});
@@ -548,9 +560,11 @@ function setImportKind(kind) {
el('mc-do').classList.toggle('hidden', importKind !== 'mc');
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
el('technic-do').classList.toggle('hidden', importKind !== 'technic');
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
}
function openImportDialog(kind = 'zip') {
@@ -923,6 +937,132 @@ async function runMmcImport() {
if (instances.length) await selectInstance(instances[0].id);
}
// ---------- Technic-Plattform: Modpack-Katalog ----------
async function searchTechnicPlatform() {
const query = el('tp-query').value.trim();
const box = el('tp-results');
const st = el('tp-status');
st.textContent = 'Suche …';
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
const res = await window.api.technicPlatformSearch(query);
if (!res.ok) {
st.textContent = 'Fehler: ' + (res.message || 'Suche fehlgeschlagen.');
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
return;
}
const hits = res.hits || [];
st.textContent = hits.length ? `${hits.length} Treffer` : 'Keine Treffer.';
box.innerHTML = '';
if (!hits.length) { box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; return; }
for (const h of hits) {
const item = document.createElement('div');
item.className = 'mod-item';
item.innerHTML =
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
<div class="mod-info"><div class="mod-name">${escapeHtml(h.name)}</div></div>
<div class="mod-action"></div>`;
const btn = document.createElement('button');
btn.className = 'btn btn-primary btn-mini';
btn.textContent = 'Installieren';
btn.addEventListener('click', () => installTechnicFromSearch(h, btn));
item.querySelector('.mod-action').appendChild(btn);
box.appendChild(item);
}
}
async function installTechnicFromSearch(hit, btn) {
if (!hit || !hit.slug) return;
const prev = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const st = el('tp-status');
st.textContent = `Installiere „${hit.name}" …`;
try {
const res = await window.api.technicPlatformInstall(hit.slug);
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack installiert: ${res.instance.name}`);
await selectInstance(res.instance.id);
} else {
st.textContent = '✖ ' + (res.message || 'Installation fehlgeschlagen.');
toast(res.message || 'Installation fehlgeschlagen.', true);
}
} finally {
if (btn) { btn.disabled = false; btn.textContent = prev; }
}
}
async function scanTechnic() {
const folder = el('technic-path').value.trim();
el('technic-status').textContent = 'Suche Modpacks …';
const res = await window.api.technicScan(folder);
if (!res.ok) {
el('technic-status').textContent = '✖ Dort wurde kein modpacks-Ordner gefunden.';
el('technic-result').classList.add('hidden');
el('technic-do').disabled = true;
return;
}
technicFound = res.instances;
if (!folder && res.instancesDir) el('technic-path').value = res.instancesDir;
el('technic-status').textContent = `${res.count} Modpack${res.count === 1 ? '' : 's'} gefunden.`;
el('technic-result').classList.remove('hidden');
el('technic-all').checked = true;
renderTechnicList();
}
function renderTechnicList() {
const box = el('technic-list');
box.innerHTML = '';
if (!technicFound.length) { box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; return; }
technicFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'Version unbekannt', loaderTxt]
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateTechnicButton();
}
function selectedTechnic() {
return [...document.querySelectorAll('#technic-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => technicFound[Number(c.dataset.idx)]);
}
function updateTechnicButton() {
const n = selectedTechnic().length;
el('technic-do').disabled = n === 0;
el('technic-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runTechnicImport() {
const entries = selectedTechnic();
if (!entries.length) return;
const copy = el('technic-copy').checked;
el('technic-do').disabled = true;
el('technic-progress').textContent = 'Übernehme …';
el('technic-progress').classList.remove('hidden');
const res = await window.api.technicImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
// ---------- Bedrock (Windows-Store-Ausgabe) ----------
let bedrockKind = 'worlds';
@@ -1121,6 +1261,27 @@ function onUpdateProgress(p) {
}
}
// Aktualisiert die Statuszeile im Info-Bereich genutzt sowohl vom manuellen
// "Nach Updates suchen"-Knopf als auch vom automatischen Check beim Start.
function renderUpdateStatus(info) {
const st = el('s-update-status');
if (!st) return;
st.classList.remove('status-ok', 'status-bad');
if (info.checking) {
st.textContent = 'Suche nach Updates …';
} else if (info.error) {
st.classList.add('status-bad');
st.textContent = '✖ ' + info.error;
} else if (info.noReleases) {
st.textContent = 'Noch keine Veröffentlichung vorhanden.';
} else if (info.available) {
st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`;
} else {
st.classList.add('status-ok');
st.textContent = `✓ Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`;
}
}
// ---------- Zustand der Anmelde-Dienste in der Statusleiste ----------
function showAuthHealth(h) {
const box = el('status-auth');
@@ -1135,11 +1296,15 @@ function showAuthHealth(h) {
const AUTH_SERVICE_COUNT = 3;
// ---------- Skin-Hintergrund (wie MultiMCs Katze, nur mit eigenem Skin) ----------
async function applySkinBackground() {
// uuidOverride: wird vom Konten-Wechsler (siehe unten) genutzt, um gezielt
// einen bestimmten Skin zu zeigen, ohne die eigentliche Einstellung anzufassen
async function applySkinBackground(uuidOverride) {
const s = await window.api.getSettings();
const root = document.documentElement;
let uuid = '';
if (s.bgSkin === 'active') {
if (uuidOverride) {
uuid = uuidOverride;
} else if (s.bgSkin === 'active') {
const acc = await window.api.authStatus();
uuid = acc && acc.profile ? acc.profile.id : '';
} else if (s.bgSkin) {
@@ -1172,6 +1337,44 @@ async function applySkinBackground() {
root.style.setProperty('--bg-skin', 'none');
}
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
let skinCycleTimer = null;
let skinCycleIndex = 0;
function stopSkinCycle() {
if (skinCycleTimer) { clearInterval(skinCycleTimer); skinCycleTimer = null; }
}
async function skinCycleTick() {
const accs = await window.api.authAccounts();
if (accs.length < 2) { stopSkinCycle(); await applySkinBackground(); return; }
skinCycleIndex = (skinCycleIndex + 1) % accs.length; // am Ende wieder von vorn
await applySkinBackground(accs[skinCycleIndex].id);
}
/*
* Einstiegspunkt für alles, was den Skin-Hintergrund neu anzeigen soll.
* Ersetzt die frühere direkte applySkinBackground()-Aufrufe: kümmert sich
* zusätzlich darum, den Kontenwechsel-Timer je nach Einstellung zu
* starten/stoppen, statt dass jede Aufrufstelle das selbst tun müsste.
*/
async function refreshSkinBackground() {
stopSkinCycle();
const s = await window.api.getSettings();
if (s.bgSkinCycle) {
const accs = await window.api.authAccounts();
if (accs.length >= 2) {
skinCycleIndex = 0;
await applySkinBackground(accs[0].id);
const seconds = Math.max(5, Number(s.bgSkinCycleSeconds) || 30);
skinCycleTimer = setInterval(skinCycleTick, seconds * 1000);
return;
}
// weniger als 2 Konten angemeldet -> normal weiter wie ohne Wechsel
}
await applySkinBackground();
}
// Pose-Vorlagen für den lokalen 3D-Renderer (eigene Nachbildungen bekannter Motive)
const SKIN_POSES = {
walking: { label: 'Laufend', fov: 36, zoom: 0.62, apply: (p) => {
@@ -1936,9 +2139,11 @@ async function renderServers() {
<div class="mod-info">
<div class="mod-name">${escapeHtml(s.name || s.ip)}</div>
<div class="mod-meta">${escapeHtml(s.ip)}</div>
<div class="mod-meta srv-status hint">Wird geprüft …</div>
</div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
pingServerRow(s.ip, item.querySelector('.srv-status'));
const starten = document.createElement('button');
starten.className = 'btn btn-primary btn-mini';
@@ -1950,6 +2155,27 @@ async function renderServers() {
});
aktionen.appendChild(starten);
const favorit = document.createElement('button');
favorit.className = 'btn btn-mini';
favorit.textContent = '★ Verknüpfung';
favorit.title = 'Desktop-Verknüpfung erstellen, die Minecraft startet und direkt mit diesem Server verbindet (Quick Play, ab MC 1.20)';
favorit.addEventListener('click', async () => {
const inst = await window.api.getInstance(currentDetailId);
const vorschlag = `${inst ? inst.name : 'Instanz'} - ${s.name || s.ip}`;
const eingabe = await askPrompt({
title: 'Server-Verknüpfung erstellen',
text: 'Name der Desktop-Verknüpfung (ohne .lnk):',
label: 'Name',
value: vorschlag,
okLabel: 'Erstellen',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.serversShortcut(currentDetailId, s.ip, s.name, eingabe.trim());
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt: ' + (s.name || s.ip));
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
});
aktionen.appendChild(favorit);
const umbenennen = document.createElement('button');
umbenennen.className = 'btn btn-mini';
umbenennen.textContent = 'Umbenennen';
@@ -1992,6 +2218,24 @@ async function renderServers() {
}
}
// Fragt Live-Status (MOTD/Spieler/Version) ab und trägt ihn in die Zeile ein.
// Läuft unabhängig für jede Zeile, ohne die Liste selbst zu blockieren.
async function pingServerRow(ip, statusEl) {
if (!statusEl) return;
const res = await window.api.serversPing(ip);
// Zeile kann inzwischen aus dem DOM entfernt worden sein (Liste neu aufgebaut)
if (!statusEl.isConnected) return;
if (!res.ok) {
statusEl.textContent = '⚠ ' + (res.message || 'nicht erreichbar');
statusEl.classList.add('status-bad');
return;
}
const spieler = res.max != null ? `${res.online ?? '?'}/${res.max} Spieler` : '';
statusEl.textContent = [res.motd, spieler, res.version, res.latencyMs + ' ms']
.filter(Boolean).join(' · ');
statusEl.classList.add('status-ok');
}
async function addServer() {
const eintrag = { name: el('srv-name').value.trim(), ip: el('srv-ip').value.trim() };
const res = await window.api.serversAdd(currentDetailId, eintrag);
@@ -2130,7 +2374,7 @@ async function setzeSkin() {
el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.';
toast('Skin geändert.');
await openSkin(); // Vorschau mit dem neuen Stand auffrischen
await applySkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
btn.disabled = false;
@@ -2145,7 +2389,7 @@ async function setzeSkinZurueck() {
if (res.ok) {
toast('Skin auf Standard zurückgesetzt.');
await openSkin();
await applySkinBackground();
await refreshSkinBackground();
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
@@ -2227,7 +2471,8 @@ async function zeigeAbsturzbericht(instanzId) {
const zeile = (titel, wert) => wert
? `<div class="crash-zeile"><div class="crash-titel">${titel}</div><div class="crash-wert">${escapeHtml(wert)}</div></div>`
: '';
let html = zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler)
let html = zeile('Diagnose', res.diagnose) + zeile('Empfohlene Lösung', res.empfehlung)
+ zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler)
+ zeile('Verdächtige Mods', res.verdaechtigeMods);
if (res.verlauf && res.verlauf.length) {
html += `<div class="crash-zeile"><div class="crash-titel">Aufrufverlauf</div>`
@@ -2246,7 +2491,17 @@ function onLaunchEvt(evt) {
status('Minecraft beendet (Code ' + evt.code + ')');
el('status-right').textContent = '';
appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n');
const crashed = evt.code !== 0 && evt.code !== null;
// War vorher "Stopping!" im Log (regulärer Client-Shutdown), zählt ein
// nachträglicher harter Exit-Code nicht als Absturz während des Spiels
// z. B. wenn Minecrafts interner Shutdown-Watchdog wegen hängender
// Mod-Threads (z. B. JourneyMap WebMap) die JVM nach Ablauf einer
// Wartezeit zwangsweise beendet. Der Bericht bleibt trotzdem gespeichert.
const crashed = evt.code !== 0 && evt.code !== null && !evt.cleanShutdown;
if (evt.cleanShutdown && evt.code !== 0 && evt.code !== null) {
appendConsole('[Hinweis] Der Client wurde regulär beendet (Stopping!), aber danach vom '
+ 'internen Shutdown-Watchdog hart terminiert (z. B. durch einen Mod, dessen Threads '
+ 'nicht rechtzeitig beenden etwa JourneyMap WebMap). Kein Absturz während des Spiels.\n');
}
// Absturzbericht suchen, bevor über die Konsole entschieden wird
// ein Bericht sagt mehr als der Java-Auswurf
if (crashed) zeigeAbsturzbericht(evt.id);
@@ -2420,6 +2675,24 @@ function renderContentList(boxId, items, sub, emoji) {
`<div class="mod-icon">${emoji}</div>
<div class="mod-info"><div class="mod-name">${escapeHtml(it.name)}</div></div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
if (sub === 'saves') {
const sichern = document.createElement('button');
sichern.className = 'btn btn-mini';
sichern.textContent = 'Sichern';
sichern.title = 'Nur diese Welt jetzt als ZIP sichern (Dokumente\\AeroMC Launcher\\Welten-Sicherungen)';
sichern.addEventListener('click', async () => {
sichern.disabled = true;
const orig = sichern.textContent;
sichern.textContent = 'Sichere …';
const res = await window.api.backupWorld(currentDetailId, it.name);
sichern.disabled = false;
sichern.textContent = orig;
if (res.ok) toast(`${it.name}" gesichert.`);
else toast(res.message || 'Sicherung fehlgeschlagen.', true);
});
aktionen.appendChild(sichern);
}
const rm = document.createElement('button');
rm.className = 'btn btn-danger btn-mini';
rm.textContent = 'Löschen';
@@ -2427,7 +2700,7 @@ function renderContentList(boxId, items, sub, emoji) {
'Löschen', `${it.name}" wird unwiderruflich gelöscht. Fortfahren?`, '🗑 Löschen',
async () => { await window.api.deleteContent(currentDetailId, sub, it.name); await renderContentLists(); toast('Gelöscht: ' + it.name); },
));
item.querySelector('.mod-action').appendChild(rm);
aktionen.appendChild(rm);
box.appendChild(item);
}
}
@@ -2575,9 +2848,24 @@ async function refreshInstalled() {
btn.className = 'btn btn-danger btn-mini';
btn.textContent = 'Entfernen';
btn.addEventListener('click', async () => {
await window.api.modRemove(currentDetailId, m.filename, modArt);
toast('Entfernt: ' + m.title);
await refreshInstalled();
const tatsaechlichEntfernen = async () => {
await window.api.modRemove(currentDetailId, m.filename, modArt);
toast('Entfernt: ' + m.title);
await refreshInstalled();
};
const abhaenger = await window.api.modDependents(currentDetailId, m.filename, modArt);
if (abhaenger.length) {
const namen = abhaenger.map((n) => `${n}"`).join(', ');
askConfirm(
'Wird noch gebraucht',
`${namen} ${abhaenger.length === 1 ? 'benötigt' : 'benötigen'}${m.title}" als Pflicht-Abhängigkeit. `
+ 'Wird sie trotzdem entfernt, funktionieren diese Mods vermutlich nicht mehr richtig. Trotzdem entfernen?',
'Trotzdem entfernen',
tatsaechlichEntfernen,
);
} else {
await tatsaechlichEntfernen();
}
});
aktionen.appendChild(btn);
box.appendChild(item);
@@ -2780,9 +3068,14 @@ async function openSettings(pane) {
}
sel.value = s.bgSkin || '';
el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
el('s-bgskin-cycle').checked = !!s.bgSkinCycle;
el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30;
sel.disabled = !!s.bgSkinCycle;
el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false;
el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
el('s-autobackup-every').value = s.autoBackupEvery || 'daily';
@@ -2829,7 +3122,7 @@ async function openSettings(pane) {
['Version', formatVersionLabel(info.version)],
['Autor', info.author || 'Nicht hinterlegt'],
['Webseite', info.homepage || 'Nicht hinterlegt', info.homepage || ''],
['Gitseite', info.gitpage || 'Nicht hinterlegt', info.gitpage || ''],
['Launcher-Webseite', info.launcherWebsite || 'Nicht hinterlegt', info.launcherWebsite || ''],
].map(([label, value, url]) => {
const renderedValue = url
? `<a href="#" class="info-meta-link" data-url="${escapeHtml(url)}">${escapeHtml(value)}</a>`
@@ -2897,9 +3190,13 @@ async function saveSettings() {
globalPostExit: el('s-global-post').value.trim(),
bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value,
bgSkinCycle: el('s-bgskin-cycle').checked,
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked,
bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked,
autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
autoBackupEvery: el('s-autobackup-every').value,
@@ -2931,7 +3228,7 @@ async function saveSettings() {
applyTheme(patch.theme);
iso3dEnabled = patch.iso3dIcons;
await applyBedrockVisibility();
await applySkinBackground();
await refreshSkinBackground();
hide('modal-settings');
toast('Einstellungen gespeichert.');
await reload();
@@ -3169,7 +3466,7 @@ async function runGlobalRestore() {
applyTheme(s.theme || 'dark');
iso3dEnabled = s.iso3dIcons !== false;
await applyBedrockVisibility();
await applySkinBackground();
await refreshSkinBackground();
hide('modal-settings');
await reload();
toast('Backup wiederhergestellt: ' + res.path);
@@ -3256,7 +3553,7 @@ async function renderAccounts(boxId = 'login-accounts') {
await window.api.authSetActive(a.id);
await refreshAllAccountLists();
await refreshAccountButton();
await applySkinBackground();
await refreshSkinBackground();
toast('Aktiv: ' + a.name);
});
actions.appendChild(use);
@@ -3381,8 +3678,9 @@ function wire() {
// Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf)
el('btn-skin').addEventListener('click', async () => {
const s = await window.api.getSettings();
if (s.bgSkin) {
await window.api.saveSettings({ bgSkin: '' });
if (s.bgSkin || s.bgSkinCycle) {
// auch den Kontenwechsel mit ausschalten, sonst würde er sofort wieder anspringen
await window.api.saveSettings({ bgSkin: '', bgSkinCycle: false });
toast('Skin-Hintergrund aus.');
} else {
const acc = await window.api.authStatus();
@@ -3390,7 +3688,7 @@ function wire() {
await window.api.saveSettings({ bgSkin: 'active' });
toast('Skin-Hintergrund an: ' + acc.profile.name);
}
await applySkinBackground();
await refreshSkinBackground();
});
// Kontextmenü: Einträge lösen die Aktionen der rechten Leiste aus
@@ -3431,6 +3729,13 @@ function wire() {
searchModpacks();
}
}
if (tab.dataset.kind === 'technic') {
const box = el('tp-results');
if (box && !box.dataset.loaded) {
box.dataset.loaded = '1';
searchTechnicPlatform();
}
}
});
});
el('import-zip-do').addEventListener('click', runZipImport);
@@ -3474,6 +3779,23 @@ function wire() {
});
el('mmc-list').addEventListener('change', updateMmcButton);
el('mmc-do').addEventListener('click', runMmcImport);
el('tp-search-btn').addEventListener('click', searchTechnicPlatform);
el('tp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchTechnicPlatform(); });
window.api.onTechnicPlatformProgress((p) => {
el('tp-status').textContent = `Lade Mods … ${p.current}/${p.total}${p.name ? ' (' + p.name + ')' : ''}`;
});
el('technic-pick').addEventListener('click', async () => {
const p = await window.api.technicPickFolder();
if (p) { el('technic-path').value = p; await scanTechnic(); }
});
el('technic-scan').addEventListener('click', scanTechnic);
el('technic-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanTechnic(); });
el('technic-all').addEventListener('change', (e) => {
document.querySelectorAll('#technic-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateTechnicButton();
});
el('technic-list').addEventListener('change', updateTechnicButton);
el('technic-do').addEventListener('click', runTechnicImport);
window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden');
@@ -3486,13 +3808,33 @@ function wire() {
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mmc-progress').classList.remove('hidden');
});
window.api.onTechnicProgress((p) => {
el('technic-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('technic-progress').classList.remove('hidden');
});
// Bedrock
el('btn-bedrock').addEventListener('click', openBedrock);
el('bd-play').addEventListener('click', async () => {
const res = await window.api.bedrockLaunch();
if (res && res.ok) toast('Bedrock wird gestartet …');
else toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true);
const btn = el('bd-play');
// Ein Klick löst eine nicht abbrechbare App-Aktivierung aus, die etwas
// dauern kann Button sperren, damit ein ungeduldiger zweiter Klick
// nicht zu einem doppelt geöffneten Spiel führt.
if (btn.disabled) return;
btn.disabled = true;
toast('Bedrock wird gestartet …');
try {
const res = await window.api.bedrockLaunch();
if (res && res.ok) {
if (!res.confirmed) {
toast('Start ausgelöst, konnte aber nicht bestätigt werden. Falls sich nichts öffnet, bitte erneut versuchen.');
}
} else {
toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true);
}
} finally {
btn.disabled = false;
}
});
el('bd-import').addEventListener('click', async () => {
const res = await window.api.bedrockInstallAddon();
@@ -3536,7 +3878,16 @@ function wire() {
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', async () => {
if (!currentDetailId) return;
const res = await window.api.createShortcut(currentDetailId);
const inst = await window.api.getInstance(currentDetailId);
const eingabe = await askPrompt({
title: 'Verknüpfung erstellen',
text: 'Name der Desktop-Verknüpfung (ohne .lnk):',
label: 'Name',
value: inst ? inst.name : '',
okLabel: 'Erstellen',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.createShortcut(currentDetailId, eingabe.trim());
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt.');
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
});
@@ -3662,6 +4013,16 @@ function wire() {
});
el('crash-console').addEventListener('click', () => { hide('modal-crash'); openConsole(); });
el('m-add-file').addEventListener('click', async () => {
const res = await window.api.modAddFile(currentDetailId, modArt);
if (res.canceled) return;
if (!res.ok) { toast(res.message || 'Datei konnte nicht hinzugefügt werden.', true); return; }
const n = res.added.length;
const f = res.failed.length;
if (n) toast(`${n} Datei${n === 1 ? '' : 'en'} hinzugefügt${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0 && !n);
else if (f) toast(res.failed[0].message || 'Hinzufügen fehlgeschlagen.', true);
await refreshInstalled();
});
el('m-check-updates').addEventListener('click', checkModUpdates);
el('m-apply-updates').addEventListener('click', applyModUpdates);
window.api.onModUpdateProgress((p) => setUpdateStatus(`Suche … ${p.geprueft}/${p.gesamt} (${p.titel})`));
@@ -3686,10 +4047,20 @@ function wire() {
bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value,
});
await applySkinBackground();
await refreshSkinBackground();
};
el('s-bgskin').addEventListener('change', previewSkin);
el('s-bgskin-pose').addEventListener('change', previewSkin);
const previewSkinCycle = async () => {
el('s-bgskin').disabled = el('s-bgskin-cycle').checked;
await window.api.saveSettings({
bgSkinCycle: el('s-bgskin-cycle').checked,
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
});
await refreshSkinBackground();
};
el('s-bgskin-cycle').addEventListener('change', previewSkinCycle);
el('s-bgskin-cycle-seconds').addEventListener('change', previewSkinCycle);
el('s-scan-java').addEventListener('click', scanJava);
el('f-java-detected').addEventListener('change', (e) => {
if (e.target.value) el('f-java').value = e.target.value;
@@ -3698,6 +4069,13 @@ function wire() {
const p = await window.api.pickJava();
if (p) { el('f-java').value = p; el('f-java-detected').value = ''; }
});
el('f-extra-args-aikar').addEventListener('click', () => {
const feld = el('f-extra-args');
const vorhanden = feld.value.trim();
if (vorhanden.includes('G1NewSizePercent')) { toast('Die GC-Flags stehen schon drin.'); return; }
feld.value = vorhanden ? vorhanden + ' ' + AIKAR_FLAGS : AIKAR_FLAGS;
toast('GC-Flags eingefügt.');
});
el('s-java-detected').addEventListener('change', async (e) => {
if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); }
});
@@ -3733,18 +4111,10 @@ function wire() {
el('upd-open').addEventListener('click', openReleasePage);
if (el('s-check-update')) {
el('s-check-update').addEventListener('click', async () => {
const st = el('s-update-status');
st.textContent = 'Suche nach Updates …';
renderUpdateStatus({ checking: true });
const info = await window.api.updateCheck();
if (info.error) { st.textContent = '✖ ' + info.error; return; }
if (info.noReleases) { st.textContent = 'Noch keine Veröffentlichung vorhanden.'; return; }
if (info.available) {
st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`;
hide('modal-settings');
showUpdateDialog(info);
} else {
st.textContent = `Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`;
}
renderUpdateStatus(info);
if (info.available) { hide('modal-settings'); showUpdateDialog(info); }
});
}
@@ -3807,8 +4177,93 @@ function wire() {
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open) open.classList.add('hidden');
return;
}
const strg = e.ctrlKey || e.metaKey;
const modalOffen = document.querySelector('.modal:not(.hidden)');
const inEingabe = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)
|| document.activeElement.isContentEditable;
// Strg+F: Suche fokussieren nicht wenn gerade ein Dialog offen ist,
// der eine eigene Suche hat (Mods, Modpack-Katalog, Import …)
if (strg && e.key.toLowerCase() === 'f' && !modalOffen) {
e.preventDefault();
el('search').focus();
el('search').select();
return;
}
// Strg+N: neue Instanz anlegen
if (strg && e.key.toLowerCase() === 'n' && !modalOffen) {
e.preventDefault();
el('btn-new').click();
return;
}
// Entf: markierte Instanz(en) löschen nicht während man irgendwo tippt
if (e.key === 'Delete' && !modalOffen && !inEingabe) {
e.preventDefault();
el('p-delete').click();
}
});
wireFileDrop();
}
// ---------- Datei-Drag&Drop aufs Fenster (.jar/.mrpack/.zip) ----------
function wireFileDrop() {
let dragDepth = 0; // dragenter/-leave feuern auch für Kindelemente
const istDatei = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
window.addEventListener('dragenter', (e) => {
if (!istDatei(e)) return;
e.preventDefault();
dragDepth++;
document.body.classList.add('file-drop-active');
});
window.addEventListener('dragover', (e) => {
if (!istDatei(e)) return;
e.preventDefault();
});
window.addEventListener('dragleave', (e) => {
if (!istDatei(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) document.body.classList.remove('file-drop-active');
});
window.addEventListener('drop', async (e) => {
if (!istDatei(e)) return;
e.preventDefault();
dragDepth = 0;
document.body.classList.remove('file-drop-active');
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path);
for (const f of files) await handleDroppedFile(f.path);
});
}
async function handleDroppedFile(filePath) {
const ext = ('.' + filePath.split('.').pop()).toLowerCase();
if (!['.jar', '.mrpack', '.zip'].includes(ext)) {
toast('Nicht unterstützter Dateityp: ' + filePath.split(/[\\/]/).pop(), true);
return;
}
// Für .jar: läuft der Mods-Dialog gerade, zählt die dort gewählte
// Inhaltsart (Mod/Ressourcenpaket/Shader) sonst landet es als Mod
// direkt in der aktuell ausgewählten Instanz.
const modsOpen = !el('modal-mods').classList.contains('hidden');
const targetArt = modsOpen ? modArt : 'mod';
const res = await window.api.filesDrop(filePath, currentDetailId, targetArt);
if (!res.ok) { toast(res.message || 'Import fehlgeschlagen.', true); return; }
if (res.kind === 'mod') {
toast(`Datei hinzugefügt: ${res.added[0]}`);
if (modsOpen) await refreshInstalled();
return;
}
await reload();
if (res.instance) {
toast(`Instanz übernommen: ${res.instance.name}`);
await selectInstance(res.instance.id);
} else {
toast('Import abgeschlossen.');
}
}
window.addEventListener('DOMContentLoaded', async () => {
@@ -3820,21 +4275,28 @@ window.addEventListener('DOMContentLoaded', async () => {
iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
} catch { applyTheme('dark'); }
await applySkinBackground();
await refreshSkinBackground();
window.api.onLaunchProgress(onLaunchEvt);
window.api.onBackupProgress((p) => applyBackupProgressEvent(p.mode, p.percent || 0, p.detail || ''));
window.api.onAuthCode(onAuthCode);
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await applySkinBackground(); });
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await refreshSkinBackground(); });
window.api.onAuthHealth(showAuthHealth);
// Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen
window.api.onAccountsChanged(() => { refreshAllAccountLists(); });
// über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen
window.api.onLaunchRequest(async (id) => {
// (bei einer Server-Verknüpfung zusätzlich direkt mit dem Server verbinden)
window.api.onLaunchRequest(async (payload) => {
const id = payload && typeof payload === 'object' ? payload.id : payload;
const serverIp = payload && typeof payload === 'object' ? payload.serverIp : null;
await selectInstance(id);
launch(id);
launch(id, null, serverIp ? { quickPlayServer: serverIp } : null);
});
// Empfänger steht jetzt dem Hauptprozess Bescheid geben, damit ein über
// eine Verknüpfung mitgegebener Startwunsch sicher zugestellt werden kann
window.api.notifyReady();
window.api.onUpdateAvailable(showUpdateDialog);
window.api.onUpdateProgress(onUpdateProgress);
window.api.onUpdateChecked(renderUpdateStatus);
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
await loadMcIcons(); // echte Minecraft-Icons bereitstellen
await applyBedrockVisibility();

View File

@@ -75,9 +75,13 @@ const DEFAULT_SETTINGS = {
sortBy: 'lastPlayed', // lastPlayed | name
iso3dIcons: true, // Block-Symbole räumlich darstellen
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
autostart: false, // mit Windows starten, im Infobereich statt als Fenster
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk
proxyHost: '',
@@ -232,6 +236,24 @@ function addMod(id, entry, art = 'mod') {
return list;
}
/*
* Findet installierte Mods, die den per "filename" identifizierten Mod als
* Pflicht-Abhängigkeit brauchen (requiredDeps wird beim Installieren über
* AeroMC gesetzt von Hand hinzugefügte oder vor diesem Feature installierte
* Mods haben keine requiredDeps und werden daher nicht als Ziel erkannt,
* können aber selbst weiterhin als Abhängiger auftauchen).
* -> [Titel, Titel, ...] der abhängigen Mods
*/
function findDependents(id, filename, art = 'mod') {
const list = listMods(id, art);
const target = list.find((m) => m.filename === filename);
if (!target || !target.projectId) return [];
return list
.filter((m) => m.filename !== filename
&& Array.isArray(m.requiredDeps) && m.requiredDeps.includes(target.projectId))
.map((m) => m.title || m.filename);
}
function removeMod(id, filename, art = 'mod') {
const list = listMods(id, art).filter((m) => m.filename !== filename);
writeJson(modsIndexFile(id, art), list);
@@ -443,6 +465,7 @@ module.exports = {
listMods,
addMod,
removeMod,
findDependents,
setModEnabled,
listInstances,
getInstance,

View File

@@ -141,6 +141,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px}
.card.dragging{opacity:.4}
/* Datei von außen übers Fenster gezogen (.jar/.mrpack/.zip) */
body.file-drop-active::after{
content:"Datei loslassen zum Importieren";
position:fixed;inset:0;z-index:9999;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);
color:#fff;font-size:20px;font-weight:600;
border:3px dashed var(--accent);
pointer-events:none;
}
/* ---------- Leerer Zustand ---------- */
.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px}
.empty h2{color:#333;margin:0}
@@ -364,6 +375,7 @@ img.account-av{object-fit:cover;image-rendering:auto}
.info-card{max-width:760px}
.info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--border2)}
.info-status{font-size:12px;color:var(--dim)}
.modal-sep{border-top:1px solid var(--border2)}
.info-meta{margin-top:10px}
.info-meta .settings-kv-row{align-items:center}
.info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px}

175
src/technicdiscover.js Normal file
View File

@@ -0,0 +1,175 @@
'use strict';
/*
* technicdiscover.js Technic-Modpacks direkt über die Technic-Plattform
* durchsuchen und installieren, ohne vorher manuell eine Datei herunterladen
* zu müssen. Genutzt wird dieselbe öffentliche API wie bei Prism Launcher:
*
* Suche: GET https://api.technicpack.net/search?build=multimc&q=<term>
* Beliebt: GET https://api.technicpack.net/trending?build=multimc
* Pack-Infos: GET https://api.technicpack.net/modpack/<slug>?build=multimc
* -> entweder "url" (ein fertiges ZIP) oder "solder" (Solder-Server)
* Solder: GET <solder>modpack/<slug> Liste der Builds
* GET <solder>modpack/<slug>/<build> Mods dieser Fassung
* { minecraft, forge, mods: [{ name, url, ... }] }
*
* "build=multimc" ist die Kennung, mit der sich auch andere Drittlauncher
* bei der Technic-Plattform melden kein echter Minecraft-Build, sondern
* nur ein Client-Identifier, den die API erwartet.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const forge = require('./forge');
const technicimport = require('./technicimport');
const API_BASE = 'https://api.technicpack.net/';
const API_BUILD = 'multimc';
const UA = 'AeroMC-Launcher/1.0';
async function getJson(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function getBuffer(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return Buffer.from(await res.arrayBuffer());
}
/*
* query leer -> beliebte Packs (trending), sonst Suchtreffer.
* -> [{ slug, name, iconUrl }]
*/
async function search(query) {
const q = String(query || '').trim();
const url = q
? `${API_BASE}search?build=${API_BUILD}&q=${encodeURIComponent(q)}`
: `${API_BASE}trending?build=${API_BUILD}`;
const data = await getJson(url);
const packs = Array.isArray(data.modpacks) ? data.modpacks : [];
return packs
.filter((p) => p && p.slug && p.slug !== 'vanilla')
.map((p) => ({ slug: p.slug, name: p.name || p.slug, iconUrl: p.iconUrl || '' }));
}
/*
* Entpackt einen ZIP-Buffer nach destDir.
* Enthält der ZIP-Inhalt bereits eine erkennbare Ordnerstruktur (mods/,
* config/, …), wird sie 1:1 übernommen. Liegen nur einzelne Dateien direkt
* im ZIP-Wurzelverzeichnis (typisch für ältere, einzeln gepackte Solder-
* Mods), landen sie stattdessen im mods-Ordner.
*/
function extractZipBuffer(buf, destDir) {
const idx = forge.readZipIndex(buf);
const namen = Object.keys(idx);
const hatOrdnerStruktur = namen.some((n) => /^(mods|config|resources|resourcepacks|scripts|structures|bin)\//i.test(n));
for (const name of namen) {
if (name.endsWith('/')) continue; // reines Verzeichnis wird beim Schreiben automatisch angelegt
const relPfad = hatOrdnerStruktur ? name : path.posix.join('mods', name);
const zielPfad = path.join(destDir, ...relPfad.split('/'));
if (!zielPfad.startsWith(destDir + path.sep)) continue; // Pfad-Ausbruch verhindern
fs.mkdirSync(path.dirname(zielPfad), { recursive: true });
fs.writeFileSync(zielPfad, forge.readZipEntry(buf, idx[name]));
}
}
// Solder-Basis-URLs enden mal mit "/api/", mal ohne abschließenden Slash
function solderUrl(basis, pfad) {
const b = String(basis || '').replace(/\/+$/, '');
return `${b}/${pfad}`;
}
async function installSolderBuild(solderBasis, slug, gameDir, onProgress) {
const info = await getJson(solderUrl(solderBasis, 'modpack/' + encodeURIComponent(slug)));
const build = info.recommended || info.latest || (info.builds || [])[0];
if (!build) throw new Error('Keine Fassung (Build) für dieses Modpack gefunden.');
const daten = await getJson(solderUrl(solderBasis, `modpack/${encodeURIComponent(slug)}/${encodeURIComponent(build)}`));
const mods = Array.isArray(daten.mods) ? daten.mods : [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i];
if (onProgress) onProgress({ current: i + 1, total: mods.length, name: mod.name || '' });
if (!mod || !mod.url) continue;
try {
const buf = await getBuffer(mod.url);
extractZipBuffer(buf, gameDir);
} catch { /* einzelner Mod fehlgeschlagen Rest trotzdem weiter versuchen */ }
}
let loader = 'vanilla';
const loaderVersion = daten.forge || '';
if (loaderVersion) loader = 'forge';
return { version: daten.minecraft || '', loader, loaderVersion };
}
async function installSingleZip(url, gameDir) {
const buf = await getBuffer(url);
// in einen Temp-Ordner entpacken, damit wir wie beim Ordner-Import
// (technicimport.js) Version/Loader aus bin/modpack.jar erkennen können,
// bevor die Dateien an ihren endgültigen Platz kommen
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'technic-dl-'));
try {
extractZipBuffer(buf, tmp);
let base = tmp;
if (!fs.existsSync(path.join(base, 'bin')) && !fs.existsSync(path.join(base, 'mods'))) {
// manche Packs haben einen umschließenden Ordner im ZIP
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
.find((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
if (sub) base = sub;
}
const { version, loader, loaderVersion } = technicimport.detectVersion(base);
fs.cpSync(base, gameDir, { recursive: true });
return { version, loader, loaderVersion };
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
}
}
/*
* Lädt ein Modpack von der Technic-Plattform und legt daraus eine neue
* Instanz an. onProgress(info) wird bei Solder-Packs pro Mod aufgerufen.
* -> { ok, instance } oder { ok: false, message }
*/
async function installFromPlatform(store, slug, onProgress) {
let created = null;
try {
const detail = await getJson(`${API_BASE}modpack/${encodeURIComponent(slug)}?build=${API_BUILD}`);
// die Technic-API antwortet bei unbekanntem Slug mit HTTP 200 und
// {"error": "..."} statt einem Fehlerstatus daher explizit prüfen
if (detail.error) throw new Error(detail.error);
created = store.createOrReplaceImportedInstance({
name: detail.displayName || detail.name || slug,
notes: detail.description || '',
});
const gameDir = store.gameDir(created.id);
fs.mkdirSync(gameDir, { recursive: true });
let ergebnis;
if (detail.solder) {
ergebnis = await installSolderBuild(detail.solder, slug, gameDir, onProgress);
} else if (detail.url) {
ergebnis = await installSingleZip(detail.url, gameDir);
} else {
throw new Error('Für dieses Modpack ist kein Download hinterlegt.');
}
store.updateInstance(created.id, {
minecraft: {
version: ergebnis.version || detail.minecraft || '',
loader: ergebnis.loader || 'vanilla',
loaderVersion: ergebnis.loaderVersion || '',
},
});
return { ok: true, instance: store.getInstance(created.id) };
} catch (err) {
// fehlgeschlagene Installation nicht als leere Karteileiche stehen lassen
if (created) { try { store.deleteInstance(created.id); } catch { /* egal */ } }
return { ok: false, message: 'Installation fehlgeschlagen: ' + err.message };
}
}
module.exports = { search, installFromPlatform };

172
src/technicimport.js Normal file
View File

@@ -0,0 +1,172 @@
'use strict';
/*
* technicimport.js Instanzen aus dem Technic Launcher übernehmen
* -------------------------------------------------------------------
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner Welten,
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
* bei einem klassischen .minecraft.
*
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
* schönen Anzeigenamen "installedPacks" im Wurzelordner listet Packs nur
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft wie bei den
* anderen Importern über die tatsächlich vorhandenen Ordner/Dateien.
*/
const fs = require('fs');
const path = require('path');
const forge = require('./forge');
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
const SKIP_DIRS = new Set(['bin']);
function technicRoot() {
return path.join(process.env.APPDATA || '', '.technic');
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
catch { return null; }
}
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
// oder ein einzelner Pack-Ordner angegeben wurde
function resolveModpacksDir(inputPath) {
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
if (!fs.existsSync(root)) return null;
const direct = path.join(root, 'modpacks');
if (fs.existsSync(direct)) return direct;
if (path.basename(root).toLowerCase() === 'modpacks') return root;
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
}
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
function looksLikePack(dir) {
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
return markers.some((name) => fs.existsSync(path.join(dir, name)));
}
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
// als stabile API dokumentiert ist
function readInstalledPacksMeta(root) {
const data = readJson(path.join(root, 'installedPacks'));
const packs = data && data.installedPacks;
return packs && typeof packs === 'object' ? packs : {};
}
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
// bestmöglich schlägt die Erkennung fehl, bleibt die Instanz einfach
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
function detectVersion(packDir) {
const bin = path.join(packDir, 'bin');
const jarFile = path.join(bin, 'modpack.jar');
if (fs.existsSync(jarFile)) {
try {
const buf = fs.readFileSync(jarFile);
const idx = forge.readZipIndex(buf);
const entry = idx['version.json'];
if (entry) {
// manche Jars speichern JSON mit BOM ohne Entfernen bricht JSON.parse
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
const profile = JSON.parse(text);
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
let loader = 'vanilla';
let loaderVersion = '';
for (const lib of libs) {
const name = String((lib && lib.name) || '');
if (/net\.minecraftforge:forge:/i.test(name)) {
loader = 'forge';
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
loader = 'neoforge';
loaderVersion = name.split(':')[2] || '';
}
}
const id = String(profile.id || profile.inheritsFrom || '');
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
if (version) return { version, loader, loaderVersion };
}
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP weiter unten Fallback */ }
}
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
const legacy = path.join(bin, 'version');
try {
if (fs.existsSync(legacy)) {
const version = fs.readFileSync(legacy, 'utf8').trim();
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
}
} catch { /* egal, bleibt leer */ }
return { version: '', loader: 'vanilla', loaderVersion: '' };
}
function scan(inputPath) {
const modpacksDir = resolveModpacksDir(inputPath);
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
? path.dirname(modpacksDir) : modpacksDir;
const meta = readInstalledPacksMeta(root);
const list = [];
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(modpacksDir, entry.name);
if (!looksLikePack(dir)) continue;
const { version, loader, loaderVersion } = detectVersion(dir);
const info = meta[entry.name] || {};
list.push({
folder: entry.name,
dir,
name: info.displayName || entry.name,
group: '',
notes: '',
version,
loader,
loaderVersion,
gameDir: dir,
hasGameData: true,
});
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
}
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
if (SKIP_DIRS.has(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
}
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
notes: entry.notes,
minecraft: {
version: entry.version,
loader: entry.loader,
loaderVersion: entry.loaderVersion,
},
});
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion };