Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bafad25059 | ||
| ec4992c5c7 | |||
| 3e28b50f1b | |||
|
|
54d248133c | ||
|
|
129ab2d939 | ||
| b250ef16fd | |||
|
|
9e601b1962 | ||
|
|
176d20ae7b |
224
README.md
224
README.md
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
|
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
|
||||||
|
|
||||||
Aktueller Stand: Version 1.0.3
|
Aktueller Stand: Version 1.0.6
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,86 +20,204 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
|
|||||||
|
|
||||||
- Beliebig viele getrennte Minecraft-Instanzen
|
- Beliebig viele getrennte Minecraft-Instanzen
|
||||||
- Unterstützung für Vanilla, Fabric, Quilt, Forge und NeoForge
|
- Unterstützung für Vanilla, Fabric, Quilt, Forge und NeoForge
|
||||||
- Modrinth-Integration mit einfacher Mod-Installation inklusive Pflicht-Abhängigkeiten
|
- Versionsliste mit Filter: Releases, Snapshots oder alle
|
||||||
|
- 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
|
- Microsoft-Login mit mehreren Konten
|
||||||
- Gemeinsamer Import-Dialog für AeroMC-ZIP, Modrinth-Modpacks, den Minecraft Launcher, CurseForge sowie MultiMC- und Prism-Instanzen
|
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, MultiMC/Prism sowie Technic
|
||||||
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen statt Dubletten anzulegen
|
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen
|
||||||
- Export, Kopieren, Gruppieren und Verwalten von Instanzen
|
- Export, Kopieren, Gruppieren, Löschen und Desktop-Verknüpfung pro Instanz
|
||||||
- Mehrfachauswahl mit Strg-Klick für Sammellöschen mehrerer Instanzen
|
- Mehrfachauswahl mit Strg-Klick für Sammellöschen, Tastenkürzel für die wichtigsten Aktionen (Strg+F, Strg+N, Entf)
|
||||||
- Globales Backup und Wiederherstellung für alle Instanzen direkt im Einstellungsbereich
|
- Mods/Modpacks/Instanzen auch per Drag & Drop aufs Fenster ziehen
|
||||||
- Verwaltung von Welten, Screenshots und Spielordnern direkt aus dem Launcher
|
- Mods zusätzlich als eigene Datei hinzufügen, wenn sie nicht über Modrinth/CurseForge zu finden sind
|
||||||
- Bedrock-Bereich für Start und Verwaltung vorhandener Bedrock-Daten unter Windows
|
- Warnt beim Entfernen eines Mods, falls ein anderer installierter Mod ihn noch als Pflicht-Abhängigkeit braucht
|
||||||
- Update-Funktion für neue Launcher-Versionen
|
- Globales Backup inkl. automatischer Zeitplan-Sicherungen, dazu Schnellsicherung einzelner Welten
|
||||||
|
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
|
||||||
|
- Serverliste mit Live-Status (Spieleranzahl, MOTD, Version) und Übernahme einzelner Einträge in andere Instanzen
|
||||||
|
- Online- und Offline-Start
|
||||||
|
- Eingebaute Konsole/Log-Ansicht 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), 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, inkl. Installation zusätzlicher Add-ons
|
||||||
|
- Update-Suche läuft automatisch beim Start, dazu manuelle Prüfung in den Einstellungen
|
||||||
|
- Design Hell/Dunkel und Sortierung der Instanzen
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Es gibt zwei Varianten:
|
Es gibt zwei Varianten:
|
||||||
|
|
||||||
- AeroMC-Setup.exe installiert den Launcher und sorgt im Alltag für den schnelleren Start
|
- **AeroMC-Setup.exe** – installiert den Launcher, schneller Start im Alltag
|
||||||
- AeroMC-Portable.exe startet ohne Installation direkt als portable Variante
|
- **AeroMC-Portable.exe** – startet ohne Installation, ideal zum Mitnehmen (etwas längerer Start)
|
||||||
|
|
||||||
Für die meisten Nutzer ist die Setup-Version die bessere Wahl.
|
Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable nur Portable-Updates. Ein automatischer Wechsel zwischen beiden Varianten findet nicht statt.
|
||||||
|
|
||||||
Die portable Datei ist praktisch zum Mitnehmen, braucht beim Öffnen aber in der Regel etwas länger.
|
|
||||||
|
|
||||||
Automatische Updates bleiben dabei sauber getrennt: Die Setup-Version erhält nur Setup-Updates, die portable Variante nur Portable-Updates. Ein Wechsel zwischen beiden Varianten passiert nicht automatisch.
|
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Windows x64
|
- Windows x64
|
||||||
- Eine legale Minecraft Java Edition mit Microsoft-Konto für Java-Start und Anmeldung
|
- Legale Minecraft Java Edition mit Microsoft-Konto
|
||||||
- Internetverbindung beim ersten Download von Minecraft-Dateien, Loadern oder Mods
|
- Internetverbindung beim ersten Download von Minecraft-Dateien, Loadern oder Inhalten
|
||||||
- Für Bedrock-Funktionen: installierte Minecraft Bedrock Edition aus dem Microsoft Store
|
- Für Bedrock: installierte Minecraft Bedrock Edition aus dem Microsoft Store
|
||||||
|
|
||||||
## Erste Schritte
|
## Erste Schritte
|
||||||
|
|
||||||
1. AeroMC installieren oder die portable Datei starten.
|
1. AeroMC installieren oder die portable Datei starten.
|
||||||
2. Über Konten ein Microsoft-Konto hinzufügen.
|
2. Über **Konten** ein Microsoft-Konto hinzufügen.
|
||||||
3. Eine neue Instanz anlegen.
|
3. Eine neue Instanz anlegen.
|
||||||
4. Minecraft-Version und bei Bedarf einen Loader auswählen.
|
4. Minecraft-Version und optional einen Loader wählen.
|
||||||
5. Optional Mods hinzufügen oder vorhandene Instanzen bzw. Modpacks über Importieren übernehmen.
|
5. Optional Mods/Ressourcenpakete/Shader hinzufügen oder bestehende Setups über **Importieren** übernehmen.
|
||||||
6. Instanz starten.
|
6. Instanz starten (online oder offline).
|
||||||
|
|
||||||
|
## Instanzen
|
||||||
|
|
||||||
|
Jede Instanz hat:
|
||||||
|
|
||||||
|
- Namen und optionale Gruppe
|
||||||
|
- Minecraft-Version und optionalen Mod-Loader
|
||||||
|
- 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 (inkl. Schnellsicherung einzelner Welten), Datenpakete und Bildschirmfotos
|
||||||
|
- Server verwalten (Live-Status, Übernahme eines Eintrags in andere Instanzen)
|
||||||
|
- Logdateien und Absturzberichte ansehen
|
||||||
|
- Minecraft-, Konfigurations- und Instanz-Ordner öffnen
|
||||||
|
- Desktop-Verknüpfung erstellen
|
||||||
|
- Exportieren, Kopieren, Gruppe ändern, Löschen
|
||||||
|
- Bei importierten Modpacks: Modpack aktualisieren
|
||||||
|
|
||||||
|
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
|
## Import
|
||||||
|
|
||||||
Über Importieren in der oberen Leiste können mehrere Quellen übernommen werden:
|
Über **Importieren**:
|
||||||
|
|
||||||
- AeroMC-ZIP-Archive mit instance.json
|
| Quelle | Typische Nutzung |
|
||||||
- Modrinth-Modpacks im .mrpack-Format
|
|---|---|
|
||||||
- Profile aus dem offiziellen Minecraft Launcher, typischerweise aus C:\Users\DeinName\AppData\Roaming\.minecraft
|
| ZIP-Archiv | AeroMC-Export (`instance.json`) sowie einzelne MultiMC/Prism-Instanzen (auch gezippt) |
|
||||||
- CurseForge-Instanzen, typischerweise aus C:\Users\DeinName\curseforge\minecraft\Instances
|
| Modpack | Modrinth-Katalog direkt durchsuchen und installieren, oder Datei importieren: `.mrpack` sowie CurseForge-ZIP (`manifest.json`) |
|
||||||
- MultiMC- und Prism-Instanzen
|
| 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`) |
|
||||||
|
|
||||||
Wenn bereits eine Instanz mit demselben Namen existiert, wird sie beim Import ersetzt statt zusätzlich angelegt.
|
Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
|
||||||
|
|
||||||
Beim Import aus dem offiziellen Minecraft Launcher werden nur echte benutzerdefinierte Profile angezeigt. Standardprofile wie Latest Release oder Latest Snapshot werden ausgeblendet. Nutzt ein Profil den gemeinsamen Standard-Spielordner, übernimmt AeroMC daraus nur spielrelevante Daten wie Mods, Welten und Konfiguration statt den kompletten Launcher-Cache zu kopieren.
|
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 (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. 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
|
## Backup und Wiederherstellung
|
||||||
|
|
||||||
Im Bereich Einstellungen > Backup können komplette Sicherungen aller Instanzen erstellt und wieder eingespielt werden.
|
Unter **Optionen → Backup**:
|
||||||
|
|
||||||
- Backups werden automatisch unter Dokumente\AeroMC Launcher\Backups gespeichert
|
- Manuelles globales Backup (alle Instanzen + Launcher-Einstellungen)
|
||||||
- Gesichert werden alle Instanzen sowie die Launcher-Einstellungen
|
- Automatische Sicherungen (täglich oder wöchentlich, einstellbare Anzahl)
|
||||||
- Microsoft-Konten und Tokens werden bewusst nicht mitgesichert
|
- Optional zusätzlich vor jedem Spielstart sichern
|
||||||
- Vorhandene Backups werden im Wiederherstellen-Bereich direkt in einem Dropdown aufgelistet
|
- Wiederherstellung aus vorhandenem Backup
|
||||||
|
|
||||||
Wichtig: Beim Wiederherstellen werden alle aktuell vorhandenen Instanzen mit dem Inhalt des gewählten Backups überschrieben. Die Launcher-Einstellungen werden ebenfalls aus dem Backup übernommen.
|
Speicherort: `Dokumente\AeroMC Launcher\Backups`.
|
||||||
|
|
||||||
## Was AeroMC verwaltet
|
**Wichtig:** Konten und Tokens werden nicht mitgesichert. Beim Wiederherstellen werden aktuelle Instanzen und Launcher-Einstellungen durch den Backup-Inhalt ersetzt.
|
||||||
|
|
||||||
- Minecraft-Versionen und gemeinsame Spieldateien
|
## Einstellungen
|
||||||
- Instanzbezogene Mods und Konfigurationen
|
|
||||||
- Welten und Screenshots
|
|
||||||
- Mehrere Microsoft-Konten
|
|
||||||
- Java-Auswahl pro Instanz
|
|
||||||
|
|
||||||
Bei der Instanzliste unterstützt AeroMC außerdem Strg-Klick für Mehrfachauswahl und Sammellöschen.
|
### Allgemein
|
||||||
|
- Design (Dunkel / Hell)
|
||||||
|
- 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)
|
||||||
|
|
||||||
Alle Nutzerdaten werden lokal unter %APPDATA%\aeromc gespeichert.
|
### Darstellung
|
||||||
|
- Block-Symbole räumlich (3D)
|
||||||
|
- Skin-Hintergrund: Quelle, Pose, Deckkraft, optional automatischer Wechsel zwischen mehreren angemeldeten Konten (mit einstellbarem Zeitabstand)
|
||||||
|
|
||||||
|
### Spiel
|
||||||
|
- Standard-RAM (min./max.)
|
||||||
|
- Fenstergröße bzw. maximiert starten
|
||||||
|
|
||||||
|
### Start & Java
|
||||||
|
- Globale Java-Auswahl und Erkennung
|
||||||
|
- Fehlende Java-Version automatisch herunterladen
|
||||||
|
- Konsole: während des Spiels, nach Beenden schließen, bei Absturz anzeigen, Historie-Limit
|
||||||
|
- Globale Pre-Launch- / Post-Exit-Befehle
|
||||||
|
- Optionaler Wrapper-Befehl
|
||||||
|
|
||||||
|
### Netzwerk
|
||||||
|
- HTTP-Proxy (Host, Port, optional Benutzer/Passwort)
|
||||||
|
- CurseForge: eigener API-Key und/oder Weiterleitungs-Adresse inkl. Token
|
||||||
|
|
||||||
|
Ohne eigenen Key und ohne Weiterleitung funktionieren Modrinth-Inhalte weiterhin. Für CurseForge-Downloads wird einer der beiden Wege benötigt.
|
||||||
|
|
||||||
|
### Konten
|
||||||
|
- Mehrere Microsoft-Konten verwalten, aktiv setzen, entfernen
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
- Manuell, Zeitplan und Wiederherstellung
|
||||||
|
|
||||||
|
### Info
|
||||||
|
- 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
|
## Bedrock
|
||||||
|
|
||||||
Der Bedrock-Bereich ist für bereits installierte Bedrock-Versionen unter Windows gedacht. AeroMC kann Bedrock nicht selbst installieren, aber vorhandene Welten, Pakete, Add-ons und Ordner leichter zugänglich machen.
|
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
|
||||||
|
|
||||||
|
- Konsole über die obere Leiste oder bei Absturz
|
||||||
|
- Pro Instanz: gespeicherte Logdateien und Crash-Reports einsehen, kopieren und Ordner öffnen
|
||||||
|
|
||||||
|
## Datenort
|
||||||
|
|
||||||
|
Nutzerdaten liegen lokal unter `%APPDATA%\aeromc`.
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
@@ -112,6 +230,14 @@ Der Bedrock-Bereich ist für bereits installierte Bedrock-Versionen unter Window
|
|||||||
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.
|
Minecraft ist eine Marke von Mojang AB.
|
||||||
|
|
||||||
## Lizenz
|
### Urheberrecht & Nutzungsbedingungen
|
||||||
|
|
||||||
MIT
|
AeroMC ist urheberrechtlich geschützt. © M_Viper – Alle Rechte vorbehalten.
|
||||||
|
|
||||||
|
- ❌ Die Weitergabe dieses Launchers an Dritte ist strengstens untersagt
|
||||||
|
- ❌ Das Verändern, Dekompilieren oder Bearbeiten des Codes ist nicht erlaubt
|
||||||
|
- ❌ Eine Veröffentlichung – auch in veränderter Form – ist verboten
|
||||||
|
- ❌ Der Verkauf oder die kommerzielle Nutzung ist nicht gestattet
|
||||||
|
- ✅ Die Nutzung ist ausschließlich für den privaten, nicht-kommerziellen Gebrauch erlaubt
|
||||||
|
|
||||||
|
Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich belangt werden.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"version": "1.0.1-beta",
|
"version": "1.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"version": "1.0.1-beta",
|
"version": "1.0.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"productName": "AeroMC",
|
"productName": "AeroMC",
|
||||||
"version": "1.0.3",
|
"version": "1.0.6",
|
||||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||||
"homepage": "https://m-viper.de",
|
"homepage": "https://m-viper.de",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ const path = require('path');
|
|||||||
const { execFile, spawn } = require('child_process');
|
const { execFile, spawn } = require('child_process');
|
||||||
|
|
||||||
const PKG_FAMILY = 'Microsoft.MinecraftUWP_8wekyb3d8bbwe';
|
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() {
|
function comMojangDir() {
|
||||||
return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY,
|
return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY,
|
||||||
@@ -49,30 +54,87 @@ async function detect() {
|
|||||||
// Start
|
// 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.
|
* Startet Bedrock direkt.
|
||||||
* Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen – sonst
|
* Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen – sonst
|
||||||
* öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments.
|
* öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments.
|
||||||
*/
|
*/
|
||||||
function launch() {
|
async function launch() {
|
||||||
const target = 'shell:appsFolder\\' + APP_ID;
|
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 = [
|
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',
|
['powershell', ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
|
||||||
`Start-Process explorer.exe -ArgumentList '${target}'`], {}],
|
`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) {
|
for (const [cmd, args, extra] of versuche) {
|
||||||
try {
|
try {
|
||||||
const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra));
|
const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra));
|
||||||
p.unref();
|
p.unref();
|
||||||
return { ok: true, method: cmd };
|
methode = cmd;
|
||||||
} catch { /* nächsten Weg versuchen */ }
|
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.' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"curseforgeProxyUrl": "",
|
"curseforgeProxyUrl": "",
|
||||||
"curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J"
|
"curseforgeProxyToken": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,18 @@ function neuesterBericht(gameDir, seit) {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Zieht die aussagekräftigen Zeilen aus einem Bericht.
|
* Zieht die aussagekräftigen Zeilen aus einem Bericht.
|
||||||
* -> { beschreibung, fehler, verdaechtigeMods, verlauf: [] }
|
* -> { beschreibung, fehler, verdaechtigeMods, diagnose, empfehlung, verlauf: [] }
|
||||||
*/
|
*/
|
||||||
function auswerten(text) {
|
function auswerten(text) {
|
||||||
const zeilen = String(text || '').split(/\r?\n/);
|
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++) {
|
for (let i = 0; i < zeilen.length; i++) {
|
||||||
const z = zeilen[i];
|
const z = zeilen[i];
|
||||||
@@ -86,6 +93,22 @@ function auswerten(text) {
|
|||||||
const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim()));
|
const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim()));
|
||||||
if (treffer) befund.fehler = treffer.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;
|
return befund;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
181
src/index.html
181
src/index.html
@@ -10,7 +10,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
|
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
|
||||||
<header class="toolbar">
|
<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-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>
|
<button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button>
|
||||||
<span class="tbsep"></span>
|
<span class="tbsep"></span>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
|
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
|
||||||
<span class="tbgrow"></span>
|
<span class="tbgrow"></span>
|
||||||
<div class="search-wrap">
|
<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>
|
</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-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>
|
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
<button id="p-mods" class="sbtn">Mods anzeigen</button>
|
<button id="p-mods" class="sbtn">Mods anzeigen</button>
|
||||||
<button id="p-worlds" class="sbtn">Welten verwalten</button>
|
<button id="p-worlds" class="sbtn">Welten verwalten</button>
|
||||||
<button id="p-screens" class="sbtn">Bildschirmfotos verwalten</button>
|
<button id="p-screens" class="sbtn">Bildschirmfotos verwalten</button>
|
||||||
|
<button id="p-modpack" class="sbtn hidden">Modpack aktualisieren</button>
|
||||||
<button id="p-logs" class="sbtn">Logdateien ansehen</button>
|
<button id="p-logs" class="sbtn">Logdateien ansehen</button>
|
||||||
<button id="p-servers" class="sbtn">Server verwalten</button>
|
<button id="p-servers" class="sbtn">Server verwalten</button>
|
||||||
<div class="hr"></div>
|
<div class="hr"></div>
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
<div class="hr"></div>
|
<div class="hr"></div>
|
||||||
<button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button>
|
<button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button>
|
||||||
<button id="p-export" class="sbtn">Instanz exportieren</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>
|
<button id="p-copy" class="sbtn">Instanz kopieren</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -157,9 +158,24 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<small id="f-mem-hint" class="hint"></small>
|
<small id="f-mem-hint" class="hint"></small>
|
||||||
|
<label class="field">
|
||||||
|
<span>Java (java.exe)</span>
|
||||||
|
<div class="path-row">
|
||||||
|
<input id="f-java" type="text" placeholder="leer = globale Einstellung bzw. automatisch" />
|
||||||
|
<button id="f-java-pick" class="btn" type="button">Wählen…</button>
|
||||||
|
</div>
|
||||||
|
<div class="path-row">
|
||||||
|
<select id="f-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
|
||||||
|
</div>
|
||||||
|
<small id="f-java-hint" class="hint"></small>
|
||||||
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Zusätzliche JVM-Argumente</span>
|
<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>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Pre-Launch-Befehl <small class="opt">(läuft vor dem Start)</small></span>
|
<span>Pre-Launch-Befehl <small class="opt">(läuft vor dem Start)</small></span>
|
||||||
@@ -241,6 +257,14 @@
|
|||||||
</label>
|
</label>
|
||||||
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
|
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,6 +306,14 @@
|
|||||||
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
|
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,8 +444,12 @@
|
|||||||
<h5>CurseForge</h5>
|
<h5>CurseForge</h5>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Eigener API-Key <small class="opt">(optional)</small></span>
|
<span>Eigener API-Key <small class="opt">(optional)</small></span>
|
||||||
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
|
<div class="path-row">
|
||||||
<small class="hint">Kostenlos unter console.curseforge.com. Ist hier ein Schlüssel eingetragen, geht der Launcher direkt zu CurseForge und nutzt keine Weiterleitung.</small>
|
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
|
||||||
|
<button type="button" id="s-cf-test" class="btn">Prüfen</button>
|
||||||
|
</div>
|
||||||
|
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
|
||||||
|
<small id="s-cf-test-status" class="hint"></small>
|
||||||
</label>
|
</label>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -556,6 +592,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Eingabe-Dialog (ersetzt window.prompt, das in Electron nicht geht) -->
|
||||||
|
<div id="modal-prompt" class="modal hidden">
|
||||||
|
<div class="modal-card modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="prompt-title">Eingabe</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="prompt-text" class="hint"></p>
|
||||||
|
<label class="field" style="margin-top:10px">
|
||||||
|
<span id="prompt-label">Name</span>
|
||||||
|
<input id="prompt-input" type="text" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn" data-close>Abbrechen</button>
|
||||||
|
<button id="prompt-ok" class="btn btn-primary">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Dialog: Mods (Modrinth) ===================== -->
|
<!-- ===================== Dialog: Mods (Modrinth) ===================== -->
|
||||||
<div id="modal-mods" class="modal hidden">
|
<div id="modal-mods" class="modal hidden">
|
||||||
<div class="modal-card modal-lg">
|
<div class="modal-card modal-lg">
|
||||||
@@ -573,11 +630,20 @@
|
|||||||
<div id="m-warn" class="mods-warn hidden"></div>
|
<div id="m-warn" class="mods-warn hidden"></div>
|
||||||
|
|
||||||
<div class="mods-search">
|
<div class="mods-search">
|
||||||
|
<select id="m-art" title="Was soll gesucht werden?">
|
||||||
|
<option value="mod">Mods</option>
|
||||||
|
<option value="resourcepack">Ressourcenpakete</option>
|
||||||
|
<option value="shader">Shader</option>
|
||||||
|
</select>
|
||||||
<input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" />
|
<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>
|
<button id="m-search-btn" class="btn btn-primary">Suchen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mods-filters">
|
<div class="mods-filters">
|
||||||
|
<select id="m-source" title="Quelle">
|
||||||
|
<option value="modrinth">Modrinth</option>
|
||||||
|
<option value="curseforge">CurseForge</option>
|
||||||
|
</select>
|
||||||
<select id="m-sort" title="Sortierung">
|
<select id="m-sort" title="Sortierung">
|
||||||
<option value="downloads">Beliebt</option>
|
<option value="downloads">Beliebt</option>
|
||||||
<option value="relevance">Relevanz</option>
|
<option value="relevance">Relevanz</option>
|
||||||
@@ -613,6 +679,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="m-pane-installed" class="hidden">
|
<div id="m-pane-installed" class="hidden">
|
||||||
<div class="mmc-bar">
|
<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-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>
|
<button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button>
|
||||||
<span id="m-update-status" class="hint"></span>
|
<span id="m-update-status" class="hint"></span>
|
||||||
@@ -693,6 +760,10 @@
|
|||||||
<h4>Ressourcenpakete <button id="content-open-rp" class="btn btn-mini" data-ic="folder"></button></h4>
|
<h4>Ressourcenpakete <button id="content-open-rp" class="btn btn-mini" data-ic="folder"></button></h4>
|
||||||
<div id="content-rp" class="mods-list"></div>
|
<div id="content-rp" class="mods-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mods-col">
|
||||||
|
<h4>Datenpakete <button id="content-open-dp" class="btn btn-mini" data-ic="folder"></button></h4>
|
||||||
|
<div id="content-dp" class="mods-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
@@ -710,6 +781,12 @@
|
|||||||
<button class="modal-close" data-close>✕</button>
|
<button class="modal-close" data-close>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div class="screens-toolbar">
|
||||||
|
<button id="screens-select-all" class="btn btn-mini">Alle auswählen</button>
|
||||||
|
<button id="screens-select-none" class="btn btn-mini">Auswahl aufheben</button>
|
||||||
|
<button id="screens-delete-sel" class="btn btn-danger btn-mini" disabled>Auswahl löschen</button>
|
||||||
|
<span id="screens-sel-hint" class="hint">Klick auf Vorschaubild zum Auswählen</span>
|
||||||
|
</div>
|
||||||
<div id="screens-grid" class="screens-grid"></div>
|
<div id="screens-grid" class="screens-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
@@ -720,6 +797,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Dialog: Modpack aktualisieren ===================== -->
|
||||||
|
<div id="modal-modpack" class="modal hidden">
|
||||||
|
<div class="modal-card modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Modpack aktualisieren</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="mp-status" class="hint">Suche …</p>
|
||||||
|
<div id="mp-kern" class="crash-kern hidden"></div>
|
||||||
|
<p class="hint">Welten, Bildschirmfotos und deine Optionen bleiben unangetastet. Mods und Konfigurationsdateien des Pakets werden ersetzt; was die neue Fassung nicht mehr enthält, wird entfernt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn" data-close>Schließen</button>
|
||||||
|
<button id="mp-do" class="btn btn-primary hidden">Jetzt aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Dialog: Serverliste ===================== -->
|
<!-- ===================== Dialog: Serverliste ===================== -->
|
||||||
<div id="modal-servers" class="modal hidden">
|
<div id="modal-servers" class="modal hidden">
|
||||||
<div class="modal-card modal-lg">
|
<div class="modal-card modal-lg">
|
||||||
@@ -904,6 +1001,7 @@
|
|||||||
<button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
|
<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-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-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
|
||||||
|
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="import-pane-zip" class="import-pane">
|
<div id="import-pane-zip" class="import-pane">
|
||||||
@@ -912,8 +1010,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="import-pane-modpack" class="import-pane hidden">
|
<div id="import-pane-modpack" class="import-pane hidden">
|
||||||
<p class="import-copy">Importiert ein Modpack als neue Instanz.</p>
|
<p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
|
||||||
<p class="hint">Unterstützt werden Modrinth (.mrpack oder ZIP mit modrinth.index.json) und CurseForge (ZIP mit manifest.json). Für CurseForge ist ein eigener API-Key in den Netzwerk-Einstellungen nötig.</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>
|
||||||
|
|
||||||
|
<div class="field-row" style="margin-top:12px;align-items:flex-end">
|
||||||
|
<label class="field" style="flex:1">
|
||||||
|
<span>Suche (Modrinth)</span>
|
||||||
|
<input id="mp-query" type="text" placeholder="z. B. Fabulously Optimized, Create …" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label class="field" style="width:140px">
|
||||||
|
<span>MC-Version <small class="opt">(opt.)</small></span>
|
||||||
|
<input id="mp-mc" type="text" placeholder="1.21.1" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<button id="mp-search-btn" class="btn btn-primary">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-row" style="margin-top:8px">
|
||||||
|
<label class="field">
|
||||||
|
<span>Sortierung</span>
|
||||||
|
<select id="mp-sort">
|
||||||
|
<option value="relevance">Relevanz</option>
|
||||||
|
<option value="downloads">Downloads</option>
|
||||||
|
<option value="follows">Follower</option>
|
||||||
|
<option value="newest">Neu</option>
|
||||||
|
<option value="updated">Aktualisiert</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="mp-status" class="hint" style="margin-top:8px">Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.</div>
|
||||||
|
<div id="mp-results" class="mods-list" style="margin-top:10px;max-height:280px;overflow:auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="import-pane-mc" class="import-pane hidden">
|
<div id="import-pane-mc" class="import-pane hidden">
|
||||||
@@ -987,11 +1111,51 @@
|
|||||||
<div id="mmc-list" class="mods-list"></div>
|
<div id="mmc-list" class="mods-list"></div>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<div id="mc-progress" class="hint hidden"></div>
|
<div id="mc-progress" class="hint hidden"></div>
|
||||||
<div id="mmc-progress" class="hint hidden"></div>
|
<div id="mmc-progress" class="hint hidden"></div>
|
||||||
<div id="cf-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>
|
<div class="spacer"></div>
|
||||||
<button class="btn" data-close>Abbrechen</button>
|
<button class="btn" data-close>Abbrechen</button>
|
||||||
<button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button>
|
<button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button>
|
||||||
@@ -999,6 +1163,7 @@
|
|||||||
<button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
|
<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="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
|
||||||
<button id="mmc-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { spawn, spawnSync } = require('child_process');
|
|||||||
const services = require('./services');
|
const services = require('./services');
|
||||||
const loaders = require('./loaders');
|
const loaders = require('./loaders');
|
||||||
const forge = require('./forge');
|
const forge = require('./forge');
|
||||||
|
const processwatchdog = require('./processwatchdog');
|
||||||
|
|
||||||
const UA = 'AeroMC-Launcher/0.2';
|
const UA = 'AeroMC-Launcher/0.2';
|
||||||
const RESOURCES = 'https://resources.download.minecraft.net';
|
const RESOURCES = 'https://resources.download.minecraft.net';
|
||||||
@@ -253,20 +254,28 @@ async function prepareAssets(vj, sharedDir, onProgress) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function pickJava(vj, preferredPath) {
|
function pickJava(vj, preferredPath) {
|
||||||
|
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
||||||
|
let preferred = null;
|
||||||
if (preferredPath && preferredPath.trim()) {
|
if (preferredPath && preferredPath.trim()) {
|
||||||
const info = services.probeJava(preferredPath);
|
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();
|
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 x64 = jvms.filter((j) => j.arch === 'x64');
|
||||||
const exact = x64.find((j) => j.major === required);
|
const exact = x64.find((j) => j.major === required);
|
||||||
if (exact) return { path: exact.path, major: exact.major };
|
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];
|
const fallback = preferred || x64[0] || jvms[0];
|
||||||
if (higher) return { path: higher.path, major: higher.major };
|
return fallback ? {
|
||||||
const any = jvms[0];
|
path: fallback.path,
|
||||||
return any ? { path: any.path, major: any.major, mismatch: required } : null;
|
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) 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
|
// Mod-Loader
|
||||||
let effectiveVj = vj;
|
let effectiveVj = vj;
|
||||||
@@ -478,6 +495,10 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
|
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
|
||||||
if (opts.mcMaximized) args.push('--fullscreen');
|
if (opts.mcMaximized) args.push('--fullscreen');
|
||||||
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
||||||
|
// Ab 1.20: direkt einem Server beitreten (Quick Play)
|
||||||
|
if (opts.quickPlayServer) {
|
||||||
|
args.push('--quickPlayMultiplayer', String(opts.quickPlayServer));
|
||||||
|
}
|
||||||
|
|
||||||
ensureDir(opts.gameDir);
|
ensureDir(opts.gameDir);
|
||||||
|
|
||||||
@@ -499,14 +520,33 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
spawnArgs = [...w.slice(1), java.path, ...args];
|
spawnArgs = [...w.slice(1), java.path, ...args];
|
||||||
}
|
}
|
||||||
const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false });
|
const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false });
|
||||||
child.stdout.on('data', (d) => emit('log', { line: d.toString() }));
|
const shutdownWatchdog = opts.wrapperCommand && opts.wrapperCommand.trim()
|
||||||
child.stderr.on('data', (d) => emit('log', { line: d.toString() }));
|
? 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
|
// Ende genau einmal melden – 'exit' und 'error' können beide kommen
|
||||||
let beendetGemeldet = false;
|
let beendetGemeldet = false;
|
||||||
const meldeEnde = (code) => {
|
const meldeEnde = (code, cleanShutdown) => {
|
||||||
if (beendetGemeldet) return;
|
if (beendetGemeldet) return;
|
||||||
beendetGemeldet = true;
|
beendetGemeldet = true;
|
||||||
emit('exit', { code });
|
emit('exit', { code, cleanShutdown: !!cleanShutdown });
|
||||||
// Post-Exit-Befehl
|
// Post-Exit-Befehl
|
||||||
if (opts.postExitCommand && opts.postExitCommand.trim()) {
|
if (opts.postExitCommand && opts.postExitCommand.trim()) {
|
||||||
emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' });
|
emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' });
|
||||||
@@ -517,12 +557,19 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
} catch { /* egal */ }
|
} 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
|
// ohne diesen Listener würde ein Fehlstart (z. B. falscher Java-Pfad) als
|
||||||
// unbehandeltes 'error'-Ereignis den Hauptprozess beenden
|
// unbehandeltes 'error'-Ereignis den Hauptprozess beenden
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
|
if (shutdownWatchdog) shutdownWatchdog.end();
|
||||||
emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' });
|
emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' });
|
||||||
meldeEnde(-1);
|
meldeEnde(-1, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, pid: child.pid, java };
|
return { ok: true, pid: child.pid, java };
|
||||||
|
|||||||
748
src/main.js
748
src/main.js
@@ -6,7 +6,7 @@
|
|||||||
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
|
* (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 } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
@@ -25,10 +25,13 @@ const backup = require('./backup');
|
|||||||
const mmcimport = require('./mmcimport');
|
const mmcimport = require('./mmcimport');
|
||||||
const cfimport = require('./cfimport');
|
const cfimport = require('./cfimport');
|
||||||
const mcimport = require('./mcimport');
|
const mcimport = require('./mcimport');
|
||||||
|
const technicimport = require('./technicimport');
|
||||||
|
const technicdiscover = require('./technicdiscover');
|
||||||
const javadl = require('./javadl');
|
const javadl = require('./javadl');
|
||||||
const crashreports = require('./crashreports');
|
const crashreports = require('./crashreports');
|
||||||
const skins = require('./skins');
|
const skins = require('./skins');
|
||||||
const servers = require('./servers');
|
const servers = require('./servers');
|
||||||
|
const mcping = require('./mcping');
|
||||||
|
|
||||||
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
|
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
|
||||||
const undici = require('undici');
|
const undici = require('undici');
|
||||||
@@ -90,13 +93,34 @@ let mainWindow = null;
|
|||||||
const hatEinzelSperre = app.requestSingleInstanceLock();
|
const hatEinzelSperre = app.requestSingleInstanceLock();
|
||||||
if (!hatEinzelSperre) app.quit();
|
if (!hatEinzelSperre) app.quit();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Instanz-Kennung aus den Startargumenten lesen.
|
||||||
|
*
|
||||||
|
* Wichtig: Bei einem zweiten Start sortiert Chromium die Argumente um –
|
||||||
|
* alle Schalter wandern nach vorn, alles andere ans Ende. Aus
|
||||||
|
* --launch 1.21.11
|
||||||
|
* wird dabei
|
||||||
|
* --launch --allow-file-access-from-files <pfad> 1.21.11
|
||||||
|
* und die Kennung steht nicht mehr hinter ihrem Schalter. Deshalb wird die
|
||||||
|
* Form "--launch=<kennung>" verwendet: die bleibt ein einziges Argument.
|
||||||
|
*/
|
||||||
function instanzAusArgumenten(argv) {
|
function instanzAusArgumenten(argv) {
|
||||||
const args = argv || [];
|
const args = (argv || []).map(String);
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
const a = String(args[i]);
|
for (const a of args) {
|
||||||
if (a === '--launch' && args[i + 1]) return String(args[i + 1]);
|
|
||||||
if (a.startsWith('--launch=')) return a.slice('--launch='.length);
|
if (a.startsWith('--launch=')) return a.slice('--launch='.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notnagel für Verknüpfungen aus früheren Fassungen (und den Aufruf von
|
||||||
|
// Hand): Steht "--launch" allein da, ist die Kennung durch das Umsortieren
|
||||||
|
// ans Ende gerutscht – das letzte Argument, das kein Schalter ist.
|
||||||
|
if (args.includes('--launch')) {
|
||||||
|
const i = args.indexOf('--launch');
|
||||||
|
if (args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1];
|
||||||
|
for (let j = args.length - 1; j > 0; j--) {
|
||||||
|
if (!args[j].startsWith('-')) return args[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +220,18 @@ app.whenReady().then(() => {
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const info = await updater.checkForUpdate(app.getVersion());
|
const info = await updater.checkForUpdate(app.getVersion());
|
||||||
if (info.available && mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('update:available', info);
|
// 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);
|
}, 4000);
|
||||||
|
|
||||||
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
|
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
|
||||||
@@ -253,6 +285,8 @@ ipcMain.handle('java:detect', (_e, javaPath) => detectJava(javaPath));
|
|||||||
|
|
||||||
// automatische Java-Suche über das System
|
// automatische Java-Suche über das System
|
||||||
ipcMain.handle('java:scan', () => services.scanJava(false)); // Knopf sucht immer frisch
|
ipcMain.handle('java:scan', () => services.scanJava(false)); // Knopf sucht immer frisch
|
||||||
|
// aus dem Zwischenspeicher – für Dialoge, die nur die Liste anzeigen wollen
|
||||||
|
ipcMain.handle('java:list', () => services.scanJava(true));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IPC – Minecraft-Versionen (Mojang)
|
// IPC – Minecraft-Versionen (Mojang)
|
||||||
@@ -264,41 +298,95 @@ ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData
|
|||||||
// IPC – Mods (Modrinth)
|
// IPC – Mods (Modrinth)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
ipcMain.handle('mods:search', (_e, opts) => services.modrinthSearch(opts));
|
ipcMain.handle('curseforge:testKey', async (_e, apiKey) => {
|
||||||
|
try { return await services.curseforgeTestKey(apiKey); }
|
||||||
|
catch (err) { return { ok: false, message: err.message || String(err) }; }
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('mods:list', (_e, id) => store.listMods(id));
|
ipcMain.handle('mods:search', async (_e, opts) => {
|
||||||
|
const o = opts || {};
|
||||||
|
const source = o.source || 'modrinth';
|
||||||
|
try {
|
||||||
|
if (source === 'curseforge') {
|
||||||
|
const zugang = services.curseforgeZugang(store.getSettings());
|
||||||
|
return await services.curseforgeSearch(zugang, o);
|
||||||
|
}
|
||||||
|
return await services.modrinthSearch(o);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
hits: [],
|
||||||
|
error: (err && err.message) ? err.message : String(err),
|
||||||
|
needKey: !!(err && err.code === 'need-key'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('mods:install', async (_e, id, project) => {
|
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
|
||||||
|
|
||||||
|
ipcMain.handle('mods:install', async (_e, id, project, art) => {
|
||||||
const inst = store.getInstance(id);
|
const inst = store.getInstance(id);
|
||||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
|
const typ = services.inhaltsArt(art || 'mod');
|
||||||
const gameVersion = inst.minecraft.version;
|
const gameVersion = inst.minecraft.version;
|
||||||
const loader = inst.minecraft.loader;
|
const loader = inst.minecraft.loader;
|
||||||
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
|
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
|
||||||
if (!loader || loader === 'vanilla') return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' };
|
// Nur Mods brauchen einen Loader – Ressourcenpakete und Shader laufen auch
|
||||||
|
// in einer Vanilla-Instanz.
|
||||||
|
if (typ.loaderNoetig && (!loader || loader === 'vanilla')) {
|
||||||
|
return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' };
|
||||||
|
}
|
||||||
|
|
||||||
const modsDir = store.modsDir(id);
|
const modsDir = store.inhaltsDir(id, art || 'mod');
|
||||||
const done = new Set(store.listMods(id).map((m) => m.projectId));
|
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId));
|
||||||
const added = [];
|
const added = [];
|
||||||
let firstError = null;
|
let firstError = null;
|
||||||
|
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
|
||||||
|
const zugang = istCf ? services.curseforgeZugang(store.getSettings()) : null;
|
||||||
|
|
||||||
// installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten
|
// installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten
|
||||||
async function installOne(proj, isDep) {
|
async function installOne(proj, isDep) {
|
||||||
if (done.has(proj.projectId)) return;
|
if (done.has(proj.projectId)) return;
|
||||||
done.add(proj.projectId);
|
done.add(proj.projectId);
|
||||||
try {
|
try {
|
||||||
const res = await services.modrinthInstall({
|
let res;
|
||||||
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir,
|
if (String(proj.projectId || '').startsWith('cf-') || proj.source === 'curseforge') {
|
||||||
});
|
res = await services.curseforgeInstall({
|
||||||
if (!res.ok) {
|
zugang, projectId: proj.projectId, title: proj.title,
|
||||||
if (!isDep && !firstError) firstError = res.message;
|
gameVersion, loader, modsDir, art: art || 'mod',
|
||||||
return;
|
});
|
||||||
}
|
if (!res.ok) {
|
||||||
store.addMod(id, res.entry);
|
if (!isDep && !firstError) firstError = res.message;
|
||||||
added.push(res.entry);
|
return;
|
||||||
for (const dep of res.dependencies || []) {
|
}
|
||||||
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
|
// merken, welche Pflicht-Abhängigkeiten dieser Mod mitbringt – damit
|
||||||
const title = await services.modrinthProjectTitle(dep.project_id);
|
// wir beim Entfernen warnen können, wenn ein anderer Mod ihn braucht
|
||||||
await installOne({ projectId: dep.project_id, title }, true);
|
res.entry.requiredDeps = (res.dependencies || [])
|
||||||
|
.filter((d) => d.required && d.projectId).map((d) => d.projectId);
|
||||||
|
store.addMod(id, res.entry, art || 'mod');
|
||||||
|
added.push(res.entry);
|
||||||
|
for (const dep of res.dependencies || []) {
|
||||||
|
if (dep.required && dep.projectId && !done.has(dep.projectId)) {
|
||||||
|
await installOne({ projectId: dep.projectId, title: dep.projectId, source: 'curseforge' }, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res = await services.modrinthInstall({
|
||||||
|
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!isDep && !firstError) firstError = res.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.entry.requiredDeps = (res.dependencies || [])
|
||||||
|
.filter((d) => d.dependency_type === 'required' && d.project_id).map((d) => d.project_id);
|
||||||
|
store.addMod(id, res.entry, art || 'mod');
|
||||||
|
added.push(res.entry);
|
||||||
|
for (const dep of res.dependencies || []) {
|
||||||
|
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
|
||||||
|
const title = await services.modrinthProjectTitle(dep.project_id);
|
||||||
|
await installOne({ projectId: dep.project_id, title }, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -311,13 +399,61 @@ ipcMain.handle('mods:install', async (_e, id, project) => {
|
|||||||
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
|
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
|
// verfügbare Loader-Versionen (Fabric/Quilt) für eine MC-Version
|
||||||
ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
|
ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
|
||||||
try { return await loaders.listLoaderVersions(loader, mcVersion); }
|
try { return await loaders.listLoaderVersions(loader, mcVersion); }
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('mods:remove', (_e, id, filename) => store.removeMod(id, filename));
|
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
|
// Vorhandener Arbeitsspeicher – Grundlage für die Prüfung der RAM-Angaben
|
||||||
function gesamtSpeicherMb() {
|
function gesamtSpeicherMb() {
|
||||||
@@ -356,6 +492,23 @@ ipcMain.handle('servers:remove', (_e, id, ip) => {
|
|||||||
return servers.entferne(store.gameDir(id), ip);
|
return servers.entferne(store.gameDir(id), ip);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => {
|
||||||
|
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
|
return servers.benenneUm(store.gameDir(id), ip, neuerName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live-Status eines Servers abfragen (MOTD/Spielerzahl/Version) – wie der
|
||||||
|
// Vanilla-Mehrspieler-Bildschirm, ohne sich wirklich zu verbinden
|
||||||
|
ipcMain.handle('servers:ping', async (_e, ip) => {
|
||||||
|
const s = String(ip || '').trim();
|
||||||
|
const m = s.match(/^([^:]+)(?::(\d+))?$/);
|
||||||
|
if (!m) return { ok: false, message: 'Ungültige Adresse.' };
|
||||||
|
const host = m[1];
|
||||||
|
const port = m[2] ? Number(m[2]) : 25565;
|
||||||
|
try { return await mcping.pingServer(host, port); }
|
||||||
|
catch (err) { return { ok: false, message: err.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
// denselben Server in weitere Instanzen übertragen
|
// denselben Server in weitere Instanzen übertragen
|
||||||
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
|
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
|
||||||
const erledigt = [];
|
const erledigt = [];
|
||||||
@@ -400,28 +553,29 @@ ipcMain.handle('crash:openFolder', (_e, id) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
|
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
|
||||||
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled) => {
|
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
|
||||||
const res = store.setModEnabled(id, filename, !!enabled);
|
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
|
const ordner = services.inhaltsArt(art || 'mod').ordner;
|
||||||
const gruende = {
|
const gruende = {
|
||||||
'not-found': 'Die Datei liegt nicht mehr im mods-Ordner.',
|
'not-found': `Die Datei liegt nicht mehr im ${ordner}-Ordner.`,
|
||||||
'both-exist': 'Es liegen beide Fassungen im mods-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.',
|
'both-exist': `Es liegen beide Fassungen im ${ordner}-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.`,
|
||||||
'bad-name': 'Ungültiger Dateiname.',
|
'bad-name': 'Ungültiger Dateiname.',
|
||||||
};
|
};
|
||||||
return { ok: false, message: gruende[res.reason] || res.reason };
|
return { ok: false, message: gruende[res.reason] || res.reason };
|
||||||
});
|
});
|
||||||
|
|
||||||
// sucht für die installierten Mods nach neueren Dateien (lädt noch nichts)
|
// sucht für die installierten Mods nach neueren Dateien (lädt noch nichts)
|
||||||
ipcMain.handle('mods:checkUpdates', async (_e, id) => {
|
ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
|
||||||
const inst = store.getInstance(id);
|
const inst = store.getInstance(id);
|
||||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
const gameVersion = inst.minecraft.version;
|
const gameVersion = inst.minecraft.version;
|
||||||
const loader = inst.minecraft.loader;
|
const loader = inst.minecraft.loader;
|
||||||
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
|
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
|
||||||
try {
|
try {
|
||||||
const res = await services.modrinthFindUpdates(store.listMods(id), gameVersion, loader, (p) => {
|
const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
|
||||||
});
|
}, art || 'mod');
|
||||||
return { ok: true, ...res };
|
return { ok: true, ...res };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, message: 'Update-Suche fehlgeschlagen: ' + err.message };
|
return { ok: false, message: 'Update-Suche fehlgeschlagen: ' + err.message };
|
||||||
@@ -429,28 +583,29 @@ ipcMain.handle('mods:checkUpdates', async (_e, id) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
|
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
|
||||||
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds) => {
|
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => {
|
||||||
const inst = store.getInstance(id);
|
const inst = store.getInstance(id);
|
||||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
const gameVersion = inst.minecraft.version;
|
const gameVersion = inst.minecraft.version;
|
||||||
const loader = inst.minecraft.loader;
|
const loader = inst.minecraft.loader;
|
||||||
const modsDir = store.modsDir(id);
|
const sorte = art || 'mod';
|
||||||
|
const modsDir = store.inhaltsDir(id, sorte);
|
||||||
const gewuenscht = new Set(projectIds || []);
|
const gewuenscht = new Set(projectIds || []);
|
||||||
const erneuert = [];
|
const erneuert = [];
|
||||||
const fehler = [];
|
const fehler = [];
|
||||||
|
|
||||||
for (const mod of store.listMods(id)) {
|
for (const mod of store.listMods(id, sorte)) {
|
||||||
if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue;
|
if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue;
|
||||||
try {
|
try {
|
||||||
const res = await services.modrinthInstall({
|
const res = await services.modrinthInstall({
|
||||||
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir,
|
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir, art: sorte,
|
||||||
});
|
});
|
||||||
if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; }
|
if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; }
|
||||||
store.addMod(id, res.entry);
|
store.addMod(id, res.entry, sorte);
|
||||||
// alte Datei erst weg, wenn die neue liegt – und nie, wenn sie gleich heißt
|
// alte Datei erst weg, wenn die neue liegt – und nie, wenn sie gleich heißt
|
||||||
if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename);
|
if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename, sorte);
|
||||||
// war die Mod abgeschaltet, bleibt sie es auch nach dem Update
|
// war der Eintrag abgeschaltet, bleibt er es auch nach dem Update
|
||||||
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false);
|
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false, sorte);
|
||||||
erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber });
|
erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fehler.push({ title: mod.title, message: err.message });
|
fehler.push({ title: mod.title, message: err.message });
|
||||||
@@ -582,6 +737,7 @@ function listDir(dir, dirsOnly) {
|
|||||||
|
|
||||||
ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true));
|
ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true));
|
||||||
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
|
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
|
||||||
|
ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false));
|
||||||
|
|
||||||
function safeSub(id, sub, name) {
|
function safeSub(id, sub, name) {
|
||||||
const base = path.join(store.gameDir(id), sub);
|
const base = path.join(store.gameDir(id), sub);
|
||||||
@@ -658,6 +814,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) ----
|
// ---- Modpack-Import (Modrinth .mrpack) ----
|
||||||
|
|
||||||
async function importMrpack(buf, zip) {
|
async function importMrpack(buf, zip) {
|
||||||
@@ -677,38 +863,210 @@ async function importMrpack(buf, zip) {
|
|||||||
const gameDir = store.gameDir(created.id);
|
const gameDir = store.gameDir(created.id);
|
||||||
const inside = (dest) => dest.startsWith(gameDir + path.sep);
|
const inside = (dest) => dest.startsWith(gameDir + path.sep);
|
||||||
|
|
||||||
const files = index.files || [];
|
const eingespielt = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: false });
|
||||||
|
|
||||||
|
// Herkunft merken, damit sich das Paket später aktualisieren lässt
|
||||||
|
const herkunft = await modpackHerkunft(buf, index);
|
||||||
|
store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) });
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
instance: store.getInstance(created.id),
|
||||||
|
fileCount: eingespielt.dateien.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Spielt ein .mrpack in ein Spielverzeichnis ein.
|
||||||
|
* schonePlayerDaten: bei einer Aktualisierung bleiben Welten, Bildschirmfotos
|
||||||
|
* und die Optionen unangetastet – sonst wären die Einstellungen des Spielers weg.
|
||||||
|
* -> { dateien: [relative Pfade] }
|
||||||
|
*/
|
||||||
|
const SPIELERDATEN = [/^saves\//i, /^screenshots\//i, /^logs\//i, /^options\.txt$/i, /^servers\.dat$/i];
|
||||||
|
|
||||||
|
async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) {
|
||||||
|
const inside = (dest) => dest.startsWith(gameDir + path.sep);
|
||||||
|
const geschuetzt = (rel) => schonePlayerDaten && SPIELERDATEN.some((r) => r.test(rel));
|
||||||
|
const dateien = [];
|
||||||
|
|
||||||
|
const files = (index.files || []).filter((f) => !(f.env && f.env.client === 'unsupported'));
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
i++;
|
i++;
|
||||||
if (f.env && f.env.client === 'unsupported') continue;
|
|
||||||
const url = f.downloads && f.downloads[0];
|
const url = f.downloads && f.downloads[0];
|
||||||
if (!url || !f.path) continue;
|
if (!url || !f.path) continue;
|
||||||
const dest = path.join(gameDir, f.path.replace(/\//g, path.sep));
|
const rel = String(f.path).replace(/\\/g, '/');
|
||||||
|
if (geschuetzt(rel)) continue;
|
||||||
|
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
|
||||||
if (!inside(dest)) continue;
|
if (!inside(dest)) continue;
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
|
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
|
||||||
}
|
}
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher/0.11' } });
|
try {
|
||||||
if (!res.ok) continue;
|
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
if (!res.ok) continue;
|
||||||
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
||||||
|
dateien.push(rel);
|
||||||
|
} catch { /* einzelne Datei überspringen */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// overrides einspielen
|
// overrides einspielen
|
||||||
for (const prefix of ['overrides/', 'client-overrides/']) {
|
for (const prefix of ['overrides/', 'client-overrides/']) {
|
||||||
for (const name of Object.keys(zip)) {
|
for (const name of Object.keys(zip)) {
|
||||||
if (name.startsWith(prefix) && !name.endsWith('/')) {
|
if (!name.startsWith(prefix) || name.endsWith('/')) continue;
|
||||||
const dest = path.join(gameDir, name.slice(prefix.length).replace(/\//g, path.sep));
|
const rel = name.slice(prefix.length);
|
||||||
if (!inside(dest)) continue;
|
if (geschuetzt(rel)) continue;
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
|
||||||
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
|
if (!inside(dest)) continue;
|
||||||
}
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
|
||||||
|
dateien.push(rel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, instance: store.getInstance(created.id), fileCount: files.length };
|
return { dateien };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Woher stammt das Paket? Modrinth kennt jede veröffentlichte Datei an ihrer
|
||||||
|
* Prüfsumme – darüber finden wir Projekt und Fassung, ohne den Nutzer zu fragen.
|
||||||
|
*/
|
||||||
|
async function modpackHerkunft(buf, index) {
|
||||||
|
const basis = {
|
||||||
|
quelle: 'modrinth',
|
||||||
|
name: index.name || '',
|
||||||
|
versionNummer: index.versionId || '',
|
||||||
|
projectId: '',
|
||||||
|
versionId: '',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const sha1 = require('crypto').createHash('sha1').update(buf).digest('hex');
|
||||||
|
const res = await fetch(`https://api.modrinth.com/v2/version_file/${sha1}?algorithm=sha1`,
|
||||||
|
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
|
||||||
|
if (res.ok) {
|
||||||
|
const v = await res.json();
|
||||||
|
basis.projectId = v.project_id || '';
|
||||||
|
basis.versionId = v.id || '';
|
||||||
|
basis.versionNummer = v.version_number || basis.versionNummer;
|
||||||
|
}
|
||||||
|
} catch { /* offline oder unbekanntes Paket – dann eben ohne Herkunft */ }
|
||||||
|
return basis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Modpack aktualisieren (Modrinth) ----
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sucht eine neuere Fassung des installierten Modpacks.
|
||||||
|
* -> { ok, verfuegbar, aktuelleNummer, neueNummer, ... }
|
||||||
|
*/
|
||||||
|
ipcMain.handle('modpack:check', async (_e, id) => {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
|
const mp = inst.modpack;
|
||||||
|
if (!mp || !mp.projectId) {
|
||||||
|
return { ok: false, message: 'Für diese Instanz ist kein Modrinth-Modpack hinterlegt. '
|
||||||
|
+ 'Aktualisieren geht nur bei Instanzen, die aus einem .mrpack entstanden sind.' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.modrinth.com/v2/project/${mp.projectId}/version`,
|
||||||
|
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return { ok: false, message: 'Modrinth antwortet mit HTTP ' + res.status };
|
||||||
|
const versionen = await res.json();
|
||||||
|
if (!Array.isArray(versionen) || !versionen.length) return { ok: true, verfuegbar: false };
|
||||||
|
|
||||||
|
const neueste = versionen[0];
|
||||||
|
const verfuegbar = neueste.id !== mp.versionId;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
verfuegbar,
|
||||||
|
name: mp.name,
|
||||||
|
aktuelleNummer: mp.versionNummer || '?',
|
||||||
|
neueNummer: neueste.version_number,
|
||||||
|
neueVersionId: neueste.id,
|
||||||
|
hinweise: neueste.changelog || '',
|
||||||
|
spielVersion: (neueste.game_versions || [])[0] || '',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: 'Suche fehlgeschlagen: ' + err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Spielt eine neuere Fassung ein. Dateien der alten Fassung, die es nicht mehr
|
||||||
|
* gibt, werden entfernt; Welten, Bildschirmfotos und Optionen bleiben.
|
||||||
|
*/
|
||||||
|
ipcMain.handle('modpack:update', async (_e, id, versionId) => {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
|
const mp = inst.modpack;
|
||||||
|
if (!mp || !mp.projectId) return { ok: false, message: 'Kein Modpack hinterlegt.' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vres = await fetch(`https://api.modrinth.com/v2/version/${versionId}`,
|
||||||
|
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
|
||||||
|
if (!vres.ok) return { ok: false, message: 'Fassung nicht abrufbar (HTTP ' + vres.status + ').' };
|
||||||
|
const version = await vres.json();
|
||||||
|
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
|
||||||
|
if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' };
|
||||||
|
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' });
|
||||||
|
}
|
||||||
|
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||||
|
if (!dl.ok) return { ok: false, message: 'Download fehlgeschlagen (HTTP ' + dl.status + ').' };
|
||||||
|
const buf = Buffer.from(await dl.arrayBuffer());
|
||||||
|
|
||||||
|
const zip = forge.readZipIndex(buf);
|
||||||
|
if (!zip['modrinth.index.json']) return { ok: false, message: 'Die geladene Datei ist kein .mrpack.' };
|
||||||
|
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, ''));
|
||||||
|
|
||||||
|
const gameDir = store.gameDir(id);
|
||||||
|
const neu = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: true });
|
||||||
|
|
||||||
|
// Was die alte Fassung mitbrachte und die neue nicht mehr kennt, muss weg –
|
||||||
|
// sonst bleiben alte Mods liegen und der Loader stolpert darüber.
|
||||||
|
const behalten = new Set(neu.dateien.map((d) => d.toLowerCase()));
|
||||||
|
const entfernt = [];
|
||||||
|
for (const alt of mp.dateien || []) {
|
||||||
|
if (behalten.has(String(alt).toLowerCase())) continue;
|
||||||
|
if (SPIELERDATEN.some((r) => r.test(alt))) continue;
|
||||||
|
const p = path.join(gameDir, String(alt).replace(/\//g, path.sep));
|
||||||
|
if (!p.startsWith(gameDir + path.sep)) continue;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); entfernt.push(alt); }
|
||||||
|
} catch { /* egal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minecraft-Version und Loader können sich mit der neuen Fassung ändern
|
||||||
|
const deps = index.dependencies || {};
|
||||||
|
let loader = 'vanilla'; let loaderVersion = '';
|
||||||
|
if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; }
|
||||||
|
else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; }
|
||||||
|
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
|
||||||
|
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
|
||||||
|
|
||||||
|
store.updateInstance(id, {
|
||||||
|
minecraft: { version: deps.minecraft || inst.minecraft.version, loader, loaderVersion },
|
||||||
|
modpack: Object.assign({}, mp, {
|
||||||
|
versionId: version.id,
|
||||||
|
versionNummer: version.version_number,
|
||||||
|
dateien: neu.dateien,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
neueNummer: version.version_number,
|
||||||
|
eingespielt: neu.dateien.length,
|
||||||
|
entfernt: entfernt.length,
|
||||||
|
spielVersion: deps.minecraft || '',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: 'Aktualisierung fehlgeschlagen: ' + err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Modpack-Import (CurseForge .zip mit manifest.json) ----
|
// ---- Modpack-Import (CurseForge .zip mit manifest.json) ----
|
||||||
|
|
||||||
// "forge-47.2.0" / "fabric-0.15.7" -> { loader, loaderVersion }
|
// "forge-47.2.0" / "fabric-0.15.7" -> { loader, loaderVersion }
|
||||||
@@ -789,6 +1147,99 @@ async function importCurseForge(buf, zip, zugang) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('modpack:search', async (_e, opts) => {
|
||||||
|
try {
|
||||||
|
const q = Object.assign({}, opts || {}, { art: 'modpack' });
|
||||||
|
return await services.modrinthSearch(q);
|
||||||
|
} catch (err) {
|
||||||
|
return { total: 0, hits: [], error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
|
||||||
|
try {
|
||||||
|
const projectId = project && (project.projectId || project.id);
|
||||||
|
if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' };
|
||||||
|
const gameVersion = (project && project.gameVersion) || '';
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('launch:progress', {
|
||||||
|
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
|
||||||
|
if (!file) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: gameVersion
|
||||||
|
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
|
||||||
|
: 'Keine Modpack-Version gefunden.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('launch:progress', {
|
||||||
|
phase: 'modpack',
|
||||||
|
detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const buf = await services.downloadBuffer(file.url);
|
||||||
|
const zip = forge.readZipIndex(buf);
|
||||||
|
if (!zip['modrinth.index.json']) {
|
||||||
|
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
|
||||||
|
}
|
||||||
|
const result = await importMrpack(buf, zip);
|
||||||
|
if (result && result.ok && result.instance) {
|
||||||
|
// Herkunft mit aktueller Version-ID ergänzen, falls der Index sie nicht trägt
|
||||||
|
const inst = result.instance;
|
||||||
|
const mp = Object.assign({}, inst.modpack || {}, {
|
||||||
|
projectId: projectId,
|
||||||
|
versionId: file.versionId,
|
||||||
|
versionNumber: file.versionNumber,
|
||||||
|
title: (project && project.title) || (inst.modpack && inst.modpack.title) || inst.name,
|
||||||
|
});
|
||||||
|
store.updateInstance(inst.id, { modpack: mp });
|
||||||
|
result.instance = store.getInstance(inst.id);
|
||||||
|
}
|
||||||
|
if (result && result.ok && result.instance) {
|
||||||
|
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 () => {
|
ipcMain.handle('modpack:import', async () => {
|
||||||
const res = await dialog.showOpenDialog(mainWindow, {
|
const res = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: 'Modpack importieren',
|
title: 'Modpack importieren',
|
||||||
@@ -796,22 +1247,7 @@ ipcMain.handle('modpack:import', async () => {
|
|||||||
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
|
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
|
||||||
});
|
});
|
||||||
if (res.canceled) return { ok: false, canceled: true };
|
if (res.canceled) return { ok: false, canceled: true };
|
||||||
try {
|
return importModpackFromPath(res.filePaths[0]);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
|
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
|
||||||
@@ -827,7 +1263,9 @@ ipcMain.handle('instances:shortcut', (_e, id) => {
|
|||||||
const ziel = verknuepfungsZiel();
|
const ziel = verknuepfungsZiel();
|
||||||
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
|
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
|
||||||
target: ziel,
|
target: ziel,
|
||||||
args: `--launch "${id}"`,
|
// Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium
|
||||||
|
// an ihrem Schalter bleibt (siehe instanzAusArgumenten)
|
||||||
|
args: `--launch="${id}"`,
|
||||||
cwd: path.dirname(ziel),
|
cwd: path.dirname(ziel),
|
||||||
description: 'AeroMC – ' + inst.name,
|
description: 'AeroMC – ' + inst.name,
|
||||||
icon: ziel,
|
icon: ziel,
|
||||||
@@ -840,18 +1278,17 @@ ipcMain.handle('instances:shortcut', (_e, id) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('instances:import', async () => {
|
/*
|
||||||
const res = await dialog.showOpenDialog(mainWindow, {
|
* Kern des ZIP-Instanz-Imports (AeroMC-Export oder einzelne MultiMC/Prism-
|
||||||
title: 'Instanz importieren',
|
* Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per
|
||||||
properties: ['openFile'],
|
* Drag&Drop aufs Fenster kommt.
|
||||||
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
|
*/
|
||||||
});
|
async function importInstanceZipFromPath(filePath) {
|
||||||
if (res.canceled) return { ok: false, canceled: true };
|
|
||||||
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
|
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(tmp, { recursive: true });
|
fs.mkdirSync(tmp, { recursive: true });
|
||||||
await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force',
|
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)
|
// instance.json finden (auch in Unterordner)
|
||||||
let base = tmp;
|
let base = tmp;
|
||||||
@@ -893,8 +1330,88 @@ ipcMain.handle('instances:import', async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
|
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
|
// Sicherungen: von Hand und nach Zeitplan
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -922,6 +1439,7 @@ async function erstelleBackup({ still } = {}) {
|
|||||||
if (!still && mainWindow && !mainWindow.isDestroyed()) {
|
if (!still && mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' });
|
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;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
|
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
|
||||||
@@ -1032,7 +1550,7 @@ function kontoSpieltNichtMehr(kontoId) {
|
|||||||
meldeKontenAenderung();
|
meldeKontenAenderung();
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('instances:launch', async (_e, id, accountId) => {
|
ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||||||
const inst = store.getInstance(id);
|
const inst = store.getInstance(id);
|
||||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
if (!inst.minecraft.version) {
|
if (!inst.minecraft.version) {
|
||||||
@@ -1081,6 +1599,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
|
|||||||
auth: null,
|
auth: null,
|
||||||
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
|
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
|
||||||
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
|
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
|
||||||
|
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
|
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
|
||||||
@@ -1209,7 +1728,10 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => {
|
|||||||
const done = [];
|
const done = [];
|
||||||
const failed = [];
|
const failed = [];
|
||||||
for (const entry of entries || []) {
|
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 }); }
|
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length });
|
mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length });
|
||||||
@@ -1235,7 +1757,10 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
|
|||||||
const done = [];
|
const done = [];
|
||||||
const failed = [];
|
const failed = [];
|
||||||
for (const entry of entries || []) {
|
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 }); }
|
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
|
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
|
||||||
@@ -1261,7 +1786,10 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
|
|||||||
const done = [];
|
const done = [];
|
||||||
const failed = [];
|
const failed = [];
|
||||||
for (const entry of entries || []) {
|
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 }); }
|
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
|
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
|
||||||
@@ -1270,6 +1798,54 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
|
|||||||
return { ok: true, imported: done, failed };
|
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)
|
// IPC – Minecraft Bedrock (Windows-Store-App)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
150
src/mcping.js
Normal file
150
src/mcping.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* mcping.js – "Server List Ping" (SLP), das Protokoll, das auch der
|
||||||
|
* Vanilla-Mehrspieler-Bildschirm nutzt, um MOTD/Spielerzahl/Version
|
||||||
|
* abzufragen, ohne sich wirklich zu verbinden.
|
||||||
|
*
|
||||||
|
* Ablauf (siehe minecraft.wiki/w/Server_List_Ping):
|
||||||
|
* 1) Handshake-Paket senden (Protokollversion, Adresse, Port, next state=1)
|
||||||
|
* 2) Status-Request-Paket senden (leer)
|
||||||
|
* 3) Status-Response-Paket empfangen: enthält einen JSON-String
|
||||||
|
* Alle Pakete sind mit ihrer Länge als VarInt vorangestellt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
|
function encodeVarInt(value) {
|
||||||
|
const bytes = [];
|
||||||
|
let v = value;
|
||||||
|
do {
|
||||||
|
let temp = v & 0b01111111;
|
||||||
|
v = v >>> 7;
|
||||||
|
if (v !== 0) temp |= 0b10000000;
|
||||||
|
bytes.push(temp);
|
||||||
|
} while (v !== 0);
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeString(str) {
|
||||||
|
const strBuf = Buffer.from(str, 'utf8');
|
||||||
|
return Buffer.concat([encodeVarInt(strBuf.length), strBuf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeUShort(n) {
|
||||||
|
const b = Buffer.alloc(2);
|
||||||
|
b.writeUInt16BE(n & 0xffff, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// liest einen VarInt aus buf ab offset -> { value, next } oder null,
|
||||||
|
// wenn noch nicht genug Bytes angekommen sind
|
||||||
|
function decodeVarInt(buf, offset) {
|
||||||
|
let numRead = 0;
|
||||||
|
let result = 0;
|
||||||
|
let read;
|
||||||
|
do {
|
||||||
|
if (offset + numRead >= buf.length) return null;
|
||||||
|
read = buf[offset + numRead];
|
||||||
|
result |= (read & 0b01111111) << (7 * numRead);
|
||||||
|
numRead++;
|
||||||
|
if (numRead > 5) throw new Error('VarInt zu groß');
|
||||||
|
} while ((read & 0b10000000) !== 0);
|
||||||
|
return { value: result >>> 0, next: offset + numRead };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "description" kann ein reiner String oder eine Chat-Komponente
|
||||||
|
// ({text, extra:[...]}) sein – hier auf reinen Text reduzieren
|
||||||
|
function extractMotdText(desc) {
|
||||||
|
if (!desc) return '';
|
||||||
|
if (typeof desc === 'string') return desc;
|
||||||
|
let text = String(desc.text || '');
|
||||||
|
if (Array.isArray(desc.extra)) {
|
||||||
|
for (const part of desc.extra) text += extractMotdText(part);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatierungscodes (§a, §l, …) sind fürs Spiel gedacht, nicht für unsere Liste
|
||||||
|
function stripFormatting(text) {
|
||||||
|
return String(text || '').replace(/§./g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fragt einen Server ab.
|
||||||
|
* -> { ok, motd, version, protocol, online, max, sample, favicon, latencyMs }
|
||||||
|
* oder { ok: false, message }
|
||||||
|
*/
|
||||||
|
function pingServer(host, port = 25565, timeoutMs = 4000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const done = (result) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
try { socket.destroy(); } catch { /* egal */ }
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
socket.on('timeout', () => done({ ok: false, message: 'Zeitüberschreitung.' }));
|
||||||
|
socket.on('error', (err) => done({ ok: false, message: err.code === 'ENOTFOUND' ? 'Adresse nicht gefunden.' : err.message }));
|
||||||
|
|
||||||
|
socket.connect(port, host, () => {
|
||||||
|
const handshakeBody = Buffer.concat([
|
||||||
|
encodeVarInt(0x00),
|
||||||
|
encodeVarInt(-1), // Protokollversion "unbekannt" – für den Status-Abfrage-Zweck egal
|
||||||
|
encodeString(host),
|
||||||
|
encodeUShort(port),
|
||||||
|
encodeVarInt(1), // next state: 1 = status
|
||||||
|
]);
|
||||||
|
const handshakePacket = Buffer.concat([encodeVarInt(handshakeBody.length), handshakeBody]);
|
||||||
|
|
||||||
|
const requestBody = encodeVarInt(0x00);
|
||||||
|
const requestPacket = Buffer.concat([encodeVarInt(requestBody.length), requestBody]);
|
||||||
|
|
||||||
|
socket.write(Buffer.concat([handshakePacket, requestPacket]));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
if (finished) return;
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
try {
|
||||||
|
const lenInfo = decodeVarInt(buffer, 0);
|
||||||
|
if (!lenInfo) return; // Länge noch nicht vollständig angekommen
|
||||||
|
const packetEnd = lenInfo.next + lenInfo.value;
|
||||||
|
if (buffer.length < packetEnd) return; // Paket noch nicht vollständig da
|
||||||
|
|
||||||
|
const idInfo = decodeVarInt(buffer, lenInfo.next);
|
||||||
|
if (!idInfo) return;
|
||||||
|
const jsonLenInfo = decodeVarInt(buffer, idInfo.next);
|
||||||
|
if (!jsonLenInfo) return;
|
||||||
|
const jsonStart = jsonLenInfo.next;
|
||||||
|
const jsonEnd = jsonStart + jsonLenInfo.value;
|
||||||
|
if (buffer.length < jsonEnd) return;
|
||||||
|
|
||||||
|
const data = JSON.parse(buffer.toString('utf8', jsonStart, jsonEnd));
|
||||||
|
done({
|
||||||
|
ok: true,
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
motd: stripFormatting(extractMotdText(data.description)),
|
||||||
|
version: (data.version && data.version.name) || '',
|
||||||
|
protocol: data.version ? data.version.protocol : null,
|
||||||
|
online: data.players ? data.players.online : null,
|
||||||
|
max: data.players ? data.players.max : null,
|
||||||
|
sample: (data.players && Array.isArray(data.players.sample))
|
||||||
|
? data.players.sample.map((p) => String(p.name || '')).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
favicon: typeof data.favicon === 'string' ? data.favicon : '',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
done({ ok: false, message: 'Ungültige Antwort: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pingServer };
|
||||||
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
||||||
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
||||||
scanJava: () => ipcRenderer.invoke('java:scan'),
|
scanJava: () => ipcRenderer.invoke('java:scan'),
|
||||||
|
listJava: () => ipcRenderer.invoke('java:list'),
|
||||||
|
|
||||||
// Minecraft-Versionen (Mojang)
|
// Minecraft-Versionen (Mojang)
|
||||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||||
@@ -21,10 +22,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
|
|
||||||
// Mods (Modrinth)
|
// Mods (Modrinth)
|
||||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||||
modList: (id) => ipcRenderer.invoke('mods:list', id),
|
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
|
||||||
modInstall: (id, project) => ipcRenderer.invoke('mods:install', id, project),
|
// art: 'mod' | 'resourcepack' | 'shader'
|
||||||
modRemove: (id, filename) => ipcRenderer.invoke('mods:remove', id, filename),
|
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
|
||||||
modSetEnabled: (id, filename, enabled) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled),
|
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'),
|
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
||||||
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
||||||
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
||||||
@@ -37,12 +42,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
||||||
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
||||||
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
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),
|
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
||||||
|
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
||||||
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
||||||
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
||||||
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
||||||
modCheckUpdates: (id) => ipcRenderer.invoke('mods:checkUpdates', id),
|
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
||||||
modApplyUpdates: (id, projectIds) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds),
|
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
||||||
onModUpdateProgress: (cb) => {
|
onModUpdateProgress: (cb) => {
|
||||||
const l = (_e, d) => cb(d);
|
const l = (_e, d) => cb(d);
|
||||||
ipcRenderer.on('mods:updateProgress', l);
|
ipcRenderer.on('mods:updateProgress', l);
|
||||||
@@ -62,12 +69,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Welten & Ressourcenpakete
|
// Welten & Ressourcenpakete
|
||||||
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
||||||
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
|
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
|
||||||
|
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
|
||||||
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
|
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),
|
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
||||||
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
||||||
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
||||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||||
launchInstance: (id, accountId) => ipcRenderer.invoke('instances:launch', id, accountId),
|
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
||||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||||
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
||||||
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||||
@@ -86,6 +95,12 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
||||||
},
|
},
|
||||||
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
||||||
|
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
|
||||||
|
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
|
||||||
|
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
|
||||||
|
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
|
||||||
|
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
||||||
|
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
||||||
onLaunchProgress: (cb) => {
|
onLaunchProgress: (cb) => {
|
||||||
const listener = (_e, data) => cb(data);
|
const listener = (_e, data) => cb(data);
|
||||||
ipcRenderer.on('launch:progress', listener);
|
ipcRenderer.on('launch:progress', listener);
|
||||||
@@ -160,6 +175,21 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.on('mc:progress', l);
|
ipcRenderer.on('mc:progress', l);
|
||||||
return () => ipcRenderer.removeListener('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)
|
// Bedrock (Windows-Store-Ausgabe)
|
||||||
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
||||||
@@ -183,6 +213,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.on('update:available', l);
|
ipcRenderer.on('update:available', l);
|
||||||
return () => ipcRenderer.removeListener('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) => {
|
onUpdateProgress: (cb) => {
|
||||||
const l = (_e, d) => cb(d);
|
const l = (_e, d) => cb(d);
|
||||||
ipcRenderer.on('update:progress', l);
|
ipcRenderer.on('update:progress', l);
|
||||||
|
|||||||
101
src/processwatchdog.js
Normal file
101
src/processwatchdog.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DEFAULT_GRACE_MS = 15000;
|
||||||
|
|
||||||
|
const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/;
|
||||||
|
const FATAL_OUTPUT = [
|
||||||
|
/---- Minecraft Crash Report ----/i,
|
||||||
|
/\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i,
|
||||||
|
/\b(?:This crash report has been saved to|Saving crash report to)\b/i,
|
||||||
|
/\bA fatal error has been detected by the Java Runtime Environment\b/i,
|
||||||
|
/Exception in thread "(?:Render thread|Client thread|main)"/i,
|
||||||
|
/\[(?:Render thread|Client thread|main)\/FATAL\]/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function createProcessWatchdog(options) {
|
||||||
|
const graceMs = options.graceMs || DEFAULT_GRACE_MS;
|
||||||
|
const schedule = options.setTimer || setTimeout;
|
||||||
|
const cancel = options.clearTimer || clearTimeout;
|
||||||
|
const buffers = { stdout: '', stderr: '' };
|
||||||
|
let timer = null;
|
||||||
|
let armed = false;
|
||||||
|
let crashed = false;
|
||||||
|
let ended = false;
|
||||||
|
let forced = false;
|
||||||
|
// bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen
|
||||||
|
// wurde – im Gegensatz zu `armed` wird das NICHT durch markCrash()
|
||||||
|
// zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt
|
||||||
|
// weiterhin als "war ein regulärer Shutdown" erkennbar bleibt
|
||||||
|
let sawCleanShutdown = false;
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (timer !== null) cancel(timer);
|
||||||
|
timer = null;
|
||||||
|
armed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCrash() {
|
||||||
|
crashed = true;
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleKill() {
|
||||||
|
if (timer !== null) cancel(timer);
|
||||||
|
timer = schedule(() => {
|
||||||
|
timer = null;
|
||||||
|
if (ended || crashed || !armed || !options.isRunning()) return;
|
||||||
|
try {
|
||||||
|
forced = options.kill() === true;
|
||||||
|
} catch {
|
||||||
|
forced = false;
|
||||||
|
}
|
||||||
|
if (forced && options.onForce) options.onForce(graceMs);
|
||||||
|
else if (!forced && options.isRunning() && options.onError) options.onError();
|
||||||
|
}, graceMs);
|
||||||
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectLine(line) {
|
||||||
|
const isCleanShutdown = CLEAN_SHUTDOWN.test(line);
|
||||||
|
if (isCleanShutdown) sawCleanShutdown = true;
|
||||||
|
if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) {
|
||||||
|
markCrash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ended || crashed || armed || !isCleanShutdown) return;
|
||||||
|
|
||||||
|
armed = true;
|
||||||
|
scheduleKill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function feed(chunk, stream) {
|
||||||
|
if (ended) return;
|
||||||
|
const key = stream === 'stderr' ? 'stderr' : 'stdout';
|
||||||
|
const text = buffers[key] + String(chunk || '');
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
buffers[key] = lines.pop() || '';
|
||||||
|
for (const line of lines) inspectLine(line);
|
||||||
|
if (armed && !crashed && !ended && lines.length) scheduleKill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function end() {
|
||||||
|
ended = true;
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
feed,
|
||||||
|
end,
|
||||||
|
get armed() { return armed; },
|
||||||
|
get crashed() { return crashed; },
|
||||||
|
get forced() { return forced; },
|
||||||
|
get sawCleanShutdown() { return sawCleanShutdown; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_GRACE_MS,
|
||||||
|
CLEAN_SHUTDOWN,
|
||||||
|
FATAL_OUTPUT,
|
||||||
|
createProcessWatchdog,
|
||||||
|
};
|
||||||
992
src/renderer.js
992
src/renderer.js
File diff suppressed because it is too large
Load Diff
@@ -135,6 +135,23 @@ function entferne(gameDir, ip) {
|
|||||||
return schreibe(gameDir, liste);
|
return schreibe(gameDir, liste);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nur den Anzeigenamen ändern – die Adresse bleibt gleich.
|
||||||
|
* ip identifiziert den Eintrag (wie beim Entfernen).
|
||||||
|
*/
|
||||||
|
function benenneUm(gameDir, ip, neuerName) {
|
||||||
|
const name = String(neuerName || '').trim();
|
||||||
|
if (!name) return { ok: false, message: 'Bitte einen Namen angeben.' };
|
||||||
|
const gelesen = lies(gameDir);
|
||||||
|
if (!gelesen.ok) return gelesen;
|
||||||
|
const liste = gelesen.server;
|
||||||
|
const treffer = liste.find((s) => s.ip.toLowerCase() === String(ip || '').toLowerCase());
|
||||||
|
if (!treffer) return { ok: false, message: 'Server nicht gefunden.' };
|
||||||
|
if (treffer.name === name) return { ok: true, unveraendert: true };
|
||||||
|
treffer.name = name;
|
||||||
|
return schreibe(gameDir, liste);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Einen Server in eine andere Instanz übertragen – der eigentliche Zweck:
|
* Einen Server in eine andere Instanz übertragen – der eigentliche Zweck:
|
||||||
* denselben Server nicht in jeder Instanz neu eintippen zu müssen.
|
* denselben Server nicht in jeder Instanz neu eintippen zu müssen.
|
||||||
@@ -143,4 +160,4 @@ function uebertrage(zielGameDir, eintrag) {
|
|||||||
return fuegeHinzu(zielGameDir, eintrag);
|
return fuegeHinzu(zielGameDir, eintrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { lies, schreibe, fuegeHinzu, entferne, uebertrage, pruefeAdresse, datei };
|
module.exports = { lies, schreibe, fuegeHinzu, entferne, benenneUm, uebertrage, pruefeAdresse, datei };
|
||||||
|
|||||||
368
src/services.js
368
src/services.js
@@ -227,10 +227,30 @@ const MODRINTH = 'https://api.modrinth.com/v2';
|
|||||||
|
|
||||||
const MODRINTH_SORTS = ['relevance', 'downloads', 'follows', 'newest', 'updated'];
|
const MODRINTH_SORTS = ['relevance', 'downloads', 'follows', 'newest', 'updated'];
|
||||||
|
|
||||||
async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0 } = {}) {
|
/*
|
||||||
const facets = [['project_type:mod']];
|
* Inhaltsarten, die der Launcher aus dem Modrinth-Katalog holen kann.
|
||||||
|
* ordner = Zielverzeichnis im Spielordner, loaderNoetig = braucht einen
|
||||||
|
* Mod-Loader (Ressourcenpakete laufen auch in Vanilla).
|
||||||
|
*/
|
||||||
|
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 },
|
||||||
|
modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
function inhaltsArt(art) {
|
||||||
|
return INHALTSARTEN[art] || INHALTSARTEN.mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0, art = 'mod' } = {}) {
|
||||||
|
const typ = inhaltsArt(art);
|
||||||
|
const facets = [['project_type:' + typ.projectType]];
|
||||||
if (gameVersion) facets.push(['versions:' + gameVersion]);
|
if (gameVersion) facets.push(['versions:' + gameVersion]);
|
||||||
if (loader && loader !== 'vanilla') facets.push(['categories:' + loader]);
|
// Der Loader-Filter gilt nur für Mods. Ressourcenpakete und Shader werden
|
||||||
|
// bei Modrinth nicht nach Mod-Loader eingeordnet – der Filter würde die
|
||||||
|
// Trefferliste leeren.
|
||||||
|
if (typ.loaderNoetig && loader && loader !== 'vanilla') facets.push(['categories:' + loader]);
|
||||||
if (category) facets.push(['categories:' + category]);
|
if (category) facets.push(['categories:' + category]);
|
||||||
const url = new URL(MODRINTH + '/search');
|
const url = new URL(MODRINTH + '/search');
|
||||||
url.searchParams.set('query', query);
|
url.searchParams.set('query', query);
|
||||||
@@ -251,17 +271,24 @@ async function modrinthSearch({ query = '', gameVersion = '', loader = '', categ
|
|||||||
description: h.description,
|
description: h.description,
|
||||||
author: h.author,
|
author: h.author,
|
||||||
downloads: h.downloads,
|
downloads: h.downloads,
|
||||||
|
follows: h.follows || 0,
|
||||||
iconUrl: h.icon_url || '',
|
iconUrl: h.icon_url || '',
|
||||||
categories: h.categories || [],
|
categories: h.categories || [],
|
||||||
|
// bei Modpacks hilfreich: welche Spielversionen/Loader das Projekt angibt
|
||||||
|
versions: h.versions || [],
|
||||||
|
displayCategories: h.display_categories || h.categories || [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// findet die passende Datei-Version für Spielversion + Loader
|
// findet die passende Datei-Version für Spielversion + Loader
|
||||||
async function modrinthResolveFile({ projectId, gameVersion, loader }) {
|
async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod' }) {
|
||||||
|
const typ = inhaltsArt(art);
|
||||||
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
|
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
|
||||||
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
|
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
|
||||||
if (loader && loader !== 'vanilla') url.searchParams.set('loaders', JSON.stringify([loader]));
|
// Nur Mods werden nach Loader eingeordnet; bei Ressourcenpaketen und
|
||||||
|
// Shadern würde der Filter alle Dateien ausschließen.
|
||||||
|
if (typ.loaderNoetig && loader && loader !== 'vanilla') url.searchParams.set('loaders', JSON.stringify([loader]));
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||||
if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status);
|
if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status);
|
||||||
const versions = await res.json();
|
const versions = await res.json();
|
||||||
@@ -280,6 +307,38 @@ async function modrinthResolveFile({ projectId, gameVersion, loader }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Neueste passende Modpack-Version von Modrinth.
|
||||||
|
* gameVersion optional – ohne Filter kommt die neueste Version.
|
||||||
|
*/
|
||||||
|
async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
|
||||||
|
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
|
||||||
|
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||||
|
if (!res.ok) throw new Error('Modpack-Versionen fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
const versions = await res.json();
|
||||||
|
if (!Array.isArray(versions) || !versions.length) return null;
|
||||||
|
const v = versions[0];
|
||||||
|
const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0];
|
||||||
|
if (!file || !file.url) return null;
|
||||||
|
return {
|
||||||
|
versionId: v.id,
|
||||||
|
versionNumber: v.version_number,
|
||||||
|
name: v.name || v.version_number,
|
||||||
|
filename: file.filename,
|
||||||
|
url: file.url,
|
||||||
|
size: file.size || 0,
|
||||||
|
gameVersions: v.game_versions || [],
|
||||||
|
loaders: v.loaders || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBuffer(url, extraHeaders) {
|
||||||
|
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||||
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
||||||
async function downloadFile(url, destPath, extraHeaders) {
|
async function downloadFile(url, destPath, extraHeaders) {
|
||||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||||
@@ -291,11 +350,14 @@ async function downloadFile(url, destPath, extraHeaders) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lädt eine Mod in <modsDir>; gibt Metadaten für die Instanz zurück
|
// lädt eine Mod in <modsDir>; gibt Metadaten für die Instanz zurück
|
||||||
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir }) {
|
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
|
||||||
const file = await modrinthResolveFile({ projectId, gameVersion, loader });
|
const typ = inhaltsArt(art);
|
||||||
|
const file = await modrinthResolveFile({ projectId, gameVersion, loader, art });
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return { ok: false, reason: 'no-match',
|
return { ok: false, reason: 'no-match',
|
||||||
message: `Keine passende Version für ${gameVersion} / ${loader} gefunden.` };
|
message: typ.loaderNoetig
|
||||||
|
? `Keine passende Version für ${gameVersion} / ${loader} gefunden.`
|
||||||
|
: `Keine passende Version für ${gameVersion} gefunden.` };
|
||||||
}
|
}
|
||||||
const dest = path.join(modsDir, file.filename);
|
const dest = path.join(modsDir, file.filename);
|
||||||
const bytes = await downloadFile(file.url, dest);
|
const bytes = await downloadFile(file.url, dest);
|
||||||
@@ -322,7 +384,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir
|
|||||||
* für die weiß niemand, woher sie stammen.
|
* für die weiß niemand, woher sie stammen.
|
||||||
* -> [{ projectId, title, alt: {...}, neu: {...} }]
|
* -> [{ projectId, title, alt: {...}, neu: {...} }]
|
||||||
*/
|
*/
|
||||||
async function modrinthFindUpdates(mods, gameVersion, loader, onProgress) {
|
async function modrinthFindUpdates(mods, gameVersion, loader, onProgress, art = 'mod') {
|
||||||
const kandidaten = (mods || []).filter((m) => m && m.projectId);
|
const kandidaten = (mods || []).filter((m) => m && m.projectId);
|
||||||
const treffer = [];
|
const treffer = [];
|
||||||
let geprueft = 0;
|
let geprueft = 0;
|
||||||
@@ -333,8 +395,8 @@ async function modrinthFindUpdates(mods, gameVersion, loader, onProgress) {
|
|||||||
await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => {
|
await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => {
|
||||||
let file = null;
|
let file = null;
|
||||||
try {
|
try {
|
||||||
file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader });
|
file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader, art });
|
||||||
} catch { /* Netzfehler -> diese Mod überspringen */ }
|
} catch { /* Netzfehler -> diesen Eintrag überspringen */ }
|
||||||
geprueft++;
|
geprueft++;
|
||||||
if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId });
|
if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId });
|
||||||
if (!file || !file.versionId) return;
|
if (!file || !file.versionId) return;
|
||||||
@@ -383,16 +445,42 @@ const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || '');
|
|||||||
* kein fremdes Kontingent.
|
* kein fremdes Kontingent.
|
||||||
* -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null
|
* -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* CurseForge kennt zwei Schlüsselarten:
|
||||||
|
* - Klassisch (console.curseforge.com): Header x-api-key, oft $2a$10$…
|
||||||
|
* - Studios / Profil-PAT: Header "Authorization: Key cfc_pat_…"
|
||||||
|
*/
|
||||||
|
function cfAuthHeaders(apiKey) {
|
||||||
|
let key = String(apiKey || '').trim();
|
||||||
|
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||||
|
key = key.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
key = key.replace(/[\r\n\t]/g, '').trim();
|
||||||
|
// Komplette Kopfzeile eingefügt?
|
||||||
|
const m = key.match(/^authorization\s*:\s*key\s+(.+)$/i);
|
||||||
|
if (m) key = m[1].trim();
|
||||||
|
if (key.toLowerCase().startsWith('key ')) key = key.slice(4).trim();
|
||||||
|
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
// Neues Studios-Format
|
||||||
|
if (/^cfc_pat_/i.test(key) || /^cf[a-z]_/i.test(key)) {
|
||||||
|
return { key, headers: { Authorization: 'Key ' + key } };
|
||||||
|
}
|
||||||
|
// Klassisch
|
||||||
|
return { key, headers: { 'x-api-key': key } };
|
||||||
|
}
|
||||||
|
|
||||||
function curseforgeZugang(settings) {
|
function curseforgeZugang(settings) {
|
||||||
const s = settings || {};
|
const s = settings || {};
|
||||||
const key = String(s.curseforgeApiKey || '').trim();
|
const auth = cfAuthHeaders(s.curseforgeApiKey);
|
||||||
if (key) {
|
if (auth) {
|
||||||
return {
|
return {
|
||||||
modus: 'direkt',
|
modus: 'direkt',
|
||||||
basis: CURSEFORGE,
|
basis: CURSEFORGE,
|
||||||
kopfzeilen: { 'x-api-key': key },
|
kopfzeilen: auth.headers,
|
||||||
downloadAdresse: (url) => url,
|
downloadAdresse: (url) => url,
|
||||||
downloadKopfzeilen: { 'x-api-key': key },
|
downloadKopfzeilen: auth.headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
|
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
|
||||||
@@ -426,6 +514,250 @@ function forgeCdnUrl(fileId, fileName) {
|
|||||||
return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`;
|
return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 };
|
||||||
|
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552 };
|
||||||
|
|
||||||
|
function cfLoaderType(loader) {
|
||||||
|
return CF_LOADER[String(loader || '').toLowerCase()] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CurseForge-Katalogsuche. Braucht direkten API-Key (modus === 'direkt').
|
||||||
|
* Die Weiterleitung deckt absichtlich nur Dateiauflösung und Download ab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prüft einen API-Key direkt gegen CurseForge (ohne den gespeicherten Stand).
|
||||||
|
* Drei Proben: games, Minecraft, Modsuche – damit man sieht, ob der Key
|
||||||
|
* grundsätzlich gilt oder nur für bestimmte Bereiche fehlt.
|
||||||
|
*/
|
||||||
|
async function curseforgeTestKey(apiKey) {
|
||||||
|
const auth = cfAuthHeaders(apiKey);
|
||||||
|
if (!auth) {
|
||||||
|
return { ok: false, message: 'Kein API-Key angegeben.' };
|
||||||
|
}
|
||||||
|
const key = auth.key;
|
||||||
|
const headers = Object.assign({ Accept: 'application/json', 'User-Agent': UA }, auth.headers);
|
||||||
|
const proben = [
|
||||||
|
{ name: 'games', url: CURSEFORGE + '/games' },
|
||||||
|
{ name: 'minecraft', url: CURSEFORGE + '/games/432' },
|
||||||
|
{ name: 'suche', url: CURSEFORGE + '/mods/search?gameId=432&classId=6&pageSize=1&index=0' },
|
||||||
|
];
|
||||||
|
const ergebnisse = [];
|
||||||
|
for (const p of proben) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(p.url, { headers });
|
||||||
|
let text = '';
|
||||||
|
try { text = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 120); } catch { /* egal */ }
|
||||||
|
ergebnisse.push({ name: p.name, status: res.status, ok: res.ok, text: res.ok ? 'ok' : text });
|
||||||
|
} catch (err) {
|
||||||
|
ergebnisse.push({ name: p.name, status: 0, ok: false, text: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleOk = ergebnisse.every((e) => e.ok);
|
||||||
|
const alle403 = ergebnisse.every((e) => e.status === 401 || e.status === 403);
|
||||||
|
let message;
|
||||||
|
if (alleOk) {
|
||||||
|
message = 'Schlüssel wird akzeptiert – Katalog und Downloads sollten funktionieren.';
|
||||||
|
} else if (alle403) {
|
||||||
|
message = 'CurseForge lehnt diesen Schlüssel ab (HTTP 403/401). Entweder ist er falsch kopiert, '
|
||||||
|
+ 'wurde neu erzeugt („Regenerate“ macht den alten ungültig), oder das Organisationskonto '
|
||||||
|
+ 'ist bei CurseForge noch nicht für die API freigeschaltet.';
|
||||||
|
} else {
|
||||||
|
const suche = ergebnisse.find((e) => e.name === 'suche');
|
||||||
|
if (suche && !suche.ok && ergebnisse.filter((e) => e.name !== 'suche').every((e) => e.ok)) {
|
||||||
|
message = 'Der Schlüssel gilt grundsätzlich, aber die Modsuche wird abgelehnt. '
|
||||||
|
+ 'Für den Katalog wird der Organisationsschlüssel benötigt (nicht nur ein per-game Key).';
|
||||||
|
} else {
|
||||||
|
message = 'Gemischtes Ergebnis: ' + ergebnisse.map((e) => e.name + '=' + e.status).join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: alleOk,
|
||||||
|
message,
|
||||||
|
laenge: key.length,
|
||||||
|
beginntMit: key.slice(0, 7),
|
||||||
|
siehtAusWieCurseForge: /^\$2[aby]\$\d{2}\$/.test(key) || /^cfc_pat_/i.test(key),
|
||||||
|
format: /^cfc_pat_/i.test(key) ? 'studios-pat' : (/^\$2[aby]\$/.test(key) ? 'klassisch' : 'unbekannt'),
|
||||||
|
proben: ergebnisse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function curseforgeSearch(zugang, {
|
||||||
|
query = '', gameVersion = '', loader = '', sort = 'popularity',
|
||||||
|
limit = 30, offset = 0, art = 'mod',
|
||||||
|
} = {}) {
|
||||||
|
if (!zugang || zugang.modus !== 'direkt') {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
hits: [],
|
||||||
|
needKey: true,
|
||||||
|
error: 'Für die CurseForge-Suche brauchst du einen eigenen API-Key in den Netzwerk-Einstellungen. '
|
||||||
|
+ 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const classId = CF_CLASS[art] || CF_CLASS.mod;
|
||||||
|
const url = new URL(CURSEFORGE + '/mods/search');
|
||||||
|
url.searchParams.set('gameId', '432');
|
||||||
|
url.searchParams.set('classId', String(classId));
|
||||||
|
if (query) url.searchParams.set('searchFilter', query);
|
||||||
|
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
|
||||||
|
const lt = cfLoaderType(loader);
|
||||||
|
// Loader-Filter nur bei Mods sinnvoll
|
||||||
|
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
|
||||||
|
// sortField: 1=Featured 2=Popularity 3=LastUpdated 4=Name 5=Author 6=TotalDownloads
|
||||||
|
const sortMap = { popularity: 2, relevance: 2, downloads: 6, updated: 3, newest: 3, follows: 2 };
|
||||||
|
url.searchParams.set('sortField', String(sortMap[sort] || 2));
|
||||||
|
url.searchParams.set('sortOrder', 'desc');
|
||||||
|
url.searchParams.set('pageSize', String(Math.min(50, limit || 30)));
|
||||||
|
url.searchParams.set('index', String(offset || 0));
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: Object.assign({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': UA,
|
||||||
|
}, zugang.kopfzeilen),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try { detail = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
hits: [],
|
||||||
|
needKey: true,
|
||||||
|
error: 'CurseForge lehnt den API-Key ab (HTTP ' + res.status + '). '
|
||||||
|
+ 'Bitte unter Optionen → Netzwerk einen gültigen Schlüssel von console.curseforge.com eintragen. '
|
||||||
|
+ 'Das Proxy-Token ist kein API-Key.'
|
||||||
|
+ (detail ? ' (' + detail + ')' : ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('CurseForge-Suche fehlgeschlagen: HTTP ' + res.status + (detail ? ' – ' + detail : ''));
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const hits = (data.data || []).map((m) => {
|
||||||
|
const author = (m.authors && m.authors[0] && m.authors[0].name) || '';
|
||||||
|
const logo = (m.logo && (m.logo.thumbnailUrl || m.logo.url)) || '';
|
||||||
|
return {
|
||||||
|
projectId: 'cf-' + m.id,
|
||||||
|
cfId: m.id,
|
||||||
|
slug: m.slug,
|
||||||
|
title: m.name,
|
||||||
|
description: m.summary || '',
|
||||||
|
author,
|
||||||
|
downloads: m.downloadCount || 0,
|
||||||
|
iconUrl: logo,
|
||||||
|
categories: (m.categories || []).map((c) => c.name).filter(Boolean),
|
||||||
|
source: 'curseforge',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const total = (data.pagination && data.pagination.totalCount) || hits.length;
|
||||||
|
return { total, hits };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cfFileMatches(file, gameVersion, loader, art) {
|
||||||
|
if (!file || file.isAvailable === false) return false;
|
||||||
|
const gvs = file.gameVersions || [];
|
||||||
|
if (gameVersion && !gvs.some((v) => String(v).toLowerCase() === String(gameVersion).toLowerCase())) {
|
||||||
|
// manche Einträge trennen Version und Loader in gameVersions
|
||||||
|
const hasVer = gvs.some((v) => String(v) === gameVersion);
|
||||||
|
if (!hasVer) return false;
|
||||||
|
}
|
||||||
|
if (art === 'mod' && loader && loader !== 'vanilla') {
|
||||||
|
const want = String(loader).toLowerCase();
|
||||||
|
const ok = gvs.some((v) => String(v).toLowerCase() === want)
|
||||||
|
|| (file.sortableGameVersions || []).some((s) => {
|
||||||
|
const name = String(s.gameVersionName || s.gameVersion || '').toLowerCase();
|
||||||
|
return name === want;
|
||||||
|
});
|
||||||
|
// Wenn gar kein Loader in den Tags steht, trotzdem erlauben (manche Dateien)
|
||||||
|
const anyLoader = gvs.some((v) => /fabric|forge|quilt|neoforge/i.test(String(v)))
|
||||||
|
|| (file.sortableGameVersions || []).some((s) => /fabric|forge|quilt|neoforge/i.test(String(s.gameVersionName || '')));
|
||||||
|
if (anyLoader && !ok) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
|
||||||
|
if (!zugang || zugang.modus !== 'direkt') {
|
||||||
|
const err = new Error('CurseForge-Dateiauswahl braucht einen eigenen API-Key.');
|
||||||
|
err.code = 'need-key';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const url = new URL(CURSEFORGE + '/mods/' + modId + '/files');
|
||||||
|
url.searchParams.set('pageSize', '50');
|
||||||
|
url.searchParams.set('index', '0');
|
||||||
|
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
|
||||||
|
const lt = cfLoaderType(loader);
|
||||||
|
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: Object.assign({ Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('CurseForge-Dateien fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
const files = data.data || [];
|
||||||
|
const match = files.find((f) => cfFileMatches(f, gameVersion, loader, art)) || files[0];
|
||||||
|
if (!match) return null;
|
||||||
|
const name = match.fileName || ('file-' + match.id + '.jar');
|
||||||
|
let dl = match.downloadUrl || null;
|
||||||
|
if (!dl) dl = forgeCdnUrl(match.id, name);
|
||||||
|
return {
|
||||||
|
fileId: match.id,
|
||||||
|
filename: name,
|
||||||
|
url: dl,
|
||||||
|
versionNumber: match.displayName || match.fileName || String(match.id),
|
||||||
|
dependencies: match.dependencies || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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-/, '');
|
||||||
|
const modId = Number(rawId);
|
||||||
|
if (!modId) return { ok: false, message: 'Ungültige CurseForge-Projekt-ID.' };
|
||||||
|
|
||||||
|
const file = await curseforgePickFile(zugang, modId, gameVersion, loader, art);
|
||||||
|
if (!file || !file.url) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'no-match',
|
||||||
|
message: `Keine passende Datei für ${gameVersion || '?'} / ${loader || 'any'} gefunden.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = path.join(modsDir, file.filename);
|
||||||
|
// Download: direkt mit Key oder über Weiterleitung
|
||||||
|
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
||||||
|
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
|
||||||
|
const bytes = await downloadFile(url, dest, headers);
|
||||||
|
|
||||||
|
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
|
||||||
|
const depIds = (file.dependencies || [])
|
||||||
|
.filter((d) => d.relationType === 3 && d.modId)
|
||||||
|
.map((d) => d.modId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dependencies: depIds.map((id) => ({ projectId: 'cf-' + id, required: true })),
|
||||||
|
entry: {
|
||||||
|
projectId: 'cf-' + modId,
|
||||||
|
title: title || file.filename,
|
||||||
|
filename: file.filename,
|
||||||
|
versionId: String(file.fileId),
|
||||||
|
versionNumber: file.versionNumber,
|
||||||
|
gameVersion,
|
||||||
|
loader,
|
||||||
|
source: 'curseforge',
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Löst die Dateien eines CurseForge-Modpacks auf.
|
* Löst die Dateien eines CurseForge-Modpacks auf.
|
||||||
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
|
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
|
||||||
@@ -479,7 +811,7 @@ async function modrinthProjectTitle(projectId) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
|
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
|
||||||
getMcVersions,
|
getMcVersions,
|
||||||
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle,
|
modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
|
||||||
modrinthFindUpdates, downloadFile,
|
modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
|
||||||
curseforgeResolveFiles, forgeCdnUrl, curseforgeZugang,
|
curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
|
||||||
};
|
};
|
||||||
|
|||||||
72
src/store.js
72
src/store.js
@@ -75,9 +75,12 @@ const DEFAULT_SETTINGS = {
|
|||||||
sortBy: 'lastPlayed', // lastPlayed | name
|
sortBy: 'lastPlayed', // lastPlayed | name
|
||||||
iso3dIcons: true, // Block-Symbole räumlich darstellen
|
iso3dIcons: true, // Block-Symbole räumlich darstellen
|
||||||
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
|
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
|
||||||
|
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
|
||||||
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
|
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
|
||||||
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
|
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
|
||||||
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
|
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
|
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
|
||||||
proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk
|
proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk
|
||||||
proxyHost: '',
|
proxyHost: '',
|
||||||
@@ -124,12 +127,25 @@ function gameDir(id) {
|
|||||||
return path.join(instancePath(id), 'minecraft');
|
return path.join(instancePath(id), 'minecraft');
|
||||||
}
|
}
|
||||||
|
|
||||||
function modsDir(id) {
|
/*
|
||||||
return path.join(gameDir(id), 'mods');
|
* Inhalte je Art: Mods, Ressourcenpakete und Shader liegen in verschiedenen
|
||||||
|
* Ordnern des Spielverzeichnisses und werden getrennt verzeichnet.
|
||||||
|
* "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' };
|
||||||
|
|
||||||
|
function inhaltsDir(id, art = 'mod') {
|
||||||
|
return path.join(gameDir(id), INHALT_ORDNER[art] || INHALT_ORDNER.mod);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modsIndexFile(id) {
|
function modsDir(id, art = 'mod') {
|
||||||
return path.join(instancePath(id), 'mods.json');
|
return inhaltsDir(id, art);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modsIndexFile(id, art = 'mod') {
|
||||||
|
return path.join(instancePath(id), INHALT_INDEX[art] || INHALT_INDEX.mod);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- installierte Mods (Metadaten je Instanz) ----------
|
// ---------- installierte Mods (Metadaten je Instanz) ----------
|
||||||
@@ -142,8 +158,8 @@ function modsIndexFile(id) {
|
|||||||
const MOD_ENDUNG = /\.(jar|zip)$/i;
|
const MOD_ENDUNG = /\.(jar|zip)$/i;
|
||||||
const AUS_ENDUNG = /\.(jar|zip)\.disabled$/i;
|
const AUS_ENDUNG = /\.(jar|zip)\.disabled$/i;
|
||||||
|
|
||||||
function scanModsDir(id) {
|
function scanModsDir(id, art = 'mod') {
|
||||||
const dir = modsDir(id);
|
const dir = modsDir(id, art);
|
||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
const gefunden = [];
|
const gefunden = [];
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
@@ -163,9 +179,9 @@ function scanModsDir(id) {
|
|||||||
return gefunden;
|
return gefunden;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listMods(id) {
|
function listMods(id, art = 'mod') {
|
||||||
const aufDerPlatte = new Map(scanModsDir(id).map((m) => [m.filename, m]));
|
const aufDerPlatte = new Map(scanModsDir(id, art).map((m) => [m.filename, m]));
|
||||||
const indexed = readJson(modsIndexFile(id), []);
|
const indexed = readJson(modsIndexFile(id, art), []);
|
||||||
const merged = new Map();
|
const merged = new Map();
|
||||||
|
|
||||||
for (const entry of indexed) {
|
for (const entry of indexed) {
|
||||||
@@ -186,8 +202,8 @@ function listMods(id) {
|
|||||||
* Schaltet eine Mod an oder aus, indem die Datei umbenannt wird.
|
* Schaltet eine Mod an oder aus, indem die Datei umbenannt wird.
|
||||||
* -> { ok, enabled } oder { ok: false, reason }
|
* -> { ok, enabled } oder { ok: false, reason }
|
||||||
*/
|
*/
|
||||||
function setModEnabled(id, filename, enabled) {
|
function setModEnabled(id, filename, enabled, art = 'mod') {
|
||||||
const dir = modsDir(id);
|
const dir = modsDir(id, art);
|
||||||
const anPfad = path.join(dir, filename);
|
const anPfad = path.join(dir, filename);
|
||||||
const ausPfad = anPfad + '.disabled';
|
const ausPfad = anPfad + '.disabled';
|
||||||
// Pfad darf den mods-Ordner nicht verlassen
|
// Pfad darf den mods-Ordner nicht verlassen
|
||||||
@@ -212,18 +228,36 @@ function setModEnabled(id, filename, enabled) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMod(id, entry) {
|
function addMod(id, entry, art = 'mod') {
|
||||||
const list = listMods(id).filter((m) => m.filename !== entry.filename);
|
const list = listMods(id, art).filter((m) => m.filename !== entry.filename);
|
||||||
list.push(entry);
|
list.push(entry);
|
||||||
writeJson(modsIndexFile(id), list);
|
writeJson(modsIndexFile(id, art), list);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMod(id, filename) {
|
/*
|
||||||
const list = listMods(id).filter((m) => m.filename !== filename);
|
* Findet installierte Mods, die den per "filename" identifizierten Mod als
|
||||||
writeJson(modsIndexFile(id), list);
|
* 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);
|
||||||
// zugehörige Datei löschen – auch, wenn sie gerade abgeschaltet ist
|
// zugehörige Datei löschen – auch, wenn sie gerade abgeschaltet ist
|
||||||
const jar = path.join(modsDir(id), filename);
|
const jar = path.join(modsDir(id, art), filename);
|
||||||
for (const p of [jar, jar + '.disabled']) {
|
for (const p of [jar, jar + '.disabled']) {
|
||||||
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* egal */ }
|
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* egal */ }
|
||||||
}
|
}
|
||||||
@@ -423,12 +457,14 @@ module.exports = {
|
|||||||
instancePath,
|
instancePath,
|
||||||
gameDir,
|
gameDir,
|
||||||
modsDir,
|
modsDir,
|
||||||
|
inhaltsDir,
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
setCustomImage,
|
setCustomImage,
|
||||||
clearCustomImage,
|
clearCustomImage,
|
||||||
listMods,
|
listMods,
|
||||||
addMod,
|
addMod,
|
||||||
removeMod,
|
removeMod,
|
||||||
|
findDependents,
|
||||||
setModEnabled,
|
setModEnabled,
|
||||||
listInstances,
|
listInstances,
|
||||||
getInstance,
|
getInstance,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
--text:#e4e4e6; --dim:#9a9aa2;
|
--text:#e4e4e6; --dim:#9a9aa2;
|
||||||
--sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
|
--sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
|
||||||
--danger:#e06c6c; --play:#5cb85c;
|
--danger:#e06c6c; --play:#5cb85c;
|
||||||
|
--input:#1e1e1e;
|
||||||
}
|
}
|
||||||
:root[data-theme="dark"] body{background:var(--win)}
|
:root[data-theme="dark"] body{background:var(--win)}
|
||||||
:root[data-theme="dark"] .search-wrap input,
|
:root[data-theme="dark"] .search-wrap input,
|
||||||
@@ -65,7 +66,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
:root[data-theme="dark"] .field>span{color:var(--text)}
|
:root[data-theme="dark"] .field>span{color:var(--text)}
|
||||||
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
|
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
|
||||||
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
|
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
|
||||||
:root[data-theme="dark"] .mods-filters select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
|
:root[data-theme="dark"] .mods-filters select,
|
||||||
|
:root[data-theme="dark"] .mods-search select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
|
||||||
:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
|
:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
|
||||||
:root[data-theme="dark"] .modal-close{color:#aaa}
|
:root[data-theme="dark"] .modal-close{color:#aaa}
|
||||||
:root[data-theme="dark"] .modal-close:hover{color:#fff}
|
:root[data-theme="dark"] .modal-close:hover{color:#fff}
|
||||||
@@ -139,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}
|
.group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px}
|
||||||
.card.dragging{opacity:.4}
|
.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 ---------- */
|
/* ---------- 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{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}
|
.empty h2{color:#333;margin:0}
|
||||||
@@ -160,6 +173,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
|
|
||||||
/* ---------- Modals ---------- */
|
/* ---------- Modals ---------- */
|
||||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50}
|
.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50}
|
||||||
|
/* Bestätigungsdialog immer über anderen Dialogen (z. B. Screenshots) */
|
||||||
|
#modal-confirm,#modal-prompt{z-index:120}
|
||||||
.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}
|
.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}
|
||||||
.modal-sm{width:420px}.modal-lg{width:820px}
|
.modal-sm{width:420px}.modal-lg{width:820px}
|
||||||
#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)}
|
#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)}
|
||||||
@@ -224,9 +239,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
/* Mods */
|
/* Mods */
|
||||||
.mods-body{min-height:420px}
|
.mods-body{min-height:420px}
|
||||||
.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px}
|
.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px}
|
||||||
.mods-search{display:flex;gap:8px;margin-bottom:10px}
|
.mods-search{display:flex;gap:8px;margin-bottom:10px;align-items:stretch}
|
||||||
|
.mods-search select{
|
||||||
|
flex:0 0 auto;min-width:140px;max-width:180px;
|
||||||
|
padding:7px 10px;border:1px solid var(--border);border-radius:3px;
|
||||||
|
background:var(--input,#fff);color:var(--text);
|
||||||
|
font-family:inherit;font-size:12px;
|
||||||
|
}
|
||||||
|
.mods-search select:focus{outline:none;border-color:var(--accent)}
|
||||||
.mods-filters{display:flex;gap:8px;margin-bottom:12px}
|
.mods-filters{display:flex;gap:8px;margin-bottom:12px}
|
||||||
.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
|
.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
|
||||||
|
.mods-filters select:focus{outline:none;border-color:var(--accent)}
|
||||||
#m-more{display:block;width:100%;margin-top:8px}
|
#m-more{display:block;width:100%;margin-top:8px}
|
||||||
.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)}
|
.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)}
|
||||||
.mods-search input:focus{outline:none;border-color:var(--accent)}
|
.mods-search input:focus{outline:none;border-color:var(--accent)}
|
||||||
@@ -311,6 +334,10 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
|||||||
.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer}
|
.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer}
|
||||||
.screen-thumb:hover .screen-del{opacity:1}
|
.screen-thumb:hover .screen-del{opacity:1}
|
||||||
|
.screen-thumb.selected{outline:2px solid var(--sel-br);outline-offset:1px;background:var(--sel)}
|
||||||
|
.screen-thumb .screen-check{position:absolute;top:4px;left:4px;width:18px;height:18px;margin:0;cursor:pointer;z-index:2}
|
||||||
|
.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
|
||||||
|
.screens-toolbar .hint{font-size:11px}
|
||||||
.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
|
.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
|
||||||
|
|
||||||
/* Konsole (dunkel wie ein Log) */
|
/* Konsole (dunkel wie ein Log) */
|
||||||
@@ -348,6 +375,7 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
|||||||
.info-card{max-width:760px}
|
.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-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)}
|
.info-status{font-size:12px;color:var(--dim)}
|
||||||
|
.modal-sep{border-top:1px solid var(--border2)}
|
||||||
.info-meta{margin-top:10px}
|
.info-meta{margin-top:10px}
|
||||||
.info-meta .settings-kv-row{align-items:center}
|
.info-meta .settings-kv-row{align-items:center}
|
||||||
.info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px}
|
.info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px}
|
||||||
|
|||||||
175
src/technicdiscover.js
Normal file
175
src/technicdiscover.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* technicdiscover.js – Technic-Modpacks direkt über die Technic-Plattform
|
||||||
|
* durchsuchen und installieren, ohne vorher manuell eine Datei herunterladen
|
||||||
|
* zu müssen. Genutzt wird dieselbe öffentliche API wie bei Prism Launcher:
|
||||||
|
*
|
||||||
|
* Suche: GET https://api.technicpack.net/search?build=multimc&q=<term>
|
||||||
|
* Beliebt: GET https://api.technicpack.net/trending?build=multimc
|
||||||
|
* Pack-Infos: GET https://api.technicpack.net/modpack/<slug>?build=multimc
|
||||||
|
* -> entweder "url" (ein fertiges ZIP) oder "solder" (Solder-Server)
|
||||||
|
* Solder: GET <solder>modpack/<slug> – Liste der Builds
|
||||||
|
* GET <solder>modpack/<slug>/<build> – Mods dieser Fassung
|
||||||
|
* { minecraft, forge, mods: [{ name, url, ... }] }
|
||||||
|
*
|
||||||
|
* "build=multimc" ist die Kennung, mit der sich auch andere Drittlauncher
|
||||||
|
* bei der Technic-Plattform melden – kein echter Minecraft-Build, sondern
|
||||||
|
* nur ein Client-Identifier, den die API erwartet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const forge = require('./forge');
|
||||||
|
const technicimport = require('./technicimport');
|
||||||
|
|
||||||
|
const API_BASE = 'https://api.technicpack.net/';
|
||||||
|
const API_BUILD = 'multimc';
|
||||||
|
const UA = 'AeroMC-Launcher/1.0';
|
||||||
|
|
||||||
|
async function getJson(url) {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBuffer(url) {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* query leer -> beliebte Packs (trending), sonst Suchtreffer.
|
||||||
|
* -> [{ slug, name, iconUrl }]
|
||||||
|
*/
|
||||||
|
async function search(query) {
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
const url = q
|
||||||
|
? `${API_BASE}search?build=${API_BUILD}&q=${encodeURIComponent(q)}`
|
||||||
|
: `${API_BASE}trending?build=${API_BUILD}`;
|
||||||
|
const data = await getJson(url);
|
||||||
|
const packs = Array.isArray(data.modpacks) ? data.modpacks : [];
|
||||||
|
return packs
|
||||||
|
.filter((p) => p && p.slug && p.slug !== 'vanilla')
|
||||||
|
.map((p) => ({ slug: p.slug, name: p.name || p.slug, iconUrl: p.iconUrl || '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Entpackt einen ZIP-Buffer nach destDir.
|
||||||
|
* Enthält der ZIP-Inhalt bereits eine erkennbare Ordnerstruktur (mods/,
|
||||||
|
* config/, …), wird sie 1:1 übernommen. Liegen nur einzelne Dateien direkt
|
||||||
|
* im ZIP-Wurzelverzeichnis (typisch für ältere, einzeln gepackte Solder-
|
||||||
|
* Mods), landen sie stattdessen im mods-Ordner.
|
||||||
|
*/
|
||||||
|
function extractZipBuffer(buf, destDir) {
|
||||||
|
const idx = forge.readZipIndex(buf);
|
||||||
|
const namen = Object.keys(idx);
|
||||||
|
const hatOrdnerStruktur = namen.some((n) => /^(mods|config|resources|resourcepacks|scripts|structures|bin)\//i.test(n));
|
||||||
|
for (const name of namen) {
|
||||||
|
if (name.endsWith('/')) continue; // reines Verzeichnis – wird beim Schreiben automatisch angelegt
|
||||||
|
const relPfad = hatOrdnerStruktur ? name : path.posix.join('mods', name);
|
||||||
|
const zielPfad = path.join(destDir, ...relPfad.split('/'));
|
||||||
|
if (!zielPfad.startsWith(destDir + path.sep)) continue; // Pfad-Ausbruch verhindern
|
||||||
|
fs.mkdirSync(path.dirname(zielPfad), { recursive: true });
|
||||||
|
fs.writeFileSync(zielPfad, forge.readZipEntry(buf, idx[name]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solder-Basis-URLs enden mal mit "/api/", mal ohne abschließenden Slash
|
||||||
|
function solderUrl(basis, pfad) {
|
||||||
|
const b = String(basis || '').replace(/\/+$/, '');
|
||||||
|
return `${b}/${pfad}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installSolderBuild(solderBasis, slug, gameDir, onProgress) {
|
||||||
|
const info = await getJson(solderUrl(solderBasis, 'modpack/' + encodeURIComponent(slug)));
|
||||||
|
const build = info.recommended || info.latest || (info.builds || [])[0];
|
||||||
|
if (!build) throw new Error('Keine Fassung (Build) für dieses Modpack gefunden.');
|
||||||
|
const daten = await getJson(solderUrl(solderBasis, `modpack/${encodeURIComponent(slug)}/${encodeURIComponent(build)}`));
|
||||||
|
const mods = Array.isArray(daten.mods) ? daten.mods : [];
|
||||||
|
|
||||||
|
for (let i = 0; i < mods.length; i++) {
|
||||||
|
const mod = mods[i];
|
||||||
|
if (onProgress) onProgress({ current: i + 1, total: mods.length, name: mod.name || '' });
|
||||||
|
if (!mod || !mod.url) continue;
|
||||||
|
try {
|
||||||
|
const buf = await getBuffer(mod.url);
|
||||||
|
extractZipBuffer(buf, gameDir);
|
||||||
|
} catch { /* einzelner Mod fehlgeschlagen – Rest trotzdem weiter versuchen */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let loader = 'vanilla';
|
||||||
|
const loaderVersion = daten.forge || '';
|
||||||
|
if (loaderVersion) loader = 'forge';
|
||||||
|
return { version: daten.minecraft || '', loader, loaderVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installSingleZip(url, gameDir) {
|
||||||
|
const buf = await getBuffer(url);
|
||||||
|
// in einen Temp-Ordner entpacken, damit wir wie beim Ordner-Import
|
||||||
|
// (technicimport.js) Version/Loader aus bin/modpack.jar erkennen können,
|
||||||
|
// bevor die Dateien an ihren endgültigen Platz kommen
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'technic-dl-'));
|
||||||
|
try {
|
||||||
|
extractZipBuffer(buf, tmp);
|
||||||
|
let base = tmp;
|
||||||
|
if (!fs.existsSync(path.join(base, 'bin')) && !fs.existsSync(path.join(base, 'mods'))) {
|
||||||
|
// manche Packs haben einen umschließenden Ordner im ZIP
|
||||||
|
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
|
||||||
|
.find((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
|
||||||
|
if (sub) base = sub;
|
||||||
|
}
|
||||||
|
const { version, loader, loaderVersion } = technicimport.detectVersion(base);
|
||||||
|
fs.cpSync(base, gameDir, { recursive: true });
|
||||||
|
return { version, loader, loaderVersion };
|
||||||
|
} finally {
|
||||||
|
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Lädt ein Modpack von der Technic-Plattform und legt daraus eine neue
|
||||||
|
* Instanz an. onProgress(info) wird bei Solder-Packs pro Mod aufgerufen.
|
||||||
|
* -> { ok, instance } oder { ok: false, message }
|
||||||
|
*/
|
||||||
|
async function installFromPlatform(store, slug, onProgress) {
|
||||||
|
let created = null;
|
||||||
|
try {
|
||||||
|
const detail = await getJson(`${API_BASE}modpack/${encodeURIComponent(slug)}?build=${API_BUILD}`);
|
||||||
|
// die Technic-API antwortet bei unbekanntem Slug mit HTTP 200 und
|
||||||
|
// {"error": "..."} statt einem Fehlerstatus – daher explizit prüfen
|
||||||
|
if (detail.error) throw new Error(detail.error);
|
||||||
|
|
||||||
|
created = store.createOrReplaceImportedInstance({
|
||||||
|
name: detail.displayName || detail.name || slug,
|
||||||
|
notes: detail.description || '',
|
||||||
|
});
|
||||||
|
const gameDir = store.gameDir(created.id);
|
||||||
|
fs.mkdirSync(gameDir, { recursive: true });
|
||||||
|
|
||||||
|
let ergebnis;
|
||||||
|
if (detail.solder) {
|
||||||
|
ergebnis = await installSolderBuild(detail.solder, slug, gameDir, onProgress);
|
||||||
|
} else if (detail.url) {
|
||||||
|
ergebnis = await installSingleZip(detail.url, gameDir);
|
||||||
|
} else {
|
||||||
|
throw new Error('Für dieses Modpack ist kein Download hinterlegt.');
|
||||||
|
}
|
||||||
|
store.updateInstance(created.id, {
|
||||||
|
minecraft: {
|
||||||
|
version: ergebnis.version || detail.minecraft || '',
|
||||||
|
loader: ergebnis.loader || 'vanilla',
|
||||||
|
loaderVersion: ergebnis.loaderVersion || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true, instance: store.getInstance(created.id) };
|
||||||
|
} catch (err) {
|
||||||
|
// fehlgeschlagene Installation nicht als leere Karteileiche stehen lassen
|
||||||
|
if (created) { try { store.deleteInstance(created.id); } catch { /* egal */ } }
|
||||||
|
return { ok: false, message: 'Installation fehlgeschlagen: ' + err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { search, installFromPlatform };
|
||||||
172
src/technicimport.js
Normal file
172
src/technicimport.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen
|
||||||
|
* -------------------------------------------------------------------
|
||||||
|
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
|
||||||
|
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
|
||||||
|
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
|
||||||
|
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
|
||||||
|
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
|
||||||
|
* bei einem klassischen .minecraft.
|
||||||
|
*
|
||||||
|
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
|
||||||
|
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
|
||||||
|
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
|
||||||
|
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
|
||||||
|
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const forge = require('./forge');
|
||||||
|
|
||||||
|
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
|
||||||
|
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
|
||||||
|
const SKIP_DIRS = new Set(['bin']);
|
||||||
|
|
||||||
|
function technicRoot() {
|
||||||
|
return path.join(process.env.APPDATA || '', '.technic');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
|
||||||
|
// oder ein einzelner Pack-Ordner angegeben wurde
|
||||||
|
function resolveModpacksDir(inputPath) {
|
||||||
|
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
|
||||||
|
if (!fs.existsSync(root)) return null;
|
||||||
|
|
||||||
|
const direct = path.join(root, 'modpacks');
|
||||||
|
if (fs.existsSync(direct)) return direct;
|
||||||
|
if (path.basename(root).toLowerCase() === 'modpacks') return root;
|
||||||
|
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
|
||||||
|
}
|
||||||
|
|
||||||
|
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
|
||||||
|
function looksLikePack(dir) {
|
||||||
|
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
|
||||||
|
return markers.some((name) => fs.existsSync(path.join(dir, name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
|
||||||
|
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
|
||||||
|
// als stabile API dokumentiert ist
|
||||||
|
function readInstalledPacksMeta(root) {
|
||||||
|
const data = readJson(path.join(root, 'installedPacks'));
|
||||||
|
const packs = data && data.installedPacks;
|
||||||
|
return packs && typeof packs === 'object' ? packs : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
|
||||||
|
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
|
||||||
|
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
|
||||||
|
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
|
||||||
|
function detectVersion(packDir) {
|
||||||
|
const bin = path.join(packDir, 'bin');
|
||||||
|
const jarFile = path.join(bin, 'modpack.jar');
|
||||||
|
if (fs.existsSync(jarFile)) {
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(jarFile);
|
||||||
|
const idx = forge.readZipIndex(buf);
|
||||||
|
const entry = idx['version.json'];
|
||||||
|
if (entry) {
|
||||||
|
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
|
||||||
|
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
|
||||||
|
const profile = JSON.parse(text);
|
||||||
|
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
|
||||||
|
let loader = 'vanilla';
|
||||||
|
let loaderVersion = '';
|
||||||
|
for (const lib of libs) {
|
||||||
|
const name = String((lib && lib.name) || '');
|
||||||
|
if (/net\.minecraftforge:forge:/i.test(name)) {
|
||||||
|
loader = 'forge';
|
||||||
|
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
|
||||||
|
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
|
||||||
|
loader = 'neoforge';
|
||||||
|
loaderVersion = name.split(':')[2] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = String(profile.id || profile.inheritsFrom || '');
|
||||||
|
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
|
||||||
|
if (version) return { version, loader, loaderVersion };
|
||||||
|
}
|
||||||
|
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
|
||||||
|
}
|
||||||
|
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
|
||||||
|
const legacy = path.join(bin, 'version');
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(legacy)) {
|
||||||
|
const version = fs.readFileSync(legacy, 'utf8').trim();
|
||||||
|
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
|
||||||
|
}
|
||||||
|
} catch { /* egal, bleibt leer */ }
|
||||||
|
return { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(inputPath) {
|
||||||
|
const modpacksDir = resolveModpacksDir(inputPath);
|
||||||
|
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
|
||||||
|
|
||||||
|
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
|
||||||
|
? path.dirname(modpacksDir) : modpacksDir;
|
||||||
|
const meta = readInstalledPacksMeta(root);
|
||||||
|
|
||||||
|
const list = [];
|
||||||
|
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const dir = path.join(modpacksDir, entry.name);
|
||||||
|
if (!looksLikePack(dir)) continue;
|
||||||
|
|
||||||
|
const { version, loader, loaderVersion } = detectVersion(dir);
|
||||||
|
const info = meta[entry.name] || {};
|
||||||
|
list.push({
|
||||||
|
folder: entry.name,
|
||||||
|
dir,
|
||||||
|
name: info.displayName || entry.name,
|
||||||
|
group: '',
|
||||||
|
notes: '',
|
||||||
|
version,
|
||||||
|
loader,
|
||||||
|
loaderVersion,
|
||||||
|
gameDir: dir,
|
||||||
|
hasGameData: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
|
||||||
|
}
|
||||||
|
|
||||||
|
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
|
||||||
|
function copyInto(sourceDir, targetDir) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
for (const name of fs.readdirSync(sourceDir)) {
|
||||||
|
if (SKIP_DIRS.has(name)) continue;
|
||||||
|
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOne(store, entry, copyData = true) {
|
||||||
|
const created = store.createOrReplaceImportedInstance({
|
||||||
|
name: entry.name,
|
||||||
|
group: entry.group,
|
||||||
|
notes: entry.notes,
|
||||||
|
minecraft: {
|
||||||
|
version: entry.version,
|
||||||
|
loader: entry.loader,
|
||||||
|
loaderVersion: entry.loaderVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||||
|
copyInto(entry.gameDir, store.gameDir(created.id));
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
return { id: created.id, name: created.name, copied };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion };
|
||||||
Reference in New Issue
Block a user