Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87fded4cb6 | ||
|
|
33e533009e | ||
|
|
17f8726075 | ||
|
|
deec93e5fa | ||
|
|
fc57dfa186 | ||
|
|
bafad25059 | ||
|
|
ec4992c5c7 | ||
|
|
3e28b50f1b | ||
|
|
54d248133c |
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# beim Bauen erzeugt, enthält Adresse und Token der CurseForge-Weiterleitung
|
||||
src/build-config.json
|
||||
|
||||
# Zugangsdaten des Weiterleitungs-Servers
|
||||
server/curseforge-proxy/.env
|
||||
@@ -0,0 +1,38 @@
|
||||
AeroMC – Lizenz- und Nutzungsbedingungen
|
||||
|
||||
Copyright (c) M_Viper. Alle Rechte vorbehalten.
|
||||
|
||||
Diese Software („AeroMC") sowie sämtlicher zugehöriger Quellcode, Ressourcen und
|
||||
Dokumentation sind urheberrechtlich geschützt. Mit der Nutzung dieser Software
|
||||
erkennst du die folgenden Bedingungen an.
|
||||
|
||||
1. Erlaubte Nutzung
|
||||
Die Nutzung von AeroMC ist ausschließlich für den privaten, nicht-kommerziellen
|
||||
Gebrauch gestattet.
|
||||
|
||||
2. Untersagt ist insbesondere
|
||||
- Die Weitergabe dieses Launchers oder von Teilen davon an Dritte
|
||||
- Das Verändern, Dekompilieren, Zurückentwickeln (Reverse Engineering) oder
|
||||
Bearbeiten des Codes
|
||||
- Die Veröffentlichung – auch in veränderter oder abgeleiteter Form –
|
||||
an anderer Stelle als den offiziellen Kanälen des Autors
|
||||
- Der Verkauf sowie jede sonstige kommerzielle Nutzung
|
||||
|
||||
3. Keine Gewährleistung
|
||||
Die Software wird ohne jegliche Gewährleistung bereitgestellt, weder
|
||||
ausdrücklich noch stillschweigend, einschließlich, aber nicht beschränkt auf
|
||||
die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten
|
||||
Zweck und der Nichtverletzung von Rechten Dritter. Der Autor haftet nicht für
|
||||
Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung der Software
|
||||
entstehen.
|
||||
|
||||
4. Rechtsfolgen bei Verstoß
|
||||
Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich
|
||||
belangt werden. Alle Rechte, die hier nicht ausdrücklich eingeräumt werden,
|
||||
bleiben beim Autor vorbehalten.
|
||||
|
||||
5. Markenhinweis
|
||||
AeroMC ist kein offizielles Produkt von Mojang oder Microsoft. Minecraft ist
|
||||
eine Marke von Mojang AB.
|
||||
|
||||
Kontakt: https://m-viper.de
|
||||
@@ -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.8
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,23 +21,31 @@ 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), Übernahme einzelner Einträge in andere Instanzen sowie Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt, ab MC 1.20)
|
||||
- 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
|
||||
- Optionaler Autostart mit Windows, dann im Infobereich statt als Fenster
|
||||
- Design Hell/Dunkel und Sortierung der Instanzen
|
||||
|
||||
## Installation
|
||||
@@ -71,18 +79,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, Desktop-Verknüpfung pro Server)
|
||||
- Logdateien und Absturzberichte ansehen
|
||||
- Minecraft-, Konfigurations- und Instanz-Ordner öffnen
|
||||
- Desktop-Verknüpfung erstellen
|
||||
- Exportieren, Kopieren, Gruppe ändern, Löschen
|
||||
@@ -90,6 +98,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 +116,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 +174,12 @@ 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)
|
||||
- Autostart: mit Windows starten, im Infobereich statt als Fenster – solange aktiv, versteckt „Schließen" (X) den Launcher nur, statt ihn zu beenden (über das Symbol im Infobereich wieder öffnen oder wirklich beenden)
|
||||
|
||||
### 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 +205,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
|
||||
|
||||
@@ -185,14 +224,22 @@ Nutzerdaten liegen lokal unter `%APPDATA%\aeromc`.
|
||||
## Links
|
||||
|
||||
- Webseite: https://m-viper.de
|
||||
- Gitseite: https://git.viper.ipv64.net
|
||||
- Launcher-Webseite: https://aeromc.viper.ipv64.net
|
||||
- Autor: M_Viper
|
||||
|
||||
## 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.
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+6
-7
@@ -1,16 +1,14 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.8",
|
||||
"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",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/write-build-config.js",
|
||||
@@ -25,7 +23,8 @@
|
||||
"compression": "store",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
|
||||
+73
-11
@@ -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.' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+25
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+119
-6
@@ -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>
|
||||
@@ -130,6 +130,7 @@
|
||||
<option value="forge">Forge</option>
|
||||
<option value="neoforge">NeoForge</option>
|
||||
<option value="quilt">Quilt</option>
|
||||
<option value="optifine">OptiFine</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
@@ -139,6 +140,23 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="f-optifine-tools" class="field-row hidden" style="align-items:flex-start">
|
||||
<div class="field" style="flex:1">
|
||||
<span>OptiFine einrichten</span>
|
||||
<p class="hint" style="margin:2px 0 8px">
|
||||
OptiFine bietet kein automatisches Download-API (Downloads laufen bei OptiFine bewusst über eine Werbeseite).
|
||||
Lade den Installer zuerst selbst von <b>optifine.net</b> herunter, wähle ihn dann hier aus – AeroMC bereitet
|
||||
die passende Minecraft-Version vor und startet den echten OptiFine-Installer. Wähle darin als Zielordner den
|
||||
automatisch in die Zwischenablage kopierten Pfad, klicke „Install" und bestätige danach hier.
|
||||
</p>
|
||||
<div class="path-row">
|
||||
<button type="button" id="f-optifine-install" class="btn">Installer wählen und starten…</button>
|
||||
<button type="button" id="f-optifine-confirm" class="btn btn-primary hidden">Fertig – Version übernehmen</button>
|
||||
</div>
|
||||
<small id="f-optifine-status" class="hint"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Notizen <small class="opt">(optional)</small></span>
|
||||
<textarea id="f-notes" rows="2" placeholder="Modliste, Server-IP, To-dos …"></textarea>
|
||||
@@ -171,7 +189,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 +275,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 +332,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>
|
||||
@@ -614,6 +660,7 @@
|
||||
<option value="mod">Mods</option>
|
||||
<option value="resourcepack">Ressourcenpakete</option>
|
||||
<option value="shader">Shader</option>
|
||||
<option value="datapack">Datenpakete</option>
|
||||
</select>
|
||||
<input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" />
|
||||
<button id="m-search-btn" class="btn btn-primary">Suchen</button>
|
||||
@@ -659,6 +706,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>
|
||||
@@ -937,6 +985,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Lizenz ===================== -->
|
||||
<div id="modal-license" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
<div class="modal-head">
|
||||
<h3>Lizenz</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="license-text" class="crash-voll">Lade …</pre>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Update ===================== -->
|
||||
<div id="modal-update" class="modal hidden">
|
||||
<div class="modal-card modal-sm">
|
||||
@@ -980,6 +1044,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">
|
||||
@@ -989,11 +1054,18 @@
|
||||
|
||||
<div id="import-pane-modpack" class="import-pane hidden">
|
||||
<p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
|
||||
<p class="hint">Suche läuft über Modrinth. Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json) – dafür ist ein API-Key oder eine Weiterleitung in den Netzwerk-Einstellungen nötig.</p>
|
||||
<p class="hint">Katalog-Suche läuft über Modrinth oder CurseForge. Für CurseForge wird ein eigener API-Key in den Netzwerk-Einstellungen gebraucht (die Weiterleitung reicht nur für den Datei-Import). Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json).</p>
|
||||
|
||||
<div class="field-row" style="margin-top:12px;align-items:flex-end">
|
||||
<label class="field" style="width:150px">
|
||||
<span>Quelle</span>
|
||||
<select id="mp-source">
|
||||
<option value="modrinth">Modrinth</option>
|
||||
<option value="curseforge">CurseForge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" style="flex:1">
|
||||
<span>Suche (Modrinth)</span>
|
||||
<span>Suche</span>
|
||||
<input id="mp-query" type="text" placeholder="z. B. Fabulously Optimized, Create …" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field" style="width:140px">
|
||||
@@ -1089,11 +1161,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 +1213,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>
|
||||
|
||||
+87
-13
@@ -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;
|
||||
@@ -447,6 +464,37 @@ async function prepareAndLaunch(opts, onProgress) {
|
||||
classpath.unshift(path.join(libDir, rel.replace(/\//g, path.sep)));
|
||||
}
|
||||
effectiveVj = mergeLoader(vj, fvj);
|
||||
|
||||
} else if (opts.loader === 'optifine') {
|
||||
/*
|
||||
* OptiFine hat keine öffentliche Installations-API (Downloads laufen bewusst
|
||||
* über eine Werbeseite). AeroMC lädt hier nichts selbst herunter – der Nutzer
|
||||
* hat den offiziellen Installer bereits einmal sichtbar laufen lassen (siehe
|
||||
* optifine:setup/optifine:confirm in main.js), der dabei ein normales
|
||||
* Versions-Profil unter <sharedDir>/versions/<loaderVersion>/ abgelegt hat,
|
||||
* genau wie Fabric/Forge es tun. Wir lesen es nur noch ein und mergen es.
|
||||
*/
|
||||
const ofId = opts.loaderVersion;
|
||||
if (!ofId) {
|
||||
return { ok: false, message: 'Für diese Instanz ist keine OptiFine-Version hinterlegt. Bitte im Bearbeiten-Dialog einrichten.' };
|
||||
}
|
||||
const ofJsonPath = path.join(opts.sharedDir, 'versions', ofId, ofId + '.json');
|
||||
if (!fs.existsSync(ofJsonPath)) {
|
||||
return { ok: false, message: `OptiFine-Profil "${ofId}" wurde nicht gefunden. Bitte die Einrichtung im Bearbeiten-Dialog wiederholen.` };
|
||||
}
|
||||
let ofvj;
|
||||
try { ofvj = JSON.parse(fs.readFileSync(ofJsonPath, 'utf8')); }
|
||||
catch (err) { return { ok: false, message: 'OptiFine-Profil ist beschädigt: ' + err.message }; }
|
||||
for (const lib of ofvj.libraries || []) {
|
||||
const art = lib.downloads && lib.downloads.artifact;
|
||||
const rel = (art && art.path) || forge.mavenPath(lib.name);
|
||||
const dest = path.join(libDir, rel.replace(/\//g, path.sep));
|
||||
if (!fs.existsSync(dest) && art && art.url) {
|
||||
await downloadIfNeeded(art.url, dest, art.sha1 || null);
|
||||
}
|
||||
classpath.unshift(dest);
|
||||
}
|
||||
effectiveVj = mergeLoader(vj, ofvj);
|
||||
}
|
||||
|
||||
const finalClasspath = dedupeClasspath(classpath, opts.sharedDir);
|
||||
@@ -503,14 +551,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 +588,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 };
|
||||
|
||||
+632
-52
@@ -6,11 +6,11 @@
|
||||
* (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, clipboard } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execFile } = require('child_process');
|
||||
const { execFile, spawn } = require('child_process');
|
||||
const pkg = require('../package.json');
|
||||
const store = require('./store');
|
||||
const services = require('./services');
|
||||
@@ -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,13 +504,166 @@ 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); }
|
||||
catch { return []; }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OptiFine – kein offizielles Download-API (Downloads laufen bei OptiFine
|
||||
// bewusst über eine Werbeseite, das umgehen wir nicht). Stattdessen: Nutzer
|
||||
// lädt den Installer selbst von optifine.net, AeroMC bereitet Vanilla vor,
|
||||
// startet den echten Installer sichtbar mit dem passenden Zielordner in der
|
||||
// Zwischenablage und übernimmt danach die neu entstandene Profil-Version.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const optifineSetupState = new Map(); // instanceId -> { sharedDir, before: Set<string> }
|
||||
|
||||
function scanOptifineVersions(sharedDir, mcVersion) {
|
||||
const versionsDir = path.join(sharedDir, 'versions');
|
||||
if (!fs.existsSync(versionsDir)) return [];
|
||||
const prefix = String(mcVersion || '');
|
||||
return fs.readdirSync(versionsDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.filter((name) => /optifine/i.test(name) && (!prefix || name.startsWith(prefix)))
|
||||
.filter((name) => fs.existsSync(path.join(versionsDir, name, name + '.json')))
|
||||
.sort();
|
||||
}
|
||||
|
||||
ipcMain.handle('optifine:find', (_e, mcVersion) => {
|
||||
const sharedDir = path.join(app.getPath('userData'), 'shared');
|
||||
return scanOptifineVersions(sharedDir, mcVersion);
|
||||
});
|
||||
|
||||
ipcMain.handle('optifine:setup', async (_e, id) => {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||
if (!inst.minecraft.version) return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen.' };
|
||||
|
||||
const picked = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'OptiFine-Installer wählen (zuvor von optifine.net heruntergeladen)',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'OptiFine-Installer', extensions: ['jar'] }],
|
||||
});
|
||||
if (picked.canceled || !picked.filePaths.length) return { ok: false, canceled: true };
|
||||
const installerPath = picked.filePaths[0];
|
||||
|
||||
const settings = store.getSettings();
|
||||
const sharedDir = path.join(app.getPath('userData'), 'shared');
|
||||
const opts = {
|
||||
versionId: inst.minecraft.version,
|
||||
loader: 'vanilla',
|
||||
loaderVersion: '',
|
||||
gameDir: store.gameDir(id),
|
||||
sharedDir,
|
||||
userDataDir: app.getPath('userData'),
|
||||
javaPath: inst.java.path || settings.javaPath || '',
|
||||
minMemMb: inst.java.minMemMb || settings.defaultMinMemMb,
|
||||
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
|
||||
extraArgs: '',
|
||||
auth: null,
|
||||
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
|
||||
};
|
||||
|
||||
// Vanilla-Dateien sicherstellen (kein echter Start, da kein Konto übergeben wird)
|
||||
let prep;
|
||||
try {
|
||||
prep = await launcher.prepareAndLaunch(opts, (evt) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message };
|
||||
}
|
||||
if (!prep.needAuth && !prep.ok) return { ok: false, message: prep.message || 'Vorbereitung fehlgeschlagen.' };
|
||||
const javaExe = (prep.java && prep.java.path) || opts.javaPath || 'java';
|
||||
|
||||
optifineSetupState.set(id, {
|
||||
sharedDir,
|
||||
before: new Set(scanOptifineVersions(sharedDir, inst.minecraft.version)),
|
||||
});
|
||||
|
||||
try {
|
||||
const child = spawn(javaExe, ['-jar', installerPath], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Installer konnte nicht gestartet werden: ' + err.message };
|
||||
}
|
||||
try { clipboard.writeText(sharedDir); } catch { /* egal */ }
|
||||
|
||||
return { ok: true, sharedDir };
|
||||
});
|
||||
|
||||
ipcMain.handle('optifine:confirm', (_e, id) => {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||
const state = optifineSetupState.get(id);
|
||||
const sharedDir = (state && state.sharedDir) || path.join(app.getPath('userData'), 'shared');
|
||||
const nachher = scanOptifineVersions(sharedDir, inst.minecraft.version);
|
||||
const vorher = (state && state.before) || new Set();
|
||||
const neu = nachher.filter((v) => !vorher.has(v));
|
||||
const treffer = neu[0] || nachher[0];
|
||||
optifineSetupState.delete(id);
|
||||
if (!treffer) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Keine OptiFine-Version für ' + (inst.minecraft.version || '?') + ' gefunden. '
|
||||
+ 'Wurde die Installation im OptiFine-Fenster abgeschlossen und dabei der kopierte Ordner ausgewählt?',
|
||||
};
|
||||
}
|
||||
return { ok: true, versionId: treffer };
|
||||
});
|
||||
|
||||
ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod'));
|
||||
// prüft, ob andere installierte Mods diesen hier als Pflicht-Abhängigkeit brauchen
|
||||
ipcMain.handle('mods:dependents', (_e, id, filename, art) => store.findDependents(id, filename, art || 'mod'));
|
||||
|
||||
// Vorhandener Arbeitsspeicher – Grundlage für die Prüfung der RAM-Angaben
|
||||
function gesamtSpeicherMb() {
|
||||
@@ -432,6 +707,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 +1024,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 +1079,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 +1242,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 +1264,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,
|
||||
@@ -1034,8 +1358,13 @@ async function importCurseForge(buf, zip, zugang) {
|
||||
}
|
||||
|
||||
ipcMain.handle('modpack:search', async (_e, opts) => {
|
||||
const o = opts || {};
|
||||
try {
|
||||
const q = Object.assign({}, opts || {}, { art: 'modpack' });
|
||||
if (o.source === 'curseforge') {
|
||||
const zugang = services.curseforgeZugang(store.getSettings());
|
||||
return await services.curseforgeSearch(zugang, Object.assign({}, o, { art: 'modpack' }));
|
||||
}
|
||||
const q = Object.assign({}, o, { art: 'modpack' });
|
||||
return await services.modrinthSearch(q);
|
||||
} catch (err) {
|
||||
return { total: 0, hits: [], error: err.message };
|
||||
@@ -1085,12 +1414,93 @@ 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 };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
|
||||
try {
|
||||
const zugang = services.curseforgeZugang(store.getSettings());
|
||||
if (!zugang) {
|
||||
return { ok: false, needsKey: true,
|
||||
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
|
||||
}
|
||||
const rawId = String((project && (project.cfId || project.projectId || project.id)) || '').replace(/^cf-/, '');
|
||||
const modId = Number(rawId);
|
||||
if (!modId) return { ok: false, message: 'Kein Modpack gewählt.' };
|
||||
const gameVersion = (project && project.gameVersion) || '';
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('launch:progress', {
|
||||
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
|
||||
});
|
||||
}
|
||||
const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion);
|
||||
if (!file || !file.url) {
|
||||
return {
|
||||
ok: false,
|
||||
message: gameVersion
|
||||
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
|
||||
: 'Keine passende Modpack-Datei gefunden.',
|
||||
};
|
||||
}
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('launch:progress', {
|
||||
phase: 'modpack', detail: 'Lade ' + file.filename + ' …',
|
||||
});
|
||||
}
|
||||
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
||||
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen);
|
||||
const zip = forge.readZipIndex(buf);
|
||||
if (!zip['manifest.json']) {
|
||||
return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' };
|
||||
}
|
||||
const result = await importCurseForge(buf, zip, zugang);
|
||||
if (result && result.ok && result.instance) {
|
||||
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 +1508,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 +1541,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 +1629,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 +1738,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 +2027,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 +2056,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 +2085,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 +2097,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 +2235,15 @@ 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 || '',
|
||||
}));
|
||||
|
||||
// Lizenztext für die Info-Seite in den Einstellungen. Die LICENSE-Datei liegt
|
||||
// im App-Root und wird über "files" im electron-builder-Config mitgepackt.
|
||||
ipcMain.handle('app:license', () => {
|
||||
try {
|
||||
return fs.readFileSync(path.join(app.getAppPath(), 'LICENSE'), 'utf8');
|
||||
} catch {
|
||||
return 'Lizenztext konnte nicht geladen werden.';
|
||||
}
|
||||
});
|
||||
|
||||
+150
@@ -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 };
|
||||
+42
-3
@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// Minecraft-Versionen (Mojang)
|
||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
||||
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
|
||||
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
|
||||
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
|
||||
|
||||
// Mods (Modrinth)
|
||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||
@@ -27,6 +30,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 +47,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 +74,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,16 +90,24 @@ 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),
|
||||
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
|
||||
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
||||
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
||||
onLaunchProgress: (cb) => {
|
||||
@@ -169,6 +184,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 +222,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);
|
||||
@@ -202,4 +240,5 @@ contextBridge.exposeInMainWorld('api', {
|
||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||
appInfo: () => ipcRenderer.invoke('app:info'),
|
||||
appLicense: () => ipcRenderer.invoke('app:license'),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+633
-54
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -236,6 +236,7 @@ const INHALTSARTEN = {
|
||||
mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true },
|
||||
resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false },
|
||||
shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false },
|
||||
datapack: { projectType: 'datapack', ordner: 'datapacks', label: 'Datenpakete', loaderNoetig: false },
|
||||
modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false },
|
||||
};
|
||||
|
||||
@@ -515,7 +516,9 @@ function forgeCdnUrl(fileId, fileName) {
|
||||
}
|
||||
|
||||
const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 };
|
||||
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552 };
|
||||
// classId je Inhaltsart bei gameId 432 (Minecraft). modpack=4471 ist die auf
|
||||
// CurseForge selbst verwendete Kategorie-ID (siehe curseforge.com/minecraft/modpacks).
|
||||
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552, modpack: 4471 };
|
||||
|
||||
function cfLoaderType(loader) {
|
||||
return CF_LOADER[String(loader || '').toLowerCase()] || 0;
|
||||
@@ -598,7 +601,12 @@ async function curseforgeSearch(zugang, {
|
||||
+ 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.',
|
||||
};
|
||||
}
|
||||
const classId = CF_CLASS[art] || CF_CLASS.mod;
|
||||
if (!CF_CLASS[art]) {
|
||||
// Kein zugeordneter CurseForge-Klassencode für diese Inhaltsart -> lieber gar
|
||||
// keine Treffer als falsch einsortierte (z. B. Mods unter "Datenpakete").
|
||||
return { total: 0, hits: [], error: 'CurseForge unterstützt diese Inhaltsart hier nicht – bitte Modrinth nutzen.' };
|
||||
}
|
||||
const classId = CF_CLASS[art];
|
||||
const url = new URL(CURSEFORGE + '/mods/search');
|
||||
url.searchParams.set('gameId', '432');
|
||||
url.searchParams.set('classId', String(classId));
|
||||
@@ -713,6 +721,12 @@ async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
|
||||
};
|
||||
}
|
||||
|
||||
// Beste Modpack-Datei eines CurseForge-Projekts auflösen (für den Katalog-Import,
|
||||
// analog zu modrinthResolveModpack).
|
||||
async function curseforgeResolveModpackFile(zugang, modId, gameVersion) {
|
||||
return curseforgePickFile(zugang, modId, gameVersion, '', 'modpack');
|
||||
}
|
||||
|
||||
async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
|
||||
if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' };
|
||||
const rawId = String(projectId || '').replace(/^cf-/, '');
|
||||
@@ -814,4 +828,5 @@ module.exports = {
|
||||
modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
|
||||
modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
|
||||
curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
|
||||
curseforgeResolveModpackFile,
|
||||
};
|
||||
|
||||
+29
-2
@@ -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: '',
|
||||
@@ -130,8 +134,12 @@ function gameDir(id) {
|
||||
* "mod" behält die bisherigen Namen, damit vorhandene Instanzen unverändert
|
||||
* weiterlaufen.
|
||||
*/
|
||||
const INHALT_ORDNER = { mod: 'mods', resourcepack: 'resourcepacks', shader: 'shaderpacks' };
|
||||
const INHALT_INDEX = { mod: 'mods.json', resourcepack: 'resourcepacks.json', shader: 'shaders.json' };
|
||||
const INHALT_ORDNER = {
|
||||
mod: 'mods', resourcepack: 'resourcepacks', shader: 'shaderpacks', datapack: 'datapacks',
|
||||
};
|
||||
const INHALT_INDEX = {
|
||||
mod: 'mods.json', resourcepack: 'resourcepacks.json', shader: 'shaders.json', datapack: 'datapacks.json',
|
||||
};
|
||||
|
||||
function inhaltsDir(id, art = 'mod') {
|
||||
return path.join(gameDir(id), INHALT_ORDNER[art] || INHALT_ORDNER.mod);
|
||||
@@ -232,6 +240,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 +469,7 @@ module.exports = {
|
||||
listMods,
|
||||
addMod,
|
||||
removeMod,
|
||||
findDependents,
|
||||
setModEnabled,
|
||||
listInstances,
|
||||
getInstance,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
+11
-2
@@ -230,8 +230,17 @@ async function downloadAndInstall(asset, onProgress) {
|
||||
|
||||
const dir = updateDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||
fs.rmSync(dest, { force: true });
|
||||
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||
try {
|
||||
fs.rmSync(dest, { force: true });
|
||||
} catch {
|
||||
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
||||
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
||||
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
||||
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
||||
const parsed = path.parse(dest);
|
||||
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
||||
}
|
||||
|
||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
|
||||
Reference in New Issue
Block a user