Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bafad25059 | ||
| ec4992c5c7 | |||
| 3e28b50f1b | |||
|
|
54d248133c | ||
|
|
129ab2d939 | ||
| b250ef16fd | |||
|
|
9e601b1962 | ||
|
|
176d20ae7b | ||
|
|
eff6403dd5 | ||
|
|
a803f297ea | ||
| dc55e64ca3 | |||
| 64b9c460af | |||
|
|
da31e4735a |
219
README.md
219
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.0 Beta
|
Aktueller Stand: Version 1.0.6
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,59 +20,208 @@ 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
|
||||||
- Import von Modrinth-Modpacks im .mrpack-Format
|
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, MultiMC/Prism sowie Technic
|
||||||
- Import vorhandener MultiMC-Instanzen
|
- 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
|
||||||
- Verwaltung von Welten, Screenshots und Spielordnern direkt aus dem Launcher
|
- Mehrfachauswahl mit Strg-Klick für Sammellöschen, Tastenkürzel für die wichtigsten Aktionen (Strg+F, Strg+N, Entf)
|
||||||
- Bedrock-Bereich für Start und Verwaltung vorhandener Bedrock-Daten unter Windows
|
- Mods/Modpacks/Instanzen auch per Drag & Drop aufs Fenster ziehen
|
||||||
- Update-Funktion für neue Launcher-Versionen
|
- Mods zusätzlich als eigene Datei hinzufügen, wenn sie nicht über Modrinth/CurseForge zu finden sind
|
||||||
|
- Warnt beim Entfernen eines Mods, falls ein anderer installierter Mod ihn noch als Pflicht-Abhängigkeit braucht
|
||||||
|
- Globales Backup inkl. automatischer Zeitplan-Sicherungen, dazu Schnellsicherung einzelner Welten
|
||||||
|
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
|
||||||
|
- Serverliste mit Live-Status (Spieleranzahl, MOTD, Version) und Übernahme einzelner Einträge in andere Instanzen
|
||||||
|
- Online- und Offline-Start
|
||||||
|
- Eingebaute Konsole/Log-Ansicht 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.
|
|
||||||
|
|
||||||
## 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 oder ein Modpack hinzufügen.
|
5. Optional Mods/Ressourcenpakete/Shader hinzufügen oder bestehende Setups über **Importieren** übernehmen.
|
||||||
6. Instanz starten.
|
6. Instanz starten (online oder offline).
|
||||||
|
|
||||||
## Was AeroMC verwaltet
|
## Instanzen
|
||||||
|
|
||||||
- Minecraft-Versionen und gemeinsame Spieldateien
|
Jede Instanz hat:
|
||||||
- Instanzbezogene Mods und Konfigurationen
|
|
||||||
- Welten und Screenshots
|
|
||||||
- Mehrere Microsoft-Konten
|
|
||||||
- Java-Auswahl pro Instanz
|
|
||||||
|
|
||||||
Alle Nutzerdaten werden lokal unter %APPDATA%\AeroMC gespeichert.
|
- 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
|
||||||
|
|
||||||
|
Über **Importieren**:
|
||||||
|
|
||||||
|
| Quelle | Typische Nutzung |
|
||||||
|
|---|---|
|
||||||
|
| ZIP-Archiv | AeroMC-Export (`instance.json`) sowie einzelne MultiMC/Prism-Instanzen (auch gezippt) |
|
||||||
|
| Modpack | Modrinth-Katalog direkt durchsuchen und installieren, oder Datei importieren: `.mrpack` sowie CurseForge-ZIP (`manifest.json`) |
|
||||||
|
| Minecraft Launcher | Profile aus dem offiziellen Launcher |
|
||||||
|
| CurseForge | Instanzen aus dem CurseForge-Ordner |
|
||||||
|
| MultiMC / Prism | Vorhandene MultiMC- oder Prism-Instanzen |
|
||||||
|
| Technic | Technic-Katalog direkt durchsuchen und installieren, oder eine vorhandene Technic-Installation importieren (`%APPDATA%\.technic\modpacks`) |
|
||||||
|
|
||||||
|
Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
|
||||||
|
|
||||||
|
Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache.
|
||||||
|
|
||||||
|
Im Reiter **Modpack** desselben Dialogs lässt sich zusätzlich direkt im Modrinth-Katalog suchen und ein Modpack mit einem Klick installieren – ganz ohne vorherigen manuellen Download der `.mrpack`-Datei. Im Reiter **Technic** gibt es dasselbe für den Technic-Katalog.
|
||||||
|
|
||||||
|
Alternativ: `.jar`-, `.mrpack`- oder `.zip`-Dateien direkt aufs Launcher-Fenster ziehen. AeroMC erkennt anhand des Inhalts, ob es sich um einen Mod (landet in der gerade offenen/ausgewählten Instanz), ein Modpack oder einen Instanz-Export handelt.
|
||||||
|
|
||||||
|
## Mods, Ressourcenpakete und Shader
|
||||||
|
|
||||||
|
Im Inhalts-Dialog einer Instanz:
|
||||||
|
|
||||||
|
- Katalog durchsuchen (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
|
||||||
|
|
||||||
|
Unter **Optionen → Backup**:
|
||||||
|
|
||||||
|
- Manuelles globales Backup (alle Instanzen + Launcher-Einstellungen)
|
||||||
|
- Automatische Sicherungen (täglich oder wöchentlich, einstellbare Anzahl)
|
||||||
|
- Optional zusätzlich vor jedem Spielstart sichern
|
||||||
|
- Wiederherstellung aus vorhandenem Backup
|
||||||
|
|
||||||
|
Speicherort: `Dokumente\AeroMC Launcher\Backups`.
|
||||||
|
|
||||||
|
**Wichtig:** Konten und Tokens werden nicht mitgesichert. Beim Wiederherstellen werden aktuelle Instanzen und Launcher-Einstellungen durch den Backup-Inhalt ersetzt.
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
- Webseite: https://M_Viper.de
|
- Webseite: https://m-viper.de
|
||||||
- Gitseite: https://git.viper.ipv64.net
|
- Gitseite: https://git.viper.ipv64.net
|
||||||
- Autor: M_Viper
|
- Autor: M_Viper
|
||||||
|
|
||||||
@@ -81,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.
|
||||||
|
|||||||
817
package-lock.json
generated
817
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"productName": "AeroMC",
|
"productName": "AeroMC",
|
||||||
"version": "1.0.0-beta",
|
"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",
|
||||||
"author": "M_Viper",
|
"author": "M_Viper",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
|
"prebuild": "node scripts/write-build-config.js",
|
||||||
|
"prebuild:portable": "node scripts/write-build-config.js",
|
||||||
"build": "electron-builder --win nsis --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Setup.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"",
|
"build": "electron-builder --win nsis --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Setup.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"",
|
||||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
||||||
},
|
},
|
||||||
@@ -39,7 +41,9 @@
|
|||||||
"electron-builder": "^24.0.0"
|
"electron-builder": "^24.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"skinview3d": "^3.4.2",
|
"skinview3d": "^3.4.2",
|
||||||
"undici": "^6.28.0"
|
"undici": "^6.28.0",
|
||||||
|
"unzipper": "^0.12.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
scripts/write-build-config.js
Normal file
43
scripts/write-build-config.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* write-build-config.js – Vorgaben aus der Umgebung ins Paket schreiben
|
||||||
|
* ---------------------------------------------------------------------
|
||||||
|
* Läuft automatisch vor jedem Build und erzeugt src/build-config.json.
|
||||||
|
*
|
||||||
|
* Damit landen Adresse und Token der CurseForge-Weiterleitung im fertigen
|
||||||
|
* Paket, ohne je im Quelltext oder in einem Repository zu stehen. Gesetzt
|
||||||
|
* werden sie beim Bauen als Umgebungsvariablen:
|
||||||
|
*
|
||||||
|
* PowerShell:
|
||||||
|
* $env:AEROMC_CF_PROXY_URL = "https://cf.example.de"
|
||||||
|
* $env:AEROMC_CF_PROXY_TOKEN = "..."
|
||||||
|
* npm run build
|
||||||
|
*
|
||||||
|
* bash:
|
||||||
|
* AEROMC_CF_PROXY_URL=https://cf.example.de AEROMC_CF_PROXY_TOKEN=... npm run build
|
||||||
|
*
|
||||||
|
* Ohne gesetzte Variablen wird eine leere Vorgabe geschrieben – der Launcher
|
||||||
|
* verhält sich dann wie bisher und jeder Nutzer trägt selbst etwas ein.
|
||||||
|
*
|
||||||
|
* Zur Ehrlichkeit: Ein Token im ausgelieferten Paket ist auslesbar. Es hält
|
||||||
|
* Gelegenheitsnutzer ab, keine entschlossenen. Der API-Schlüssel selbst
|
||||||
|
* bleibt davon unberührt – der liegt ausschließlich auf dem Server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ziel = path.join(__dirname, '..', 'src', 'build-config.json');
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
curseforgeProxyUrl: String(process.env.AEROMC_CF_PROXY_URL || '').trim().replace(/\/+$/, ''),
|
||||||
|
curseforgeProxyToken: String(process.env.AEROMC_CF_PROXY_TOKEN || '').trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(ziel, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||||
|
|
||||||
|
// Den Token selbst nie ausgeben – Build-Protokolle landen schnell woanders.
|
||||||
|
const wo = config.curseforgeProxyUrl || '(keine)';
|
||||||
|
const tok = config.curseforgeProxyToken ? 'gesetzt (' + config.curseforgeProxyToken.length + ' Zeichen)' : 'keins';
|
||||||
|
console.log(`build-config.json geschrieben – Weiterleitung: ${wo}, Token: ${tok}`);
|
||||||
40
server/curseforge-proxy/.env.example
Normal file
40
server/curseforge-proxy/.env.example
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Vorlage. Kopieren nach .env und ausfüllen.
|
||||||
|
# .env gehört NICHT in ein Repository (steht bereits in der .gitignore).
|
||||||
|
|
||||||
|
# Pflicht: dein CurseForge-API-Key von console.curseforge.com
|
||||||
|
CF_API_KEY=hier-deinen-schluessel-eintragen
|
||||||
|
|
||||||
|
# Port und Adresse, auf denen der Dienst lauscht.
|
||||||
|
# Steht ein Reverse-Proxy davor, reicht 127.0.0.1 als HOST.
|
||||||
|
PORT=8420
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Gemeinsames Geheimnis zwischen Launcher und Server. Hält Scanner und
|
||||||
|
# Zufallsfunde zuverlässig ab – deine Domain steht durch das TLS-Zertifikat
|
||||||
|
# öffentlich in den Certificate-Transparency-Listen und wird gefunden.
|
||||||
|
# Gegen jemanden, der gezielt die .exe auspackt, hilft es nicht.
|
||||||
|
#
|
||||||
|
# Mehrere Token durch Komma getrennt. Das brauchst du beim Wechseln:
|
||||||
|
# altes und neues eine Weile parallel eintragen, damit ältere Installationen
|
||||||
|
# weiterlaufen, bis alle das Update haben – danach das alte streichen.
|
||||||
|
# CF_PROXY_TOKEN=neues-token,altes-token
|
||||||
|
#
|
||||||
|
# Leer lassen = keine Tokenprüfung.
|
||||||
|
CF_PROXY_TOKEN=
|
||||||
|
|
||||||
|
# Anfragen je Minute und Absender. 0 = keine Begrenzung.
|
||||||
|
RATE_LIMIT_PER_MIN=60
|
||||||
|
|
||||||
|
# Obergrenze für durchgereichte Dateien in MB. 0 = keine Grenze.
|
||||||
|
MAX_DOWNLOAD_MB=512
|
||||||
|
|
||||||
|
# Anzahl der Reverse-Proxy-Stufen davor:
|
||||||
|
# 0 = keine, der Dienst ist direkt erreichbar
|
||||||
|
# 1 = ein Proxy, z. B. Nginx Proxy Manager oder Caddy
|
||||||
|
# 2 = zwei, z. B. Cloudflare vor dem Nginx Proxy Manager
|
||||||
|
#
|
||||||
|
# Die Zahl zählt, nicht nur "an oder aus": nginx hängt die echte Adresse an
|
||||||
|
# X-Forwarded-For an, statt sie zu ersetzen. Gezählt wird deshalb von rechts.
|
||||||
|
# Zu hoch angesetzt, könnte ein Aufrufer eine Adresse erfinden und die
|
||||||
|
# Ratenbegrenzung umgehen – im Zweifel lieber 0.
|
||||||
|
TRUST_PROXY=0
|
||||||
19
server/curseforge-proxy/Dockerfile
Normal file
19
server/curseforge-proxy/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# keine Abhängigkeiten – das Programm nutzt nur Node-Bordmittel
|
||||||
|
WORKDIR /app
|
||||||
|
COPY index.js ./
|
||||||
|
|
||||||
|
ENV PORT=8420 \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# nicht als root laufen
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 8420
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8420)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "index.js"]
|
||||||
160
server/curseforge-proxy/README.md
Normal file
160
server/curseforge-proxy/README.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# AeroMC – CurseForge-Weiterleitung
|
||||||
|
|
||||||
|
Kleiner Dienst, der den CurseForge-API-Schlüssel hält und die Anfragen
|
||||||
|
stellvertretend für den Launcher stellt.
|
||||||
|
|
||||||
|
## Wozu das Ganze
|
||||||
|
|
||||||
|
CurseForge erlaubt es nicht, den API-Schlüssel weiterzugeben. In einer
|
||||||
|
verteilten `.exe` wäre er für jeden auslesbar – das Entpacken einer
|
||||||
|
Electron-Anwendung dauert keine Minute. Deshalb bleibt der Schlüssel hier
|
||||||
|
auf dem Server, und der Launcher kennt nur die Adresse dieses Dienstes.
|
||||||
|
|
||||||
|
## Wege
|
||||||
|
|
||||||
|
| Weg | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GET /health` | Lebenszeichen, verrät nichts über den Schlüssel |
|
||||||
|
| `POST /v1/mods/files` | Dateiliste auflösen, Rumpf `{ "fileIds": [...] }`, höchstens 100 je Anfrage |
|
||||||
|
| `GET /v1/download?url=…` | Mod-Datei durchreichen; das CurseForge-CDN verlangt seit Juli 2026 ebenfalls einen Schlüssel |
|
||||||
|
|
||||||
|
## Einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# CF_API_KEY eintragen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ohne Docker
|
||||||
|
|
||||||
|
Braucht nur Node 18 oder neuer, keine Pakete.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Dauerhaft über systemd: `aeromc-cf-proxy.service` nach
|
||||||
|
`/etc/systemd/system/` kopieren, Pfade und Benutzer darin anpassen, dann
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now aeromc-cf-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mit pm2
|
||||||
|
|
||||||
|
`ecosystem.config.js` liegt bei und liest die `.env` selbst ein – pm2 tut das
|
||||||
|
von sich aus nämlich nicht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
pm2 startup # einmalig, für den Start nach einem Neustart
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach jeder Änderung an der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart aeromc-cf-proxy --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--update-env` behält pm2 die alten Werte, und du suchst lange nach dem
|
||||||
|
Grund, warum der neue Schlüssel nicht greift.
|
||||||
|
|
||||||
|
Nachsehen, ob er läuft:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 logs aeromc-cf-proxy --lines 20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nicht auf Cluster-Betrieb umstellen.** Die Konfiguration steht bewusst auf
|
||||||
|
`exec_mode: 'fork'` mit einem Prozess. Die Ratenbegrenzung zählt im
|
||||||
|
Arbeitsspeicher – bei vier Arbeitern hätte jeder seine eigene Zählung, und
|
||||||
|
die Grenze wäre in Wahrheit viermal so hoch wie eingestellt.
|
||||||
|
|
||||||
|
### Mit Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Launcher einstellen
|
||||||
|
|
||||||
|
In den Launcher-Einstellungen unter **Netzwerk → CurseForge** die Adresse
|
||||||
|
eintragen, zum Beispiel `https://cf.example.de`. Das Feld „Eigener API-Key"
|
||||||
|
bleibt leer – wer dort einen Schlüssel einträgt, geht direkt zu CurseForge
|
||||||
|
und nutzt diesen Dienst nicht.
|
||||||
|
|
||||||
|
Soll die Adresse für alle Nutzer voreingestellt sein, trage sie in
|
||||||
|
`src/services.js` bei `STANDARD_WEITERLEITUNG` ein. Die Adresse ist kein
|
||||||
|
Geheimnis, nur der Schlüssel ist eines.
|
||||||
|
|
||||||
|
## Absicherung
|
||||||
|
|
||||||
|
Der Dienst steht offen im Netz. Eingebaut ist:
|
||||||
|
|
||||||
|
- **Host-Liste** – durchgereicht wird nur an `edge.forgecdn.net`,
|
||||||
|
`mediafilez.forgecdn.net` und `media.forgecdn.net`. Ohne diese Prüfung
|
||||||
|
könnte jemand den Dienst Adressen in deinem Heimnetz abrufen lassen und
|
||||||
|
die Antwort mitlesen.
|
||||||
|
- **Umleitungen** werden selbst verfolgt und bei jedem Sprung erneut gegen
|
||||||
|
die Host-Liste geprüft, höchstens vier Sprünge.
|
||||||
|
- **Ratenbegrenzung** je Absender, einstellbar über `RATE_LIMIT_PER_MIN`.
|
||||||
|
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`.
|
||||||
|
- **Optionales Token** über `CF_PROXY_TOKEN`.
|
||||||
|
|
||||||
|
Zum Token eine ehrliche Einordnung: Es steckt dann auch im Launcher und ist
|
||||||
|
dort auslesbar. Es hält Gelegenheitsnutzer ab, keine entschlossenen. Die
|
||||||
|
Ratenbegrenzung ist der wirksamere Schutz für dein Kontingent.
|
||||||
|
|
||||||
|
### Reverse-Proxy: `TRUST_PROXY` richtig setzen
|
||||||
|
|
||||||
|
`TRUST_PROXY` ist keine Ja/Nein-Angabe, sondern die **Anzahl der Stufen** vor
|
||||||
|
dem Dienst: `0` ohne Proxy, `1` mit Nginx Proxy Manager, `2` mit Cloudflare
|
||||||
|
davor.
|
||||||
|
|
||||||
|
Der Grund: nginx **ergänzt** `X-Forwarded-For`, statt es zu ersetzen. Schickt
|
||||||
|
ein Aufrufer selbst `X-Forwarded-For: 1.2.3.4`, steht hinterher
|
||||||
|
`1.2.3.4, <echte-IP>` darin. Deshalb wird von rechts gezählt – die eigenen
|
||||||
|
Proxy-Stufen sind die einzigen Einträge, die niemand von außen setzen kann.
|
||||||
|
|
||||||
|
Zu hoch angesetzt, greift der Dienst auf einen Eintrag zu, den der Aufrufer
|
||||||
|
selbst erfunden hat: Dann kann sich jeder mit jeder Anfrage eine neue Adresse
|
||||||
|
ausdenken und die Ratenbegrenzung ins Leere laufen lassen. Im Zweifel `0`.
|
||||||
|
|
||||||
|
### Nginx Proxy Manager
|
||||||
|
|
||||||
|
Im Proxy Host eintragen:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | Containername oder LAN-Adresse des Dienstes |
|
||||||
|
| Forward Port | `8420` |
|
||||||
|
| Block Common Exploits | an |
|
||||||
|
| Websockets Support | nicht nötig |
|
||||||
|
|
||||||
|
Im Reiter **SSL**: Let's-Encrypt-Zertifikat anfordern, „Force SSL" und
|
||||||
|
HTTP/2 aktivieren. Ohne HTTPS ginge das Token im Klartext über die Leitung.
|
||||||
|
|
||||||
|
Im Reiter **Advanced** empfiehlt sich für die durchgereichten Mod-Dateien:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `proxy_buffering off` schreibt nginx große Dateien erst vollständig in
|
||||||
|
eine temporäre Datei, bevor der Launcher das erste Byte sieht – bei
|
||||||
|
mehreren hundert MB fühlt sich das wie ein Hänger an.
|
||||||
|
|
||||||
|
Läuft der Nginx Proxy Manager selbst in Docker, gehören beide ins gleiche
|
||||||
|
Docker-Netz und der Port muss gar nicht nach außen gebunden werden: In der
|
||||||
|
`docker-compose.yml` dann den `ports`-Block weglassen. Sonst wenigstens auf
|
||||||
|
`127.0.0.1:8420:8420` einschränken, damit der Dienst nicht an der Absicherung
|
||||||
|
des Proxys vorbei erreichbar ist.
|
||||||
|
|
||||||
|
## Was der Dienst nicht tut
|
||||||
|
|
||||||
|
Er speichert nichts, protokolliert keine Adressen der Nutzer und gibt
|
||||||
|
Schlüsselfehler von CurseForge nicht im Wortlaut nach außen weiter.
|
||||||
25
server/curseforge-proxy/aeromc-cf-proxy.service
Normal file
25
server/curseforge-proxy/aeromc-cf-proxy.service
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AeroMC CurseForge-Weiterleitung
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Pfad und Benutzer anpassen
|
||||||
|
WorkingDirectory=/opt/aeromc-cf-proxy
|
||||||
|
EnvironmentFile=/opt/aeromc-cf-proxy/.env
|
||||||
|
ExecStart=/usr/bin/node /opt/aeromc-cf-proxy/index.js
|
||||||
|
User=aeromc
|
||||||
|
Group=aeromc
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Absicherung: der Dienst braucht keinen Schreibzugriff und keine neuen Rechte
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
14
server/curseforge-proxy/docker-compose.yml
Normal file
14
server/curseforge-proxy/docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
aeromc-cf-proxy:
|
||||||
|
build: .
|
||||||
|
container_name: aeromc-cf-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
# nach außen nur binden, wenn kein Reverse-Proxy davorsteht.
|
||||||
|
# Mit Reverse-Proxy besser: "127.0.0.1:8420:8420"
|
||||||
|
- "8420:8420"
|
||||||
|
# der Dienst braucht nichts zu schreiben
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
54
server/curseforge-proxy/ecosystem.config.js
Normal file
54
server/curseforge-proxy/ecosystem.config.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* pm2-Konfiguration für die CurseForge-Weiterleitung.
|
||||||
|
*
|
||||||
|
* pm2 start ecosystem.config.js
|
||||||
|
* pm2 save
|
||||||
|
*
|
||||||
|
* pm2 liest .env-Dateien nicht von selbst, deshalb tut es diese Datei.
|
||||||
|
* So bleibt der Schlüssel in der .env und steht nicht in der Konfiguration,
|
||||||
|
* die man eher mal weitergibt oder eincheckt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// kleiner .env-Leser – Kommentare, Anführungszeichen und Werte mit "=" inklusive
|
||||||
|
function leseEnv(datei) {
|
||||||
|
const werte = {};
|
||||||
|
if (!fs.existsSync(datei)) return werte;
|
||||||
|
for (const zeile of fs.readFileSync(datei, 'utf8').split(/\r?\n/)) {
|
||||||
|
const t = zeile.trim();
|
||||||
|
if (!t || t.startsWith('#')) continue;
|
||||||
|
const i = t.indexOf('=');
|
||||||
|
if (i < 1) continue;
|
||||||
|
const name = t.slice(0, i).trim();
|
||||||
|
let wert = t.slice(i + 1).trim();
|
||||||
|
const q = wert[0];
|
||||||
|
if ((q === '"' || q === "'") && wert.endsWith(q) && wert.length > 1) wert = wert.slice(1, -1);
|
||||||
|
werte[name] = wert;
|
||||||
|
}
|
||||||
|
return werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'aeromc-cf-proxy',
|
||||||
|
script: 'index.js',
|
||||||
|
cwd: __dirname,
|
||||||
|
|
||||||
|
// Fork-Betrieb mit genau einem Prozess. Im Cluster-Betrieb hätte jeder
|
||||||
|
// Arbeiter seine eigene Zählung – die Ratenbegrenzung wäre dann bei
|
||||||
|
// vier Prozessen viermal so hoch wie eingestellt.
|
||||||
|
exec_mode: 'fork',
|
||||||
|
instances: 1,
|
||||||
|
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
restart_delay: 3000,
|
||||||
|
max_memory_restart: '256M',
|
||||||
|
|
||||||
|
env: leseEnv(path.join(__dirname, '.env')),
|
||||||
|
}],
|
||||||
|
};
|
||||||
344
server/curseforge-proxy/index.js
Normal file
344
server/curseforge-proxy/index.js
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* AeroMC – CurseForge-Weiterleitung
|
||||||
|
* ---------------------------------
|
||||||
|
* Hält den CurseForge-API-Schlüssel und stellt die Anfragen stellvertretend
|
||||||
|
* für den Launcher. So bleibt der Schlüssel auf dem Server: in der verteilten
|
||||||
|
* .exe wäre er für jeden auslesbar, und CurseForge untersagt die Weitergabe
|
||||||
|
* an Dritte ausdrücklich.
|
||||||
|
*
|
||||||
|
* Wege:
|
||||||
|
* GET /health – Lebenszeichen, ohne Schlüsselangaben
|
||||||
|
* POST /v1/mods/files – Dateiliste auflösen (Rumpf: { fileIds: [...] })
|
||||||
|
* GET /v1/download?url=... – Mod-Datei durchreichen (CDN verlangt seit
|
||||||
|
* Juli 2026 ebenfalls einen Schlüssel)
|
||||||
|
*
|
||||||
|
* Einstellungen über Umgebungsvariablen, siehe .env.example.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const { Readable } = require('stream');
|
||||||
|
|
||||||
|
const KEY = (process.env.CF_API_KEY || '').trim();
|
||||||
|
const PORT = Number(process.env.PORT || 8420);
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
/*
|
||||||
|
* Ein oder mehrere gültige Token, durch Komma getrennt.
|
||||||
|
*
|
||||||
|
* Mehrere deshalb, weil das Token in ausgelieferten Paketen steckt: Beim
|
||||||
|
* Wechsel trägst du eine Zeit lang altes und neues ein, damit ältere
|
||||||
|
* Installationen weiterlaufen, bis alle das Update haben. Danach das alte
|
||||||
|
* streichen.
|
||||||
|
*/
|
||||||
|
const TOKENS = new Set(
|
||||||
|
String(process.env.CF_PROXY_TOKEN || '')
|
||||||
|
.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
|
||||||
|
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512);
|
||||||
|
// Anzahl der Reverse-Proxies davor. 0 = keiner, 1 = z. B. Nginx Proxy Manager,
|
||||||
|
// 2 = z. B. Cloudflare vor dem Proxy Manager.
|
||||||
|
const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
|
||||||
|
|
||||||
|
const CF_API = 'https://api.curseforge.com/v1';
|
||||||
|
|
||||||
|
// Ohne User-Agent weisen Dienste hinter Cloudflare Anfragen gerne mit 403 ab.
|
||||||
|
const UA = 'AeroMC-Launcher-Proxy/1.0 (+https://git.viper.ipv64.net)';
|
||||||
|
|
||||||
|
function cfKopfzeilen(weitere) {
|
||||||
|
return Object.assign({ 'x-api-key': KEY, Accept: 'application/json', 'User-Agent': UA }, weitere || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur diese Hosts dürfen durchgereicht werden. Ohne diese Liste wäre der
|
||||||
|
// Server ein offenes Tor in dein Netz – jemand könnte ihn Adressen im
|
||||||
|
// Heimnetz abrufen lassen und die Antwort mitlesen.
|
||||||
|
const ERLAUBTE_HOSTS = new Set([
|
||||||
|
'edge.forgecdn.net',
|
||||||
|
'mediafilez.forgecdn.net',
|
||||||
|
'media.forgecdn.net',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!KEY) {
|
||||||
|
console.error('CF_API_KEY fehlt. Bitte setzen (siehe .env.example) und neu starten.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ratenbegrenzung: einfaches Zeitfenster je Absender
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const zaehler = new Map(); // ip -> { anzahl, bis }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ermittelt den Absender – die Grundlage der Ratenbegrenzung.
|
||||||
|
*
|
||||||
|
* nginx & Co. hängen die echte Adresse hinten an X-Forwarded-For an, statt
|
||||||
|
* den Kopfeintrag zu ersetzen. Alles davor kann der Aufrufer frei erfinden.
|
||||||
|
* Vertrauenswürdig ist deshalb nur der Eintrag, den die letzte eigene
|
||||||
|
* Proxy-Stufe geschrieben hat: von rechts gezählt.
|
||||||
|
*
|
||||||
|
* Aufrufer schickt "1.2.3.4", ein Proxy davor
|
||||||
|
* -> "1.2.3.4, <echte-IP>" -> mit TRUST_PROXY=1 zählt <echte-IP>
|
||||||
|
*/
|
||||||
|
function absender(req) {
|
||||||
|
if (PROXY_STUFEN > 0) {
|
||||||
|
const kette = String(req.headers['x-forwarded-for'] || '')
|
||||||
|
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const i = kette.length - PROXY_STUFEN;
|
||||||
|
if (i >= 0 && kette[i]) return kette[i];
|
||||||
|
// Kette kürzer als erwartet -> nicht dem Aufrufer glauben
|
||||||
|
}
|
||||||
|
return req.socket.remoteAddress || 'unbekannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function darfNoch(ip) {
|
||||||
|
if (LIMIT_PRO_MIN <= 0) return true;
|
||||||
|
const jetzt = Date.now();
|
||||||
|
const e = zaehler.get(ip);
|
||||||
|
if (!e || jetzt > e.bis) { zaehler.set(ip, { anzahl: 1, bis: jetzt + 60000 }); return true; }
|
||||||
|
e.anzahl++;
|
||||||
|
return e.anzahl <= LIMIT_PRO_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// alte Einträge regelmäßig wegräumen, sonst wächst die Liste unbegrenzt
|
||||||
|
setInterval(() => {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
for (const [ip, e] of zaehler) if (jetzt > e.bis) zaehler.delete(ip);
|
||||||
|
}, 60000).unref();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hilfen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sendeJson(res, code, daten) {
|
||||||
|
const koerper = JSON.stringify(daten);
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(koerper) });
|
||||||
|
res.end(koerper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenOk(req) {
|
||||||
|
if (!TOKENS.size) return true;
|
||||||
|
return TOKENS.has(String(req.headers['x-aeromc-token'] || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function leseKoerper(req, maxBytes = 1_000_000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let daten = '';
|
||||||
|
let laenge = 0;
|
||||||
|
req.on('data', (d) => {
|
||||||
|
laenge += d.length;
|
||||||
|
if (laenge > maxBytes) { reject(new Error('Anfrage zu groß')); req.destroy(); return; }
|
||||||
|
daten += d;
|
||||||
|
});
|
||||||
|
req.on('end', () => resolve(daten));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// prüft die Zieladresse einer Download-Anfrage
|
||||||
|
function pruefeDownloadZiel(roh) {
|
||||||
|
let u;
|
||||||
|
try { u = new URL(roh); } catch { return null; }
|
||||||
|
if (u.protocol !== 'https:') return null;
|
||||||
|
if (!ERLAUBTE_HOSTS.has(u.hostname)) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wege
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function dateilisteAufloesen(req, res) {
|
||||||
|
let rumpf;
|
||||||
|
try { rumpf = JSON.parse(await leseKoerper(req)); }
|
||||||
|
catch { return sendeJson(res, 400, { error: 'Ungültiger Rumpf.' }); }
|
||||||
|
|
||||||
|
const ids = Array.isArray(rumpf && rumpf.fileIds) ? rumpf.fileIds.map(Number).filter(Boolean) : [];
|
||||||
|
if (!ids.length) return sendeJson(res, 400, { error: 'fileIds fehlen.' });
|
||||||
|
if (ids.length > 100) return sendeJson(res, 400, { error: 'Höchstens 100 fileIds je Anfrage.' });
|
||||||
|
|
||||||
|
const antwort = await fetch(CF_API + '/mods/files', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: cfKopfzeilen({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ fileIds: ids }),
|
||||||
|
});
|
||||||
|
if (!antwort.ok) {
|
||||||
|
// Schlüsselprobleme nicht im Wortlaut weiterreichen
|
||||||
|
const code = antwort.status === 401 || antwort.status === 403 ? 502 : antwort.status;
|
||||||
|
return sendeJson(res, code, { error: 'CurseForge antwortet mit HTTP ' + antwort.status });
|
||||||
|
}
|
||||||
|
sendeJson(res, 200, await antwort.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dateiDurchreichen(req, res, url) {
|
||||||
|
const ziel = pruefeDownloadZiel(url.searchParams.get('url') || '');
|
||||||
|
if (!ziel) return sendeJson(res, 400, { error: 'Adresse fehlt oder zeigt nicht auf das CurseForge-CDN.' });
|
||||||
|
|
||||||
|
// Weiterleitungen selbst verfolgen und jedes Mal den Host neu prüfen
|
||||||
|
let aktuell = ziel;
|
||||||
|
let antwort = null;
|
||||||
|
for (let sprung = 0; sprung < 4; sprung++) {
|
||||||
|
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual' });
|
||||||
|
const weiter = antwort.headers.get('location');
|
||||||
|
if (antwort.status >= 300 && antwort.status < 400 && weiter) {
|
||||||
|
const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString());
|
||||||
|
if (!naechste) return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' });
|
||||||
|
aktuell = naechste;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!antwort || !antwort.ok) {
|
||||||
|
return sendeJson(res, antwort ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const laenge = Number(antwort.headers.get('content-length')) || 0;
|
||||||
|
if (MAX_MB > 0 && laenge > MAX_MB * 1024 * 1024) {
|
||||||
|
return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream',
|
||||||
|
...(laenge ? { 'Content-Length': String(laenge) } : {}),
|
||||||
|
});
|
||||||
|
Readable.fromWeb(antwort.body).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
let url;
|
||||||
|
try { url = new URL(req.url, 'http://localhost'); }
|
||||||
|
catch { return sendeJson(res, 400, { error: 'Ungültige Anfrage.' }); }
|
||||||
|
|
||||||
|
if (url.pathname === '/health') {
|
||||||
|
return sendeJson(res, 200, { ok: true, dienst: 'aeromc-cf-proxy' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenOk(req)) return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hilfe beim Einstellen von TRUST_PROXY: zeigt, was der Dienst von DIESER
|
||||||
|
* Anfrage sieht. Steht bewusst hinter der Token-Prüfung, damit Scanner
|
||||||
|
* nichts über den Aufbau erfahren. Aufruf von außen:
|
||||||
|
*
|
||||||
|
* curl -s -H 'x-aeromc-token: DEIN-TOKEN' https://deine-adresse/whoami
|
||||||
|
*
|
||||||
|
* erkannt == deine echte öffentliche IP -> TRUST_PROXY stimmt
|
||||||
|
* erkannt == eine interne Adresse -> zu niedrig
|
||||||
|
* erkannt == eine erfundene Adresse -> zu hoch
|
||||||
|
*
|
||||||
|
* Vor der Ratenbegrenzung, damit die Diagnose auch dann noch antwortet,
|
||||||
|
* wenn man sich gerade selbst ausgesperrt hat.
|
||||||
|
*/
|
||||||
|
if (url.pathname === '/whoami') {
|
||||||
|
const kette = String(req.headers['x-forwarded-for'] || '')
|
||||||
|
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
return sendeJson(res, 200, {
|
||||||
|
erkannt: absender(req),
|
||||||
|
proxyStufen: PROXY_STUFEN,
|
||||||
|
kette,
|
||||||
|
verbindungVon: req.socket.remoteAddress || null,
|
||||||
|
hinweis: 'Stimmt "erkannt" mit deiner echten öffentlichen IP überein, ist TRUST_PROXY richtig gesetzt.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Selbsttest: prüft den API-Schlüssel, den DIESER Prozess wirklich geladen
|
||||||
|
* hat, direkt gegen CurseForge. Beantwortet die zwei Fragen, die man von
|
||||||
|
* außen sonst nicht auseinanderhalten kann:
|
||||||
|
* - hat der Prozess überhaupt den neuen Schlüssel?
|
||||||
|
* - akzeptiert CurseForge ihn?
|
||||||
|
* Gibt nur Länge und die ersten Zeichen aus (bei CurseForge immer "$2a$10$"),
|
||||||
|
* niemals den Schlüssel selbst.
|
||||||
|
*/
|
||||||
|
if (url.pathname === '/selftest') {
|
||||||
|
const roh = process.env.CF_API_KEY || '';
|
||||||
|
const befund = {
|
||||||
|
schluessel: {
|
||||||
|
laenge: KEY.length,
|
||||||
|
beginntMit: KEY.slice(0, 7),
|
||||||
|
randzeichenEntfernt: roh !== KEY,
|
||||||
|
sahAusWieCurseForge: /^\$2[aby]\$\d\d\$/.test(KEY),
|
||||||
|
},
|
||||||
|
curseforge: null,
|
||||||
|
urteil: '',
|
||||||
|
};
|
||||||
|
/*
|
||||||
|
* Mehrere Zugänge nacheinander prüfen. Erst der Vergleich zeigt, ob der
|
||||||
|
* Schlüssel grundsätzlich abgelehnt wird oder nur für bestimmte Bereiche
|
||||||
|
* fehlt – ein einzelner Aufruf kann das nicht unterscheiden.
|
||||||
|
*/
|
||||||
|
const PROBEN = [
|
||||||
|
{ name: 'games – Liste aller Spiele', method: 'GET', pfad: '/games' },
|
||||||
|
{ name: 'games/432 – Minecraft', method: 'GET', pfad: '/games/432' },
|
||||||
|
{ name: 'categories – Minecraft-Kategorien', method: 'GET', pfad: '/categories?gameId=432' },
|
||||||
|
{ name: 'mods/search – Modsuche', method: 'GET', pfad: '/mods/search?gameId=432&searchFilter=jei' },
|
||||||
|
{ name: 'mods/files – was der Import braucht', method: 'POST', pfad: '/mods/files', body: { fileIds: [4959679] } },
|
||||||
|
];
|
||||||
|
|
||||||
|
befund.proben = [];
|
||||||
|
for (const p of PROBEN) {
|
||||||
|
try {
|
||||||
|
const a = await fetch(CF_API + p.pfad, {
|
||||||
|
method: p.method,
|
||||||
|
headers: cfKopfzeilen(p.body ? { 'Content-Type': 'application/json' } : undefined),
|
||||||
|
body: p.body ? JSON.stringify(p.body) : undefined,
|
||||||
|
});
|
||||||
|
let text = '';
|
||||||
|
try { text = (await a.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
|
||||||
|
befund.proben.push({
|
||||||
|
name: p.name,
|
||||||
|
status: a.status,
|
||||||
|
server: a.headers.get('server') || null,
|
||||||
|
antwortAnfang: a.ok ? '(ok)' : text,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
befund.proben.push({ name: p.name, fehler: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stati = befund.proben.map((p) => p.status);
|
||||||
|
const alleAbgelehnt = stati.every((s) => s === 401 || s === 403);
|
||||||
|
const alleGut = stati.every((s) => s >= 200 && s < 300);
|
||||||
|
const nurDateien = befund.proben.find((p) => p.name.startsWith('mods/files'));
|
||||||
|
|
||||||
|
if (alleGut) befund.urteil = 'Alle Zugänge offen – der Schlüssel ist in Ordnung.';
|
||||||
|
else if (alleAbgelehnt) {
|
||||||
|
befund.urteil = 'Kein einziger Zugang offen: Dieser Schlüssel gilt bei CurseForge nicht. '
|
||||||
|
+ 'Entweder wurde er nach dem Kopieren erneut erzeugt („Regenerate" macht den alten sofort ungültig), '
|
||||||
|
+ 'oder das Organisationskonto ist nicht freigeschaltet. Kein Fehler im Launcher oder auf diesem Server.';
|
||||||
|
} else if (nurDateien && (nurDateien.status === 401 || nurDateien.status === 403)) {
|
||||||
|
befund.urteil = 'Der Schlüssel gilt grundsätzlich, aber nicht für die Dateiabfrage. '
|
||||||
|
+ 'Das deutet auf einen Schlüssel, der nur für ein einzelnes Spiel gilt – für Minecraft-Modpacks '
|
||||||
|
+ 'wird der Organisationsschlüssel gebraucht.';
|
||||||
|
} else befund.urteil = 'Gemischtes Bild – siehe die einzelnen Proben.';
|
||||||
|
|
||||||
|
// Kurzfassung für den schnellen Blick
|
||||||
|
befund.kurz = befund.proben.map((p) => `${p.name}: ${p.status ?? p.fehler}`);
|
||||||
|
return sendeJson(res, 200, befund);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!darfNoch(absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method === 'POST' && url.pathname === '/v1/mods/files') return await dateilisteAufloesen(req, res);
|
||||||
|
if (req.method === 'GET' && url.pathname === '/v1/download') return await dateiDurchreichen(req, res, url);
|
||||||
|
sendeJson(res, 404, { error: 'Unbekannter Weg.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Fehler]', err.message);
|
||||||
|
if (!res.headersSent) sendeJson(res, 500, { error: 'Interner Fehler.' });
|
||||||
|
else res.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`AeroMC CurseForge-Weiterleitung läuft auf ${HOST}:${PORT}`);
|
||||||
|
console.log(` Ratenbegrenzung: ${LIMIT_PRO_MIN > 0 ? LIMIT_PRO_MIN + '/Minute je Absender' : 'aus'}`);
|
||||||
|
// niemals die Token selbst ausgeben – Protokolle wandern schnell woanders hin
|
||||||
|
console.log(` Token verlangt : ${TOKENS.size ? 'ja (' + TOKENS.size + ' gültige)' : 'nein'}`);
|
||||||
|
console.log(` Proxy-Stufen : ${PROXY_STUFEN} (${PROXY_STUFEN ? 'Absender aus X-Forwarded-For' : 'Absender aus der Verbindung'})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { server, pruefeDownloadZiel, darfNoch, absender, ERLAUBTE_HOSTS };
|
||||||
440
src/backup.js
Normal file
440
src/backup.js
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const archiver = require('archiver');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { pipeline } = require('stream/promises');
|
||||||
|
const unzipper = require('unzipper');
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBackupFileName(date = new Date()) {
|
||||||
|
return 'AeroMC-Backup-' +
|
||||||
|
date.getFullYear() + '-' +
|
||||||
|
pad2(date.getMonth() + 1) + '-' +
|
||||||
|
pad2(date.getDate()) + '_' +
|
||||||
|
pad2(date.getHours()) + '-' +
|
||||||
|
pad2(date.getMinutes()) + '-' +
|
||||||
|
pad2(date.getSeconds()) +
|
||||||
|
'.zip';
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBackupDir(documentsDir) {
|
||||||
|
return path.join(documentsDir, 'AeroMC Launcher', 'Backups');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAutoBackupPath(documentsDir, date = new Date()) {
|
||||||
|
const dir = defaultBackupDir(documentsDir);
|
||||||
|
ensureDir(dir);
|
||||||
|
|
||||||
|
const baseName = defaultBackupFileName(date);
|
||||||
|
const ext = path.extname(baseName);
|
||||||
|
const stem = baseName.slice(0, -ext.length);
|
||||||
|
let attempt = 0;
|
||||||
|
let candidate = path.join(dir, baseName);
|
||||||
|
|
||||||
|
while (fs.existsSync(candidate)) {
|
||||||
|
attempt += 1;
|
||||||
|
candidate = path.join(dir, `${stem}-${attempt}${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listGlobalBackups(documentsDir) {
|
||||||
|
const dir = defaultBackupDir(documentsDir);
|
||||||
|
ensureDir(dir);
|
||||||
|
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && /\.zip$/i.test(entry.name) && !/\.partial\.zip$/i.test(entry.name))
|
||||||
|
.map((entry) => {
|
||||||
|
const filePath = path.join(dir, entry.name);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: filePath,
|
||||||
|
size: stat.size,
|
||||||
|
modifiedAt: stat.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const timeDiff = new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
|
||||||
|
return timeDiff || a.name.localeCompare(b.name, 'de');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function countInstanceDirs(instancesDir) {
|
||||||
|
if (!fs.existsSync(instancesDir)) return 0;
|
||||||
|
return fs.readdirSync(instancesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(file, data) {
|
||||||
|
ensureDir(path.dirname(file));
|
||||||
|
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitProgress(cb, mode, percent, detail) {
|
||||||
|
if (!cb) return;
|
||||||
|
cb({ mode, percent: Math.max(0, Math.min(100, Math.round(percent))), detail: detail || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeZipEntryPath(entryPath) {
|
||||||
|
const parts = String(entryPath || '')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!parts.length) return '';
|
||||||
|
if (parts.some((part) => part === '.' || part === '..')) {
|
||||||
|
throw new Error('Das Backup-Archiv enthält ungültige Pfade.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractBackupArchive(sourcePath, destinationDir, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const onProgress = opts.onProgress;
|
||||||
|
const mode = opts.mode || 'restore';
|
||||||
|
const startPercent = opts.startPercent ?? 5;
|
||||||
|
const endPercent = opts.endPercent ?? 18;
|
||||||
|
const detail = opts.detail || 'Entpacke Backup';
|
||||||
|
const directory = await unzipper.Open.file(sourcePath);
|
||||||
|
const files = directory.files || [];
|
||||||
|
const fileEntries = files.filter((entry) => entry.type !== 'Directory');
|
||||||
|
const totalBytes = fileEntries.reduce((sum, entry) => sum + Number(entry.uncompressedSize || 0), 0);
|
||||||
|
let processedBytes = 0;
|
||||||
|
let processedEntries = 0;
|
||||||
|
|
||||||
|
ensureDir(destinationDir);
|
||||||
|
if (!files.length) {
|
||||||
|
emitProgress(onProgress, mode, endPercent, detail + ' abgeschlossen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of files) {
|
||||||
|
const relativePath = normalizeZipEntryPath(entry.path);
|
||||||
|
if (!relativePath) continue;
|
||||||
|
|
||||||
|
const targetPath = path.join(destinationDir, relativePath);
|
||||||
|
if (entry.type === 'Directory') {
|
||||||
|
ensureDir(targetPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(path.dirname(targetPath));
|
||||||
|
await pipeline(entry.stream(), fs.createWriteStream(targetPath));
|
||||||
|
|
||||||
|
processedBytes += Number(entry.uncompressedSize || 0);
|
||||||
|
processedEntries += 1;
|
||||||
|
const ratio = totalBytes > 0
|
||||||
|
? Math.max(0, Math.min(1, processedBytes / totalBytes))
|
||||||
|
: Math.max(0, Math.min(1, processedEntries / Math.max(1, fileEntries.length)));
|
||||||
|
const percent = startPercent + ((endPercent - startPercent) * ratio);
|
||||||
|
emitProgress(onProgress, mode, percent, detail + ' ...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackupArchive(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const instancesDir = opts.instancesDir;
|
||||||
|
const settingsFile = opts.settingsFile;
|
||||||
|
const includeSettings = opts.includeSettings !== false;
|
||||||
|
const backupInfo = opts.backupInfo || {};
|
||||||
|
const destinationPath = opts.destinationPath;
|
||||||
|
const onProgress = opts.onProgress;
|
||||||
|
const mode = opts.mode || 'create';
|
||||||
|
const startPercent = opts.startPercent ?? 15;
|
||||||
|
const endPercent = opts.endPercent ?? 98;
|
||||||
|
const detail = opts.detail || 'Packe ZIP-Archiv';
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ensureDir(path.dirname(destinationPath));
|
||||||
|
|
||||||
|
const output = fs.createWriteStream(destinationPath);
|
||||||
|
const archive = archiver('zip', { store: true });
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const fail = (err) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
try { archive.destroy(); } catch { /* ignore */ }
|
||||||
|
try { output.destroy(); } catch { /* ignore */ }
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
output.on('close', () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
resolve({ bytes: archive.pointer() });
|
||||||
|
});
|
||||||
|
output.on('error', fail);
|
||||||
|
archive.on('error', fail);
|
||||||
|
archive.on('warning', (err) => {
|
||||||
|
if (err && err.code === 'ENOENT') return;
|
||||||
|
fail(err);
|
||||||
|
});
|
||||||
|
archive.on('progress', (progress) => {
|
||||||
|
const totalBytes = progress && progress.fs ? progress.fs.totalBytes : 0;
|
||||||
|
const processedBytes = progress && progress.fs ? progress.fs.processedBytes : 0;
|
||||||
|
if (!totalBytes) return;
|
||||||
|
|
||||||
|
const ratio = Math.max(0, Math.min(1, processedBytes / totalBytes));
|
||||||
|
const percent = startPercent + ((endPercent - startPercent) * ratio);
|
||||||
|
emitProgress(onProgress, mode, percent, detail + ' ...');
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(output);
|
||||||
|
if (fs.existsSync(instancesDir)) archive.directory(instancesDir, 'instances');
|
||||||
|
else archive.append('', { name: 'instances/.keep' });
|
||||||
|
|
||||||
|
if (includeSettings && settingsFile && fs.existsSync(settingsFile)) {
|
||||||
|
archive.file(settingsFile, { name: 'settings.json' });
|
||||||
|
}
|
||||||
|
|
||||||
|
archive.append(`${JSON.stringify(backupInfo, null, 2)}\n`, { name: 'backup-info.json' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalizeResult = archive.finalize();
|
||||||
|
if (finalizeResult && typeof finalizeResult.catch === 'function') finalizeResult.catch(fail);
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDirContentsTracked(sourceDir, targetDir, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const mode = opts.mode || 'create';
|
||||||
|
const onProgress = opts.onProgress;
|
||||||
|
const startPercent = opts.startPercent ?? 0;
|
||||||
|
const endPercent = opts.endPercent ?? 100;
|
||||||
|
const detailPrefix = opts.detailPrefix || 'Kopiere';
|
||||||
|
|
||||||
|
ensureDir(targetDir);
|
||||||
|
if (!fs.existsSync(sourceDir)) {
|
||||||
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
||||||
|
if (!entries.length) {
|
||||||
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
fs.cpSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name), { recursive: true });
|
||||||
|
const ratio = (index + 1) / entries.length;
|
||||||
|
const percent = startPercent + ((endPercent - startPercent) * ratio);
|
||||||
|
emitProgress(onProgress, mode, percent, `${detailPrefix}: ${entry.name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDirTracked(sourceDir, targetDir, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const mode = opts.mode || 'restore';
|
||||||
|
const onProgress = opts.onProgress;
|
||||||
|
const startPercent = opts.startPercent ?? 0;
|
||||||
|
const endPercent = opts.endPercent ?? 100;
|
||||||
|
const detailPrefix = opts.detailPrefix || 'Verschiebe';
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourceDir)) {
|
||||||
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(path.dirname(targetDir));
|
||||||
|
try {
|
||||||
|
fs.renameSync(sourceDir, targetDir);
|
||||||
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (!err || !['EXDEV', 'EPERM', 'EACCES'].includes(err.code)) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDirContentsTracked(sourceDir, targetDir, opts);
|
||||||
|
try { fs.rmSync(sourceDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExtractedBackupRoot(extractDir) {
|
||||||
|
if (fs.existsSync(path.join(extractDir, 'instances'))) return extractDir;
|
||||||
|
const entries = fs.readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
||||||
|
if (entries.length === 1) {
|
||||||
|
const nested = path.join(extractDir, entries[0].name);
|
||||||
|
if (fs.existsSync(path.join(nested, 'instances'))) return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGlobalBackup(options) {
|
||||||
|
const instancesDir = options && options.instancesDir;
|
||||||
|
const userDataDir = options && options.userDataDir;
|
||||||
|
const destinationPath = options && options.destinationPath;
|
||||||
|
const includeSettings = !options || options.includeSettings !== false;
|
||||||
|
const onProgress = options && options.onProgress;
|
||||||
|
if (!instancesDir || !userDataDir || !destinationPath) throw new Error('Backup-Parameter unvollständig.');
|
||||||
|
|
||||||
|
const settingsFile = path.join(userDataDir, 'settings.json');
|
||||||
|
const workingArchivePath = destinationPath.replace(/\.zip$/i, '') + '.partial.zip';
|
||||||
|
const backupInfo = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
instanceCount: countInstanceDirs(instancesDir),
|
||||||
|
includeSettings,
|
||||||
|
app: 'AeroMC',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
emitProgress(onProgress, 'create', 5, 'Bereite Backup vor');
|
||||||
|
emitProgress(onProgress, 'create', 10, 'Ermittle Backup-Inhalt');
|
||||||
|
|
||||||
|
if (fs.existsSync(destinationPath)) fs.unlinkSync(destinationPath);
|
||||||
|
if (fs.existsSync(workingArchivePath)) fs.unlinkSync(workingArchivePath);
|
||||||
|
emitProgress(onProgress, 'create', 90, 'Packe ZIP-Archiv');
|
||||||
|
await createBackupArchive({
|
||||||
|
instancesDir,
|
||||||
|
settingsFile,
|
||||||
|
includeSettings,
|
||||||
|
backupInfo,
|
||||||
|
destinationPath: workingArchivePath,
|
||||||
|
mode: 'create',
|
||||||
|
onProgress,
|
||||||
|
startPercent: 15,
|
||||||
|
endPercent: 98,
|
||||||
|
detail: 'Packe ZIP-Archiv',
|
||||||
|
});
|
||||||
|
emitProgress(onProgress, 'create', 99, 'Finalisiere Backup');
|
||||||
|
fs.renameSync(workingArchivePath, destinationPath);
|
||||||
|
emitProgress(onProgress, 'create', 100, 'Backup abgeschlossen');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: destinationPath,
|
||||||
|
instanceCount: countInstanceDirs(instancesDir),
|
||||||
|
includedSettings: includeSettings && fs.existsSync(settingsFile),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(workingArchivePath)) fs.rmSync(workingArchivePath, { force: true });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreGlobalBackup(options) {
|
||||||
|
const instancesDir = options && options.instancesDir;
|
||||||
|
const userDataDir = options && options.userDataDir;
|
||||||
|
const sourcePath = options && options.sourcePath;
|
||||||
|
const includeSettings = !!(options && options.includeSettings);
|
||||||
|
const onProgress = options && options.onProgress;
|
||||||
|
const instancesDirSetting = options && Object.prototype.hasOwnProperty.call(options, 'instancesDirSetting')
|
||||||
|
? options.instancesDirSetting
|
||||||
|
: null;
|
||||||
|
if (!instancesDir || !userDataDir || !sourcePath) throw new Error('Restore-Parameter unvollständig.');
|
||||||
|
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-restore-'));
|
||||||
|
const extractDir = path.join(tmp, 'extract');
|
||||||
|
const rollbackDir = path.join(tmp, 'rollback');
|
||||||
|
const rollbackInstances = path.join(rollbackDir, 'instances');
|
||||||
|
const settingsFile = path.join(userDataDir, 'settings.json');
|
||||||
|
const rollbackSettings = path.join(rollbackDir, 'settings.json');
|
||||||
|
let settingsPreviouslyExisted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureDir(extractDir);
|
||||||
|
emitProgress(onProgress, 'restore', 5, 'Entpacke Backup');
|
||||||
|
await extractBackupArchive(sourcePath, extractDir, {
|
||||||
|
mode: 'restore',
|
||||||
|
onProgress,
|
||||||
|
startPercent: 5,
|
||||||
|
endPercent: 18,
|
||||||
|
detail: 'Entpacke Backup',
|
||||||
|
});
|
||||||
|
|
||||||
|
const backupRoot = resolveExtractedBackupRoot(extractDir);
|
||||||
|
if (!backupRoot) throw new Error('Das Archiv enthält kein gültiges AeroMC-Backup.');
|
||||||
|
emitProgress(onProgress, 'restore', 18, 'Backup geprüft');
|
||||||
|
|
||||||
|
const sourceInstances = path.join(backupRoot, 'instances');
|
||||||
|
const sourceSettings = path.join(backupRoot, 'settings.json');
|
||||||
|
ensureDir(rollbackDir);
|
||||||
|
|
||||||
|
emitProgress(onProgress, 'restore', 25, 'Sichere aktuellen Stand');
|
||||||
|
moveDirTracked(instancesDir, rollbackInstances, {
|
||||||
|
mode: 'restore',
|
||||||
|
onProgress,
|
||||||
|
startPercent: 28,
|
||||||
|
endPercent: 40,
|
||||||
|
detailPrefix: 'Sichere aktuelle Instanzen',
|
||||||
|
});
|
||||||
|
settingsPreviouslyExisted = fs.existsSync(settingsFile);
|
||||||
|
if (settingsPreviouslyExisted) fs.copyFileSync(settingsFile, rollbackSettings);
|
||||||
|
|
||||||
|
ensureDir(path.dirname(instancesDir));
|
||||||
|
moveDirTracked(sourceInstances, instancesDir, {
|
||||||
|
mode: 'restore',
|
||||||
|
onProgress,
|
||||||
|
startPercent: 45,
|
||||||
|
endPercent: 78,
|
||||||
|
detailPrefix: 'Stelle Instanzen wieder her',
|
||||||
|
});
|
||||||
|
|
||||||
|
let restoredSettings = false;
|
||||||
|
if (includeSettings && fs.existsSync(sourceSettings)) {
|
||||||
|
const restored = readJson(sourceSettings, {});
|
||||||
|
restored.instancesDir = instancesDirSetting;
|
||||||
|
writeJson(settingsFile, restored);
|
||||||
|
restoredSettings = true;
|
||||||
|
emitProgress(onProgress, 'restore', 90, 'Stelle Einstellungen wieder her');
|
||||||
|
} else {
|
||||||
|
emitProgress(onProgress, 'restore', 90, 'Einstellungen übersprungen');
|
||||||
|
}
|
||||||
|
|
||||||
|
emitProgress(onProgress, 'restore', 100, 'Wiederherstellen abgeschlossen');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: sourcePath,
|
||||||
|
instanceCount: countInstanceDirs(instancesDir),
|
||||||
|
restoredSettings,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(instancesDir)) fs.rmSync(instancesDir, { recursive: true, force: true });
|
||||||
|
if (fs.existsSync(rollbackInstances)) fs.cpSync(rollbackInstances, instancesDir, { recursive: true });
|
||||||
|
else ensureDir(instancesDir);
|
||||||
|
|
||||||
|
if (includeSettings) {
|
||||||
|
if (fs.existsSync(rollbackSettings)) fs.copyFileSync(rollbackSettings, settingsFile);
|
||||||
|
else if (!settingsPreviouslyExisted && fs.existsSync(settingsFile)) fs.rmSync(settingsFile, { force: true });
|
||||||
|
}
|
||||||
|
} catch { /* ignore rollback errors */ }
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createGlobalBackup,
|
||||||
|
restoreGlobalBackup,
|
||||||
|
defaultBackupFileName,
|
||||||
|
defaultBackupDir,
|
||||||
|
listGlobalBackups,
|
||||||
|
resolveAutoBackupPath,
|
||||||
|
};
|
||||||
@@ -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.' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
4
src/build-config.json
Normal file
4
src/build-config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"curseforgeProxyUrl": "",
|
||||||
|
"curseforgeProxyToken": ""
|
||||||
|
}
|
||||||
196
src/cfimport.js
Normal file
196
src/cfimport.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* cfimport.js – Instanzen aus dem CurseForge-Launcher übernehmen
|
||||||
|
* ---------------------------------------------------------------
|
||||||
|
* Unterstützt sowohl den Launcher-Wurzelordner als auch direkt den
|
||||||
|
* Instances-Ordner. Gelesen werden nach Möglichkeit minecraftinstance.json
|
||||||
|
* und die vorhandenen Spieldaten im Profilordner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const META_FILES = ['minecraftinstance.json', 'instance.json'];
|
||||||
|
|
||||||
|
function readJson(file, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function existingMetaFile(dir) {
|
||||||
|
return META_FILES.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaVersion(meta) {
|
||||||
|
return String(
|
||||||
|
(meta && (
|
||||||
|
meta.gameVersion || meta.minecraftVersion || meta.mcVersion ||
|
||||||
|
(meta.installedModpack && meta.installedModpack.gameVersion)
|
||||||
|
)) || ''
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInstancesDir(inputPath) {
|
||||||
|
if (!inputPath || !fs.existsSync(inputPath)) return null;
|
||||||
|
const directNames = new Set(['instances', 'minecraftinstances']);
|
||||||
|
if (directNames.has(path.basename(inputPath).toLowerCase())) return inputPath;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
path.join(inputPath, 'Instances'),
|
||||||
|
path.join(inputPath, 'instances'),
|
||||||
|
path.join(inputPath, 'minecraftInstances'),
|
||||||
|
path.join(inputPath, 'Minecraft', 'Instances'),
|
||||||
|
path.join(inputPath, 'minecraft', 'Instances'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const looksLikeWindowsInstall =
|
||||||
|
/curseforge windows/i.test(inputPath) ||
|
||||||
|
(fs.existsSync(path.join(inputPath, 'CurseForge.exe')) && fs.existsSync(path.join(inputPath, 'resources')));
|
||||||
|
if (looksLikeWindowsInstall) {
|
||||||
|
candidates.push(
|
||||||
|
path.join(os.homedir(), 'curseforge', 'minecraft', 'Instances'),
|
||||||
|
path.join(os.homedir(), 'CurseForge', 'minecraft', 'Instances'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLoaderVersion(loader, rawValue, mcVersion) {
|
||||||
|
let value = String(rawValue || '').trim();
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
if (loader === 'forge') {
|
||||||
|
value = value.replace(/^forge-/i, '');
|
||||||
|
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (loader === 'neoforge') {
|
||||||
|
return value.replace(/^neoforge-/i, '');
|
||||||
|
}
|
||||||
|
if (loader === 'fabric') {
|
||||||
|
value = value.replace(/^fabric(?:-loader)?-/i, '');
|
||||||
|
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (loader === 'quilt') {
|
||||||
|
value = value.replace(/^quilt(?:-loader)?-/i, '');
|
||||||
|
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLoader(meta, mcVersion) {
|
||||||
|
const raw = meta && (
|
||||||
|
meta.baseModLoader || meta.modLoader || meta.modloader || meta.loader || meta.modLoaderId
|
||||||
|
);
|
||||||
|
const value = typeof raw === 'object'
|
||||||
|
? (raw.name || raw.id || raw.value || raw.version || '')
|
||||||
|
: (raw || '');
|
||||||
|
const lower = String(value).toLowerCase();
|
||||||
|
const objectVersion = raw && typeof raw === 'object'
|
||||||
|
? (raw.version || raw.name || raw.id || '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!lower) return { loader: 'vanilla', loaderVersion: '' };
|
||||||
|
if (lower.includes('neoforge')) return { loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', objectVersion || value, mcVersion) };
|
||||||
|
if (lower.includes('fabric')) return { loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', objectVersion || value, mcVersion) };
|
||||||
|
if (lower.includes('quilt')) return { loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', objectVersion || value, mcVersion) };
|
||||||
|
if (lower.includes('forge')) return { loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', objectVersion || value, mcVersion) };
|
||||||
|
return { loader: 'vanilla', loaderVersion: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectVersion(meta, loaderVersion) {
|
||||||
|
return metaVersion(meta) || (loaderVersion && loaderVersion.includes('-') ? loaderVersion.split('-')[0] : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGameDir(dir) {
|
||||||
|
const nested = ['minecraft', '.minecraft']
|
||||||
|
.map((name) => path.join(dir, name))
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
if (nested) return nested;
|
||||||
|
|
||||||
|
const markers = ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'options.txt'];
|
||||||
|
return markers.some((name) => fs.existsSync(path.join(dir, name))) ? dir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(inputPath) {
|
||||||
|
const instancesDir = resolveInstancesDir(inputPath);
|
||||||
|
if (!instancesDir) return { ok: false, reason: 'not-found' };
|
||||||
|
|
||||||
|
const list = [];
|
||||||
|
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const dir = path.join(instancesDir, entry.name);
|
||||||
|
const metaFile = existingMetaFile(dir);
|
||||||
|
const meta = metaFile ? readJson(metaFile, {}) : {};
|
||||||
|
const gameDir = findGameDir(dir);
|
||||||
|
if (!metaFile && !gameDir) continue;
|
||||||
|
|
||||||
|
const versionHint = metaVersion(meta);
|
||||||
|
const loaderInfo = detectLoader(meta, versionHint);
|
||||||
|
list.push({
|
||||||
|
folder: entry.name,
|
||||||
|
dir,
|
||||||
|
name: meta.name || meta.displayName || entry.name,
|
||||||
|
group: '',
|
||||||
|
notes: meta.notes || meta.summary || '',
|
||||||
|
version: versionHint || detectVersion(meta, loaderInfo.loaderVersion),
|
||||||
|
loader: loaderInfo.loader,
|
||||||
|
loaderVersion: loaderInfo.loaderVersion,
|
||||||
|
javaPath: String(meta.javaPath || meta.javaExecutable || '').trim(),
|
||||||
|
minMemMb: Number(meta.minimumMemory || meta.minMemory || meta.minMemAlloc) || null,
|
||||||
|
maxMemMb: Number(meta.maximumMemory || meta.maxMemory || meta.maxMemAlloc || meta.allocatedMemory) || null,
|
||||||
|
gameDir: gameDir || dir,
|
||||||
|
hasGameData: !!gameDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return { ok: true, instancesDir, count: list.length, instances: list };
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInto(sourceDir, targetDir) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
for (const name of fs.readdirSync(sourceDir)) {
|
||||||
|
if (META_FILES.includes(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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = {};
|
||||||
|
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
|
||||||
|
patch.java = {
|
||||||
|
path: entry.javaPath || '',
|
||||||
|
minMemMb: entry.minMemMb || null,
|
||||||
|
maxMemMb: entry.maxMemMb || null,
|
||||||
|
extraArgs: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||||
|
|
||||||
|
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, resolveInstancesDir };
|
||||||
203
src/crashreports.js
Normal file
203
src/crashreports.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* crashreports.js – Absturzberichte von Minecraft auswerten
|
||||||
|
* ----------------------------------------------------------
|
||||||
|
* Minecraft legt bei einem Absturz eine Datei unter
|
||||||
|
* <spielordner>/crash-reports/crash-<datum>-client.txt an. Die enthält
|
||||||
|
* hunderte Zeilen Systemangaben, aber nur wenige, die weiterhelfen.
|
||||||
|
*
|
||||||
|
* Dieses Modul sucht den neuesten Bericht und zieht daraus heraus:
|
||||||
|
* Beschreibung, Fehlerart, verdächtige Mods und die ersten Stellen im
|
||||||
|
* Aufrufverlauf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
// größere Berichte gibt es kaum; die Grenze schützt vor Ausreißern
|
||||||
|
const MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
function berichtsOrdner(gameDir) {
|
||||||
|
return path.join(gameDir, 'crash-reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Neuester Bericht im Ordner. seit: nur Berichte ab diesem Zeitpunkt
|
||||||
|
* (Millisekunden) – sonst zeigt man nach einem sauberen Start den Absturz
|
||||||
|
* von vorgestern an.
|
||||||
|
*/
|
||||||
|
function neuesterBericht(gameDir, seit) {
|
||||||
|
const dir = berichtsOrdner(gameDir);
|
||||||
|
let eintraege = [];
|
||||||
|
try { eintraege = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
|
||||||
|
|
||||||
|
let bester = null;
|
||||||
|
for (const e of eintraege) {
|
||||||
|
if (!e.isFile() || !/^crash-.*\.txt$/i.test(e.name)) continue;
|
||||||
|
const voll = path.join(dir, e.name);
|
||||||
|
let st;
|
||||||
|
try { st = fs.statSync(voll); } catch { continue; }
|
||||||
|
if (seit && st.mtimeMs < seit) continue;
|
||||||
|
if (!bester || st.mtimeMs > bester.mtimeMs) bester = { pfad: voll, name: e.name, mtimeMs: st.mtimeMs, groesse: st.size };
|
||||||
|
}
|
||||||
|
return bester;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zieht die aussagekräftigen Zeilen aus einem Bericht.
|
||||||
|
* -> { beschreibung, fehler, verdaechtigeMods, diagnose, empfehlung, verlauf: [] }
|
||||||
|
*/
|
||||||
|
function auswerten(text) {
|
||||||
|
const zeilen = String(text || '').split(/\r?\n/);
|
||||||
|
const befund = {
|
||||||
|
beschreibung: '',
|
||||||
|
fehler: '',
|
||||||
|
verdaechtigeMods: '',
|
||||||
|
diagnose: '',
|
||||||
|
empfehlung: '',
|
||||||
|
verlauf: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < zeilen.length; i++) {
|
||||||
|
const z = zeilen[i];
|
||||||
|
const roh = z.trim();
|
||||||
|
|
||||||
|
if (!befund.beschreibung && /^Description:/i.test(roh)) {
|
||||||
|
befund.beschreibung = roh.replace(/^Description:\s*/i, '').trim();
|
||||||
|
// Die Fehlerzeile steht direkt darunter, nach einer Leerzeile.
|
||||||
|
for (let j = i + 1; j < Math.min(i + 6, zeilen.length); j++) {
|
||||||
|
const k = zeilen[j].trim();
|
||||||
|
if (!k) continue;
|
||||||
|
befund.fehler = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forge/NeoForge nennen Verdächtige ausdrücklich
|
||||||
|
if (!befund.verdaechtigeMods && /^Suspected Mods?:/i.test(roh)) {
|
||||||
|
befund.verdaechtigeMods = roh.replace(/^Suspected Mods?:\s*/i, '').trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// die obersten Stellen im Aufrufverlauf – dort steht meist der Verursacher
|
||||||
|
if (befund.verlauf.length < 8 && /^at\s+\S/.test(roh)) {
|
||||||
|
befund.verlauf.push(roh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manche Berichte nennen keine Description, dafür eine Ausnahme weiter oben
|
||||||
|
if (!befund.fehler) {
|
||||||
|
const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim()));
|
||||||
|
if (treffer) befund.fehler = treffer.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const volltext = zeilen.join('\n');
|
||||||
|
const shutdownWatchdog = /Description:\s*Client shutdown from post-main/i.test(volltext)
|
||||||
|
&& /ClientShutdownWatchdog/i.test(volltext);
|
||||||
|
const webMapJetty = /journeymap_webmap:\s*JourneyMap WebMap\b/i.test(volltext)
|
||||||
|
&& /JettyServerThreadPool/i.test(volltext)
|
||||||
|
&& /ServerConnector.*\{0\.0\.0\.0:\d+\}/i.test(volltext);
|
||||||
|
if (shutdownWatchdog && webMapJetty) {
|
||||||
|
const version = volltext.match(/journeymap_webmap:\s*JourneyMap WebMap\s+([^\s\r\n]+)/i);
|
||||||
|
const name = `JourneyMap WebMap${version ? ` ${version[1]}` : ''}`;
|
||||||
|
befund.verdaechtigeMods = name;
|
||||||
|
befund.diagnose = `${name} hat seinen eingebetteten Jetty-Webserver beim Beenden nicht gestoppt. `
|
||||||
|
+ 'Dessen nicht beendete Server-Threads haben den Minecraft-Shutdown-Watchdog ausgelöst.';
|
||||||
|
befund.empfehlung = 'AeroMC beendet einen nach dem regulären Client-Shutdown verbliebenen Java-Prozess nun '
|
||||||
|
+ 'nach einer kurzen Schonfrist automatisch. JourneyMap WebMap kann installiert und aktiviert bleiben.';
|
||||||
|
}
|
||||||
|
return befund;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sucht und liest den neuesten Bericht.
|
||||||
|
* -> { ok: false, grund } oder { ok: true, datei, zeitpunkt, ...auswertung, text }
|
||||||
|
*/
|
||||||
|
function lies(gameDir, seit) {
|
||||||
|
const bericht = neuesterBericht(gameDir, seit);
|
||||||
|
if (!bericht) return { ok: false, grund: 'kein-bericht' };
|
||||||
|
if (bericht.groesse > MAX_BYTES) return { ok: false, grund: 'zu-gross', datei: bericht.name };
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
try { text = fs.readFileSync(bericht.pfad, 'utf8'); }
|
||||||
|
catch (err) { return { ok: false, grund: 'nicht-lesbar', message: err.message }; }
|
||||||
|
|
||||||
|
return Object.assign({
|
||||||
|
ok: true,
|
||||||
|
datei: bericht.name,
|
||||||
|
pfad: bericht.pfad,
|
||||||
|
zeitpunkt: new Date(bericht.mtimeMs).toISOString(),
|
||||||
|
text,
|
||||||
|
}, auswerten(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Logdateien der Instanz auflisten. Minecraft schreibt latest.log und legt
|
||||||
|
* ältere Läufe gepackt als <datum>-<nr>.log.gz daneben.
|
||||||
|
* -> [{ name, groesse, geaendert, gepackt }] – neueste zuerst
|
||||||
|
*/
|
||||||
|
function listeLogs(gameDir) {
|
||||||
|
const dir = path.join(gameDir, 'logs');
|
||||||
|
let eintraege = [];
|
||||||
|
try { eintraege = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
for (const e of eintraege) {
|
||||||
|
if (!e.isFile()) continue;
|
||||||
|
const gepackt = /\.log\.gz$/i.test(e.name);
|
||||||
|
if (!gepackt && !/\.log$/i.test(e.name)) continue;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(path.join(dir, e.name));
|
||||||
|
logs.push({ name: e.name, groesse: st.size, geaendert: st.mtimeMs, gepackt });
|
||||||
|
} catch { /* überspringen */ }
|
||||||
|
}
|
||||||
|
// latest.log immer zuoberst, danach nach Datum
|
||||||
|
logs.sort((a, b) => {
|
||||||
|
if (a.name.toLowerCase() === 'latest.log') return -1;
|
||||||
|
if (b.name.toLowerCase() === 'latest.log') return 1;
|
||||||
|
return b.geaendert - a.geaendert;
|
||||||
|
});
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inhalt einer Logdatei. Gepackte werden entpackt.
|
||||||
|
* maxZeilen begrenzt die Rückgabe – ein Log kann zehntausende Zeilen haben,
|
||||||
|
* und die Oberfläche soll dabei nicht einfrieren.
|
||||||
|
*/
|
||||||
|
function liesLog(gameDir, name, maxZeilen = 5000) {
|
||||||
|
const dir = path.join(gameDir, 'logs');
|
||||||
|
const voll = path.join(dir, path.basename(String(name || '')));
|
||||||
|
if (path.dirname(voll) !== dir) return { ok: false, grund: 'bad-name' };
|
||||||
|
if (!fs.existsSync(voll)) return { ok: false, grund: 'nicht-gefunden' };
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
if (/\.gz$/i.test(voll)) text = zlib.gunzipSync(fs.readFileSync(voll)).toString('utf8');
|
||||||
|
else text = fs.readFileSync(voll, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, grund: 'nicht-lesbar', message: err.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = text.split(/\r?\n/);
|
||||||
|
const gekuerzt = zeilen.length > maxZeilen;
|
||||||
|
// bei langen Logs das Ende zeigen – dort steht, was zuletzt passierte
|
||||||
|
const sichtbar = gekuerzt ? zeilen.slice(zeilen.length - maxZeilen) : zeilen;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
name: path.basename(voll),
|
||||||
|
zeilenGesamt: zeilen.length,
|
||||||
|
gekuerzt,
|
||||||
|
text: sichtbar.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logOrdner(gameDir) {
|
||||||
|
return path.join(gameDir, 'logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { lies, auswerten, neuesterBericht, berichtsOrdner, listeLogs, liesLog, logOrdner };
|
||||||
548
src/index.html
548
src/index.html
@@ -10,9 +10,8 @@
|
|||||||
<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="Instanz aus .zip importieren">Importieren</button>
|
<button id="btn-import" class="tbtn" data-ic="import" title="Instanzen importieren">Importieren</button>
|
||||||
<button id="btn-modpack" class="tbtn" data-ic="package" title="Modrinth-Modpack (.mrpack)">Modpack</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>
|
||||||
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
|
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
|
||||||
@@ -20,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>
|
||||||
@@ -52,6 +51,9 @@
|
|||||||
<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-servers" class="sbtn">Server verwalten</button>
|
||||||
<div class="hr"></div>
|
<div class="hr"></div>
|
||||||
<button id="p-mcfolder" class="sbtn">Minecraft-Ordner</button>
|
<button id="p-mcfolder" class="sbtn">Minecraft-Ordner</button>
|
||||||
<button id="p-configfolder" class="sbtn">Konfigurations-Ordner</button>
|
<button id="p-configfolder" class="sbtn">Konfigurations-Ordner</button>
|
||||||
@@ -59,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>
|
||||||
@@ -155,9 +157,25 @@
|
|||||||
<input id="f-max-mem" type="number" min="512" step="256" placeholder="global" />
|
<input id="f-max-mem" type="number" min="512" step="256" placeholder="global" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
@@ -192,6 +210,7 @@
|
|||||||
<button class="snav" data-pane="pane-start">Start & Java</button>
|
<button class="snav" data-pane="pane-start">Start & Java</button>
|
||||||
<button class="snav" data-pane="pane-network">Netzwerk</button>
|
<button class="snav" data-pane="pane-network">Netzwerk</button>
|
||||||
<button class="snav" data-pane="pane-accounts">Konten</button>
|
<button class="snav" data-pane="pane-accounts">Konten</button>
|
||||||
|
<button class="snav" data-pane="pane-backup">Backup</button>
|
||||||
<button class="snav" data-pane="pane-info">Info</button>
|
<button class="snav" data-pane="pane-info">Info</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
@@ -229,12 +248,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
|
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span>Instanzen übernehmen</span>
|
|
||||||
<button id="btn-mmc" class="btn">Aus MultiMC importieren…</button>
|
|
||||||
<small class="hint">Übernimmt Instanzen samt Version, Loader, Gruppe und Spieldaten aus einem MultiMC-Ordner.</small>
|
|
||||||
</label>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
@@ -244,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>
|
||||||
|
|
||||||
@@ -285,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>
|
||||||
@@ -343,6 +372,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<small id="s-java-status" class="hint">–</small>
|
<small id="s-java-status" class="hint">–</small>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input id="s-auto-java" type="checkbox" /> <span>Fehlende Java-Version automatisch herunterladen</span>
|
||||||
|
</label>
|
||||||
|
<small class="hint">Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.</small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
@@ -406,6 +439,30 @@
|
|||||||
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card settings-card-wide">
|
||||||
|
<h5>CurseForge</h5>
|
||||||
|
<label class="field">
|
||||||
|
<span>Eigener API-Key <small class="opt">(optional)</small></span>
|
||||||
|
<div class="path-row">
|
||||||
|
<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>
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Weiterleitung <small class="opt">(optional)</small></span>
|
||||||
|
<input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Token <small class="opt">(falls verlangt)</small></span>
|
||||||
|
<input id="s-cf-proxy-token" type="password" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="hint">Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.</small>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -426,6 +483,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pane-backup" class="spane hidden">
|
||||||
|
<div class="settings-hero">
|
||||||
|
<h4>Backup</h4>
|
||||||
|
<p>Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<section class="settings-card settings-card-wide">
|
||||||
|
<h5>Globales Backup</h5>
|
||||||
|
<small class="hint">Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.</small>
|
||||||
|
<div class="path-row" style="margin-top:14px">
|
||||||
|
<button id="s-backup-global" class="btn btn-primary">Backup erstellen</button>
|
||||||
|
<span id="s-backup-status" class="hint">Noch kein Backup erstellt.</span>
|
||||||
|
</div>
|
||||||
|
<div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true">
|
||||||
|
<div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div>
|
||||||
|
<div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card settings-card-wide">
|
||||||
|
<h5>Automatisch sichern</h5>
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input id="s-autobackup" type="checkbox" /> <span>Sicherungen nach Zeitplan erstellen</span>
|
||||||
|
</label>
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Wie oft</span>
|
||||||
|
<select id="s-autobackup-every">
|
||||||
|
<option value="daily">Täglich</option>
|
||||||
|
<option value="weekly">Wöchentlich</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Wie viele behalten</span>
|
||||||
|
<input id="s-autobackup-keep" type="number" min="1" max="50" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input id="s-autobackup-launch" type="checkbox" /> <span>Zusätzlich vor jedem Spielstart sichern</span>
|
||||||
|
</label>
|
||||||
|
<small class="hint">Eine Sicherung umfasst <em>alle</em> Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.</small>
|
||||||
|
<small id="s-autobackup-last" class="hint">Noch keine automatische Sicherung.</small>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card settings-card-wide">
|
||||||
|
<h5>Wiederherstellen</h5>
|
||||||
|
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small>
|
||||||
|
<label class="field backup-select-field">
|
||||||
|
<span>Verfügbares Backup</span>
|
||||||
|
<select id="s-restore-backup"></select>
|
||||||
|
</label>
|
||||||
|
<div class="path-row" style="margin-top:14px">
|
||||||
|
<button id="s-restore-global" class="btn">Backup wiederherstellen</button>
|
||||||
|
<span id="s-restore-status" class="hint">Noch kein Backup wiederhergestellt.</span>
|
||||||
|
</div>
|
||||||
|
<div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true">
|
||||||
|
<div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div>
|
||||||
|
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="pane-info" class="spane hidden">
|
<div id="pane-info" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Info</h4>
|
<h4>Info</h4>
|
||||||
@@ -439,6 +559,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="s-appinfo" class="settings-kv info-meta"></div>
|
<div id="s-appinfo" class="settings-kv info-meta"></div>
|
||||||
|
<div class="info-actions">
|
||||||
|
<button id="s-check-update" class="btn">Nach Updates suchen</button>
|
||||||
|
<span id="s-update-status" class="info-status">Kein Update-Check ausgeführt.</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -468,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">
|
||||||
@@ -485,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>
|
||||||
@@ -524,6 +678,12 @@
|
|||||||
<button id="m-more" class="btn btn-mini hidden">Mehr laden</button>
|
<button id="m-more" class="btn btn-mini hidden">Mehr laden</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="m-pane-installed" class="hidden">
|
<div id="m-pane-installed" class="hidden">
|
||||||
|
<div class="mmc-bar">
|
||||||
|
<button id="m-add-file" class="btn btn-mini" data-ic="plus">Datei hinzufügen…</button>
|
||||||
|
<button id="m-check-updates" class="btn btn-mini">Nach Updates suchen</button>
|
||||||
|
<button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button>
|
||||||
|
<span id="m-update-status" class="hint"></span>
|
||||||
|
</div>
|
||||||
<div id="m-installed" class="mods-list"><div class="mods-empty">Noch keine Mods.</div></div>
|
<div id="m-installed" class="mods-list"><div class="mods-empty">Noch keine Mods.</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,9 +713,10 @@
|
|||||||
<p id="login-status" class="hint">Warte auf Bestätigung …</p>
|
<p id="login-status" class="hint">Warte auf Bestätigung …</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="login-hint" class="hint">Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Mehrere Konten sind möglich – das aktive wird zum Spielen verwendet.</p>
|
<p id="login-hint" class="hint">Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten – laufende Spiele behalten ihr Konto.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
|
<button id="login-skin" class="btn">Skin ändern…</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button id="login-add" class="btn btn-primary" data-ic="plus">Konto hinzufügen</button>
|
<button id="login-add" class="btn btn-primary" data-ic="plus">Konto hinzufügen</button>
|
||||||
<button class="btn" data-close>Schließen</button>
|
<button class="btn" data-close>Schließen</button>
|
||||||
@@ -599,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">
|
||||||
@@ -616,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">
|
||||||
@@ -626,6 +797,167 @@
|
|||||||
</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 ===================== -->
|
||||||
|
<div id="modal-servers" class="modal hidden">
|
||||||
|
<div class="modal-card modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="servers-title">Server</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="hint">Diese Liste steht in der Datei servers.dat der Instanz und erscheint im Spiel unter „Mehrspieler". Änderungen wirken erst beim nächsten Start.</p>
|
||||||
|
<div class="field-row" style="margin-top:12px">
|
||||||
|
<label class="field"><span>Name</span><input id="srv-name" type="text" placeholder="z. B. Mein Server" /></label>
|
||||||
|
<label class="field"><span>Adresse</span><input id="srv-ip" type="text" placeholder="mc.example.de oder 192.168.1.50:25566" /></label>
|
||||||
|
<button id="srv-add" class="btn btn-primary" style="align-self:flex-end">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
<div id="srv-list" class="mods-list" style="margin-top:12px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn" data-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Dialog: Server übertragen ===================== -->
|
||||||
|
<div id="modal-srv-copy" class="modal hidden">
|
||||||
|
<div class="modal-card modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Server übertragen</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="srv-copy-sub" class="hint">–</p>
|
||||||
|
<div id="srv-copy-list" class="mods-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn" data-close>Abbrechen</button>
|
||||||
|
<button id="srv-copy-do" class="btn btn-primary">Übertragen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Dialog: Skin ===================== -->
|
||||||
|
<div id="modal-skin" class="modal hidden">
|
||||||
|
<div class="modal-card modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Skin ändern</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="skin-konto" class="hint">–</p>
|
||||||
|
<div class="skin-vorschau">
|
||||||
|
<div class="skin-spalte">
|
||||||
|
<div class="skin-label">Aktuell</div>
|
||||||
|
<div class="skin-bild">
|
||||||
|
<img id="skin-aktuell" alt="" class="hidden" />
|
||||||
|
<span id="skin-aktuell-leer" class="hint">wird geladen …</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="skin-spalte">
|
||||||
|
<div class="skin-label">Neu</div>
|
||||||
|
<div class="skin-bild">
|
||||||
|
<img id="skin-neu" alt="" class="hidden" />
|
||||||
|
<span id="skin-neu-leer" class="hint">noch keine Datei gewählt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="hint">Die Vorschau zeigt die Figur so, wie sie im Spiel aussieht. Die Armform wirkt sich auf die Darstellung aus.</small>
|
||||||
|
<div class="path-row" style="margin-top:12px">
|
||||||
|
<input id="skin-pfad" type="text" readonly placeholder="PNG mit 64×64 Pixeln" />
|
||||||
|
<button id="skin-pick" class="btn">Datei wählen…</button>
|
||||||
|
</div>
|
||||||
|
<label class="field" style="margin-top:10px">
|
||||||
|
<span>Armform</span>
|
||||||
|
<select id="skin-variante">
|
||||||
|
<option value="classic">Klassisch (breite Arme, Steve)</option>
|
||||||
|
<option value="slim">Schmal (Alex)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p id="skin-status" class="hint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn" data-close>Schließen</button>
|
||||||
|
<button id="skin-apply" class="btn btn-primary" disabled>Skin setzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Dialog: Logdateien ===================== -->
|
||||||
|
<div id="modal-logs" class="modal hidden">
|
||||||
|
<div class="modal-card modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="logs-title">Logdateien</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mmc-bar">
|
||||||
|
<label class="field" style="margin:0;flex:1">
|
||||||
|
<span>Datei</span>
|
||||||
|
<select id="logs-select"></select>
|
||||||
|
</label>
|
||||||
|
<span id="logs-info" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
<pre id="logs-text" class="crash-voll">Wähle eine Datei …</pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button id="logs-folder" class="btn" data-ic="folder">Ordner öffnen</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn" data-close>Schließen</button>
|
||||||
|
<button id="logs-copy" class="btn btn-primary">Log kopieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Dialog: Absturzbericht ===================== -->
|
||||||
|
<div id="modal-crash" class="modal hidden">
|
||||||
|
<div class="modal-card modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Minecraft ist abgestürzt</h3>
|
||||||
|
<button class="modal-close" data-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="crash-sub" class="hint">–</p>
|
||||||
|
<div id="crash-kern" class="crash-kern"></div>
|
||||||
|
<details class="crash-details">
|
||||||
|
<summary>Vollständigen Bericht anzeigen</summary>
|
||||||
|
<pre id="crash-voll" class="crash-voll"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button id="crash-folder" class="btn" data-ic="folder">Ordner öffnen</button>
|
||||||
|
<button id="crash-console" class="btn">Konsole</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn" data-close>Schließen</button>
|
||||||
|
<button id="crash-copy" class="btn btn-primary">Bericht kopieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Dialog: Update ===================== -->
|
<!-- ===================== Dialog: Update ===================== -->
|
||||||
<div id="modal-update" class="modal hidden">
|
<div id="modal-update" class="modal hidden">
|
||||||
<div class="modal-card modal-sm">
|
<div class="modal-card modal-sm">
|
||||||
@@ -639,55 +971,199 @@
|
|||||||
<span class="upd-arrow">→</span>
|
<span class="upd-arrow">→</span>
|
||||||
<span id="upd-new" class="upd-newv">–</span>
|
<span id="upd-new" class="upd-newv">–</span>
|
||||||
</p>
|
</p>
|
||||||
|
<p id="upd-channel" class="upd-channel hint">Update-Kanal: –</p>
|
||||||
<div id="upd-notes" class="upd-notes"></div>
|
<div id="upd-notes" class="upd-notes"></div>
|
||||||
<div id="upd-progress" class="upd-progress hidden">
|
<div id="upd-progress" class="upd-progress hidden">
|
||||||
<div class="upd-bar"><div id="upd-bar-fill" class="upd-bar-fill"></div></div>
|
<div class="upd-bar"><div id="upd-bar-fill" class="upd-bar-fill"></div></div>
|
||||||
<div id="upd-status" class="hint">Wird heruntergeladen …</div>
|
<div id="upd-status" class="hint">Wird heruntergeladen …</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Der Launcher lädt das Update herunter und startet die Installation automatisch.</p>
|
<p id="upd-hint" class="hint">Der Launcher lädt das Update herunter und startet die Installation automatisch.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button class="btn" data-close>Später</button>
|
<button class="btn" data-close>Später</button>
|
||||||
|
<button id="upd-open" class="btn btn-primary hidden">Release öffnen</button>
|
||||||
<button id="upd-install" class="btn btn-primary">Installieren</button>
|
<button id="upd-install" class="btn btn-primary">Installieren</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Dialog: MultiMC-Import ===================== -->
|
<!-- ===================== Dialog: Import ===================== -->
|
||||||
<div id="modal-mmc" class="modal hidden">
|
<div id="modal-import" class="modal hidden">
|
||||||
<div class="modal-card modal-lg">
|
<div class="modal-card modal-lg">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<h3>Instanzen aus MultiMC übernehmen</h3>
|
<h3>Importieren</h3>
|
||||||
<button class="modal-close" data-close>✕</button>
|
<button class="modal-close" data-close>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<label class="field">
|
<div class="mods-tabs import-tabs">
|
||||||
<span>MultiMC-Ordner</span>
|
<button id="import-tab-zip" class="mtab on" data-kind="zip">ZIP-Archiv</button>
|
||||||
<div class="path-row">
|
<button id="import-tab-modpack" class="mtab" data-kind="modpack">Modpack</button>
|
||||||
<input id="mmc-path" type="text" placeholder="z. B. E:\Minecraft\MultiMC" />
|
<button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
|
||||||
<button id="mmc-pick" class="btn">Wählen…</button>
|
<button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
|
||||||
<button id="mmc-scan" class="btn btn-primary">Suchen</button>
|
<button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
|
||||||
</div>
|
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
|
||||||
<small id="mmc-status" class="hint">Gib den Ordner an, in dem MultiMC.exe liegt.</small>
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<div id="mmc-result" class="hidden">
|
<div id="import-pane-zip" class="import-pane">
|
||||||
<div class="mmc-bar">
|
<p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p>
|
||||||
<label class="checkbox-field" style="margin:0">
|
<p class="hint">Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.</p>
|
||||||
<input id="mmc-all" type="checkbox" checked /> <span>Alle auswählen</span>
|
</div>
|
||||||
|
|
||||||
|
<div id="import-pane-modpack" class="import-pane hidden">
|
||||||
|
<p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
|
||||||
|
<p class="hint">Suche läuft über Modrinth. Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json) – dafür ist ein API-Key oder eine Weiterleitung in den Netzwerk-Einstellungen nötig.</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
<label class="checkbox-field" style="margin:0">
|
<label class="field" style="width:140px">
|
||||||
<input id="mmc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="mmc-list" class="mods-list"></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 id="import-pane-mc" class="import-pane hidden">
|
||||||
|
<label class="field">
|
||||||
|
<span>Minecraft-Launcher-Ordner</span>
|
||||||
|
<div class="path-row">
|
||||||
|
<input id="mc-path" type="text" placeholder="z. B. C:\Users\DeinName\AppData\Roaming\.minecraft" />
|
||||||
|
<button id="mc-pick" class="btn">Wählen…</button>
|
||||||
|
<button id="mc-scan" class="btn btn-primary">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<small id="mc-status" class="hint">Leer lassen nutzt automatisch den Standardpfad unter Windows.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="mc-result" class="hidden">
|
||||||
|
<div class="mmc-bar">
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="mc-all" type="checkbox" checked /> <span>Alle auswählen</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="mc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Mods, Welten, Konfiguration)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="mc-list" class="mods-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="import-pane-cf" class="import-pane hidden">
|
||||||
|
<label class="field">
|
||||||
|
<span>CurseForge-Instanzen</span>
|
||||||
|
<div class="path-row">
|
||||||
|
<input id="cf-path" type="text" placeholder="z. B. C:\Users\DeinName\curseforge\minecraft\Instances" />
|
||||||
|
<button id="cf-pick" class="btn">Wählen…</button>
|
||||||
|
<button id="cf-scan" class="btn btn-primary">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<small id="cf-status" class="hint">Standardpfad unter Windows: C:\Users\DeinName\curseforge\minecraft\Instances</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="cf-result" class="hidden">
|
||||||
|
<div class="mmc-bar">
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="cf-all" type="checkbox" checked /> <span>Alle auswählen</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="cf-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="cf-list" class="mods-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="import-pane-mmc" class="import-pane hidden">
|
||||||
|
<label class="field">
|
||||||
|
<span>MultiMC- oder Prism-Ordner</span>
|
||||||
|
<div class="path-row">
|
||||||
|
<input id="mmc-path" type="text" placeholder="z. B. E:\Minecraft\MultiMC" />
|
||||||
|
<button id="mmc-pick" class="btn">Wählen…</button>
|
||||||
|
<button id="mmc-scan" class="btn btn-primary">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<small id="mmc-status" class="hint">Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="mmc-result" class="hidden">
|
||||||
|
<div class="mmc-bar">
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="mmc-all" type="checkbox" checked /> <span>Alle auswählen</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="mmc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="mmc-list" class="mods-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="import-pane-technic" class="import-pane hidden">
|
||||||
|
<p class="import-copy">Modpack direkt von der Technic-Plattform installieren oder eine bereits vorhandene Technic-Installation importieren.</p>
|
||||||
|
|
||||||
|
<div class="field-row" style="align-items:flex-end">
|
||||||
|
<label class="field" style="flex:1">
|
||||||
|
<span>Technic-Katalog durchsuchen</span>
|
||||||
|
<input id="tp-query" type="text" placeholder="z. B. Hexxit, Tekxit …" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<button id="tp-search-btn" class="btn btn-primary">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<div id="tp-status" class="hint" style="margin-top:8px">Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.</div>
|
||||||
|
<div id="tp-results" class="mods-list" style="margin-top:10px;max-height:220px;overflow:auto"></div>
|
||||||
|
|
||||||
|
<div class="modal-sep" style="margin:16px 0"></div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Oder: vorhandenen Technic-Ordner importieren</span>
|
||||||
|
<div class="path-row">
|
||||||
|
<input id="technic-path" type="text" placeholder="z. B. C:\Users\DeinName\AppData\Roaming\.technic" />
|
||||||
|
<button id="technic-pick" class="btn">Wählen…</button>
|
||||||
|
<button id="technic-scan" class="btn btn-primary">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<small id="technic-status" class="hint">Leer lassen nutzt automatisch den Standardpfad unter Windows (%APPDATA%\.technic).</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="technic-result" class="hidden">
|
||||||
|
<div class="mmc-bar">
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="technic-all" type="checkbox" checked /> <span>Alle auswählen</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-field" style="margin:0">
|
||||||
|
<input id="technic-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Mods, Welten, Konfiguration)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="technic-list" class="mods-list"></div>
|
||||||
|
<p class="hint" style="margin-top:8px">Minecraft-Version und Mod-Loader werden nach bestem Wissen aus dem Pack erkannt – bitte nach dem Import kurz prüfen, falls ein Pack ungewöhnlich aufgebaut ist.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<div id="mmc-progress" class="hint"></div>
|
<div id="mc-progress" class="hint hidden"></div>
|
||||||
|
<div id="mmc-progress" class="hint hidden"></div>
|
||||||
|
<div id="cf-progress" class="hint hidden"></div>
|
||||||
|
<div id="technic-progress" class="hint hidden"></div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="btn" data-close>Abbrechen</button>
|
<button class="btn" data-close>Abbrechen</button>
|
||||||
<button id="mmc-do" class="btn btn-primary" disabled>Übernehmen</button>
|
<button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button>
|
||||||
|
<button id="import-modpack-do" class="btn btn-primary hidden">Modpack wählen…</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="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>
|
||||||
@@ -749,6 +1225,8 @@
|
|||||||
<button class="ctx-item" data-act="p-mods">Mods anzeigen</button>
|
<button class="ctx-item" data-act="p-mods">Mods anzeigen</button>
|
||||||
<button class="ctx-item" data-act="p-worlds">Welten verwalten</button>
|
<button class="ctx-item" data-act="p-worlds">Welten verwalten</button>
|
||||||
<button class="ctx-item" data-act="p-screens">Bildschirmfotos verwalten</button>
|
<button class="ctx-item" data-act="p-screens">Bildschirmfotos verwalten</button>
|
||||||
|
<button class="ctx-item" data-act="p-logs">Logdateien ansehen</button>
|
||||||
|
<button class="ctx-item" data-act="p-servers">Server verwalten</button>
|
||||||
<div class="ctx-sep"></div>
|
<div class="ctx-sep"></div>
|
||||||
<button class="ctx-item" data-act="p-mcfolder">Minecraft-Ordner</button>
|
<button class="ctx-item" data-act="p-mcfolder">Minecraft-Ordner</button>
|
||||||
<button class="ctx-item" data-act="p-configfolder">Konfigurations-Ordner</button>
|
<button class="ctx-item" data-act="p-configfolder">Konfigurations-Ordner</button>
|
||||||
|
|||||||
212
src/javadl.js
Normal file
212
src/javadl.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* javadl.js – Java bei Bedarf selbst beschaffen
|
||||||
|
* ----------------------------------------------
|
||||||
|
* services.js sucht nur nach vorhandenen Java-Installationen. Wer keine hat,
|
||||||
|
* kam bisher nicht ins Spiel. Dieses Modul lädt die passende Laufzeit von
|
||||||
|
* Adoptium (Eclipse Temurin) und legt sie unter <userData>/java ab.
|
||||||
|
*
|
||||||
|
* Ablauf: Release suchen -> ZIP laden -> entpacken -> java.exe zurückgeben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const unzipper = require('unzipper');
|
||||||
|
|
||||||
|
const API = 'https://api.adoptium.net/v3';
|
||||||
|
const UA = 'AeroMC-Launcher';
|
||||||
|
|
||||||
|
let javaRoot = null;
|
||||||
|
|
||||||
|
// Ablageort festlegen (üblicherweise <userData>/java)
|
||||||
|
function setJavaDir(dir) {
|
||||||
|
javaRoot = dir;
|
||||||
|
return javaRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJavaDir() { return javaRoot; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sucht das neueste Release für eine Major-Version.
|
||||||
|
* Bevorzugt die JRE (rund halb so groß), fällt sonst auf das JDK zurück.
|
||||||
|
*/
|
||||||
|
async function findRelease(major) {
|
||||||
|
for (const imageType of ['jre', 'jdk']) {
|
||||||
|
const url = new URL(`${API}/assets/latest/${major}/hotspot`);
|
||||||
|
url.searchParams.set('architecture', 'x64');
|
||||||
|
url.searchParams.set('image_type', imageType);
|
||||||
|
url.searchParams.set('os', 'windows');
|
||||||
|
url.searchParams.set('vendor', 'eclipse');
|
||||||
|
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const liste = await res.json();
|
||||||
|
const treffer = (Array.isArray(liste) ? liste : [])
|
||||||
|
.find((a) => a && a.binary && a.binary.package && a.binary.package.link);
|
||||||
|
if (!treffer) continue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
major,
|
||||||
|
imageType,
|
||||||
|
releaseName: treffer.release_name || `temurin-${major}`,
|
||||||
|
url: treffer.binary.package.link,
|
||||||
|
size: Number(treffer.binary.package.size) || 0,
|
||||||
|
sha256: treffer.binary.package.checksum || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordnername, unter dem eine Laufzeit abgelegt wird
|
||||||
|
function zielOrdner(release) {
|
||||||
|
const safe = String(release.releaseName).replace(/[^\w.+-]/g, '_');
|
||||||
|
return path.join(javaRoot, `${safe}-${release.imageType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// java.exe innerhalb einer entpackten Laufzeit finden (bin/ liegt eine Ebene tiefer)
|
||||||
|
function findeJavaExe(wurzel) {
|
||||||
|
const direkt = path.join(wurzel, 'bin', 'java.exe');
|
||||||
|
if (fs.existsSync(direkt)) return direkt;
|
||||||
|
let eintraege = [];
|
||||||
|
try { eintraege = fs.readdirSync(wurzel, { withFileTypes: true }); } catch { return null; }
|
||||||
|
for (const e of eintraege) {
|
||||||
|
if (!e.isDirectory()) continue;
|
||||||
|
const p = path.join(wurzel, e.name, 'bin', 'java.exe');
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Bereits heruntergeladene Laufzeiten auflisten.
|
||||||
|
* -> [{ path, dir, major }]
|
||||||
|
*/
|
||||||
|
function listManaged() {
|
||||||
|
if (!javaRoot || !fs.existsSync(javaRoot)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const name of fs.readdirSync(javaRoot)) {
|
||||||
|
const dir = path.join(javaRoot, name);
|
||||||
|
let exe = null;
|
||||||
|
try { if (fs.statSync(dir).isDirectory()) exe = findeJavaExe(dir); } catch { continue; }
|
||||||
|
if (!exe) continue;
|
||||||
|
const m = name.match(/(?:jdk-?|temurin-?)(\d+)/i);
|
||||||
|
out.push({ path: exe, dir, major: m ? Number(m[1]) : null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// schon eine passende Laufzeit da?
|
||||||
|
function findManaged(major) {
|
||||||
|
return listManaged().find((j) => j.major === major) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeDatei(url, ziel, onProgress) {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||||
|
if (!res.ok) throw new Error('Java-Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
const gesamt = Number(res.headers.get('content-length')) || 0;
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(ziel), { recursive: true });
|
||||||
|
const out = fs.createWriteStream(ziel);
|
||||||
|
let geladen = 0;
|
||||||
|
let letzte = 0;
|
||||||
|
try {
|
||||||
|
for await (const stueck of res.body) {
|
||||||
|
const buf = Buffer.from(stueck);
|
||||||
|
geladen += buf.length;
|
||||||
|
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||||||
|
const onDrain = () => { out.off('error', onError); resolve(); };
|
||||||
|
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||||||
|
out.once('drain', onDrain);
|
||||||
|
out.once('error', onError);
|
||||||
|
});
|
||||||
|
if (onProgress && Date.now() - letzte >= 250) {
|
||||||
|
letzte = Date.now();
|
||||||
|
onProgress({ phase: 'java-download', geladen, gesamt, percent: gesamt ? Math.round((geladen / gesamt) * 100) : 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
out.once('error', reject);
|
||||||
|
out.end((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
out.destroy();
|
||||||
|
fs.rmSync(ziel, { force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return geladen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pfade aus dem Archiv gegen Ausbrüche absichern
|
||||||
|
function saubererPfad(eintrag) {
|
||||||
|
const p = String(eintrag || '').replace(/\\/g, '/');
|
||||||
|
if (!p || p.startsWith('/') || /^[a-z]:/i.test(p)) return null;
|
||||||
|
if (p.split('/').includes('..')) return null;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entpacken(zipPfad, zielDir, onProgress) {
|
||||||
|
const archiv = await unzipper.Open.file(zipPfad);
|
||||||
|
const dateien = (archiv.files || []).filter((e) => e.type !== 'Directory');
|
||||||
|
fs.mkdirSync(zielDir, { recursive: true });
|
||||||
|
|
||||||
|
let fertig = 0;
|
||||||
|
let letzte = 0;
|
||||||
|
for (const eintrag of archiv.files || []) {
|
||||||
|
const rel = saubererPfad(eintrag.path);
|
||||||
|
if (!rel) continue;
|
||||||
|
const ziel = path.join(zielDir, rel);
|
||||||
|
if (eintrag.type === 'Directory') { fs.mkdirSync(ziel, { recursive: true }); continue; }
|
||||||
|
fs.mkdirSync(path.dirname(ziel), { recursive: true });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
eintrag.stream()
|
||||||
|
.pipe(fs.createWriteStream(ziel))
|
||||||
|
.on('finish', resolve)
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
fertig++;
|
||||||
|
if (onProgress && Date.now() - letzte >= 250) {
|
||||||
|
letzte = Date.now();
|
||||||
|
onProgress({ phase: 'java-entpacken', fertig, gesamt: dateien.length,
|
||||||
|
percent: dateien.length ? Math.round((fertig / dateien.length) * 100) : 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fertig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stellt sicher, dass eine Java-Laufzeit der gewünschten Major-Version da ist.
|
||||||
|
* -> { path, major, geladen } oder null
|
||||||
|
*/
|
||||||
|
async function ensureJava(major, onProgress) {
|
||||||
|
if (!javaRoot) throw new Error('Java-Ablageort ist nicht gesetzt.');
|
||||||
|
const vorhanden = findManaged(major);
|
||||||
|
if (vorhanden) return { path: vorhanden.path, major, geladen: false };
|
||||||
|
|
||||||
|
const release = await findRelease(major);
|
||||||
|
if (!release) throw new Error(`Für Java ${major} wird bei Adoptium keine Windows-Fassung angeboten.`);
|
||||||
|
|
||||||
|
const ziel = zielOrdner(release);
|
||||||
|
const tmpZip = path.join(javaRoot, `.download-${Date.now()}.zip`);
|
||||||
|
fs.mkdirSync(javaRoot, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
|
||||||
|
await ladeDatei(release.url, tmpZip, onProgress);
|
||||||
|
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
|
||||||
|
// halbfertiges Java, falls unterwegs etwas schiefgeht
|
||||||
|
const tmpDir = ziel + '.teil';
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
await entpacken(tmpZip, tmpDir, onProgress);
|
||||||
|
fs.rmSync(ziel, { recursive: true, force: true });
|
||||||
|
fs.renameSync(tmpDir, ziel);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpZip, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exe = findeJavaExe(ziel);
|
||||||
|
if (!exe) throw new Error('Im heruntergeladenen Archiv wurde keine java.exe gefunden.');
|
||||||
|
return { path: exe, major, geladen: true, dir: ziel };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setJavaDir, getJavaDir, ensureJava, findRelease, listManaged, findManaged, findeJavaExe };
|
||||||
130
src/launcher.js
130
src/launcher.js
@@ -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';
|
||||||
@@ -150,6 +151,36 @@ async function prepareLibraries(vj, sharedDir, nativesDir, onProgress) {
|
|||||||
return classpath;
|
return classpath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function classpathArtifactKey(filePath, sharedDir) {
|
||||||
|
const libDir = path.join(sharedDir, 'libraries');
|
||||||
|
const rel = path.relative(libDir, filePath);
|
||||||
|
if (rel.startsWith('..') || path.isAbsolute(rel)) return path.normalize(filePath).toLowerCase();
|
||||||
|
|
||||||
|
const parts = rel.split(path.sep);
|
||||||
|
if (parts.length < 4) return rel.replace(/\\/g, '/').toLowerCase();
|
||||||
|
|
||||||
|
const fileName = parts[parts.length - 1];
|
||||||
|
const version = parts[parts.length - 2];
|
||||||
|
const artifact = parts[parts.length - 3];
|
||||||
|
const group = parts.slice(0, -3).join('/').toLowerCase();
|
||||||
|
const base = fileName.replace(/\.[^.]+$/, '');
|
||||||
|
const prefix = `${artifact}-${version}`;
|
||||||
|
const classifier = base.startsWith(prefix + '-') ? base.slice(prefix.length + 1).toLowerCase() : '';
|
||||||
|
return `${group}:${artifact.toLowerCase()}:${classifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeClasspath(classpath, sharedDir) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const filePath of classpath) {
|
||||||
|
const key = classpathArtifactKey(filePath, sharedDir);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(filePath);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// entpackt eine Natives-JAR (ZIP) nach nativesDir (ohne externe Abhängigkeit)
|
// entpackt eine Natives-JAR (ZIP) nach nativesDir (ohne externe Abhängigkeit)
|
||||||
async function extractNatives(jarPath, nativesDir, extractRule) {
|
async function extractNatives(jarPath, nativesDir, extractRule) {
|
||||||
ensureDir(nativesDir);
|
ensureDir(nativesDir);
|
||||||
@@ -223,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -343,8 +382,28 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
|
|
||||||
// Java (VOR dem Loader – Forge-Prozessoren brauchen Java)
|
// Java (VOR dem Loader – Forge-Prozessoren brauchen Java)
|
||||||
emit('java', { detail: 'Java wählen …' });
|
emit('java', { detail: 'Java wählen …' });
|
||||||
const java = pickJava(vj, opts.javaPath);
|
let java = pickJava(vj, opts.javaPath);
|
||||||
|
// Nichts Passendes da? Dann darf der Launcher die richtige Fassung selbst
|
||||||
|
// holen – opts.ensureJava wird vom Hauptprozess gestellt.
|
||||||
|
if ((!java || java.mismatch) && opts.ensureJava) {
|
||||||
|
const benoetigt = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
||||||
|
emit('java', { detail: `Java ${benoetigt} wird geladen …` });
|
||||||
|
try {
|
||||||
|
const geholt = await opts.ensureJava(benoetigt, (p) => emit('java', p));
|
||||||
|
if (geholt && geholt.path) java = { path: geholt.path, major: geholt.major };
|
||||||
|
} catch (err) {
|
||||||
|
emit('log', { line: '[Java] Automatischer Download fehlgeschlagen: ' + err.message + '\n' });
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -407,6 +466,8 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
effectiveVj = mergeLoader(vj, fvj);
|
effectiveVj = mergeLoader(vj, fvj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finalClasspath = dedupeClasspath(classpath, opts.sharedDir);
|
||||||
|
|
||||||
// Ohne Login: alles vorbereitet, aber kein Start (kein Crack-Bypass)
|
// Ohne Login: alles vorbereitet, aber kein Start (kein Crack-Bypass)
|
||||||
if (!opts.auth) {
|
if (!opts.auth) {
|
||||||
return {
|
return {
|
||||||
@@ -425,7 +486,7 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
gameDir: opts.gameDir,
|
gameDir: opts.gameDir,
|
||||||
sharedDir: opts.sharedDir,
|
sharedDir: opts.sharedDir,
|
||||||
nativesDir,
|
nativesDir,
|
||||||
classpath,
|
classpath: finalClasspath,
|
||||||
minMemMb: opts.minMemMb || 2048,
|
minMemMb: opts.minMemMb || 2048,
|
||||||
maxMemMb: opts.maxMemMb || 4096,
|
maxMemMb: opts.maxMemMb || 4096,
|
||||||
extraArgs: opts.extraArgs || '',
|
extraArgs: opts.extraArgs || '',
|
||||||
@@ -434,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);
|
||||||
|
|
||||||
@@ -455,10 +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
|
||||||
child.on('exit', (code) => {
|
: processwatchdog.createProcessWatchdog({
|
||||||
emit('exit', { code });
|
isRunning: () => child.exitCode === null && child.signalCode === null,
|
||||||
|
kill: () => child.kill(),
|
||||||
|
onForce: (graceMs) => emit('log', {
|
||||||
|
line: `[Launcher] Regulärer Client-Shutdown war nach ${graceMs / 1000} Sekunden noch nicht beendet; `
|
||||||
|
+ 'verbliebenen Java-Prozess beendet.\n',
|
||||||
|
}),
|
||||||
|
onError: () => emit('log', {
|
||||||
|
line: '[Launcher] Der nach dem regulären Client-Shutdown verbliebene Java-Prozess konnte nicht beendet werden.\n',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
child.stdout.on('data', (d) => {
|
||||||
|
emit('log', { line: d.toString() });
|
||||||
|
if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stdout');
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (d) => {
|
||||||
|
emit('log', { line: d.toString() });
|
||||||
|
if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stderr');
|
||||||
|
});
|
||||||
|
// Ende genau einmal melden – 'exit' und 'error' können beide kommen
|
||||||
|
let beendetGemeldet = false;
|
||||||
|
const meldeEnde = (code, cleanShutdown) => {
|
||||||
|
if (beendetGemeldet) return;
|
||||||
|
beendetGemeldet = true;
|
||||||
|
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' });
|
||||||
@@ -468,6 +556,20 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
if (po.stderr) emit('log', { line: po.stderr });
|
if (po.stderr) emit('log', { line: po.stderr });
|
||||||
} catch { /* egal */ }
|
} catch { /* egal */ }
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
// vor end() lesen: sawCleanShutdown wird zwar nicht von end()/clear()
|
||||||
|
// zurückgesetzt, aber so bleibt die Reihenfolge unabhängig von internen Details
|
||||||
|
const cleanShutdown = !!(shutdownWatchdog && shutdownWatchdog.sawCleanShutdown);
|
||||||
|
if (shutdownWatchdog) shutdownWatchdog.end();
|
||||||
|
meldeEnde(shutdownWatchdog && shutdownWatchdog.forced ? 0 : code, cleanShutdown);
|
||||||
|
});
|
||||||
|
// ohne diesen Listener würde ein Fehlstart (z. B. falscher Java-Pfad) als
|
||||||
|
// unbehandeltes 'error'-Ereignis den Hauptprozess beenden
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (shutdownWatchdog) shutdownWatchdog.end();
|
||||||
|
emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' });
|
||||||
|
meldeEnde(-1, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, pid: child.pid, java };
|
return { ok: true, pid: child.pid, java };
|
||||||
@@ -475,5 +577,5 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets,
|
prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets,
|
||||||
downloadIfNeeded, extractNatives,
|
downloadIfNeeded, extractNatives, mergeLoader, dedupeClasspath, classpathArtifactKey,
|
||||||
};
|
};
|
||||||
|
|||||||
1321
src/main.js
1321
src/main.js
File diff suppressed because it is too large
Load Diff
217
src/mcimport.js
Normal file
217
src/mcimport.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PROFILE_FILES = [
|
||||||
|
'launcher_profiles.json',
|
||||||
|
'launcher_profiles_microsoft_store.json',
|
||||||
|
'launcher_profiles_microsoft_store_2.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROOT_COPY_DIRS = new Set([
|
||||||
|
'config', 'defaultconfigs', 'kubejs', 'mods', 'resourcepacks', 'screenshots', 'shaderpacks', 'saves',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ROOT_COPY_FILE_PATTERNS = [
|
||||||
|
/^options.*\.(txt|of)$/i,
|
||||||
|
/^servers\.dat(?:_old)?$/i,
|
||||||
|
/^usercache\.json$/i,
|
||||||
|
/^tl_skin_cape\.json$/i,
|
||||||
|
/^journeymap.*\.(json|txt|cfg)$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function readJson(file, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLauncherRoot(inputPath) {
|
||||||
|
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||||
|
const defaultRoot = path.join(appData, '.minecraft');
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
if (inputPath && fs.existsSync(inputPath)) candidates.push(inputPath);
|
||||||
|
candidates.push(defaultRoot);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
for (const profileFile of PROFILE_FILES) {
|
||||||
|
if (fs.existsSync(path.join(candidate, profileFile))) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProfileFile(rootDir) {
|
||||||
|
return PROFILE_FILES.map((name) => path.join(rootDir, name)).find((file) => fs.existsSync(file)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLoaderVersion(loader, rawValue, mcVersion) {
|
||||||
|
let value = String(rawValue || '').trim();
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
if (loader === 'forge') {
|
||||||
|
value = value.replace(/^forge-/i, '');
|
||||||
|
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (loader === 'neoforge') return value.replace(/^neoforge-/i, '');
|
||||||
|
if (loader === 'fabric') {
|
||||||
|
value = value.replace(/^fabric(?:-loader)?-/i, '');
|
||||||
|
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (loader === 'quilt') {
|
||||||
|
value = value.replace(/^quilt(?:-loader)?-/i, '');
|
||||||
|
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLastVersionId(lastVersionId) {
|
||||||
|
const raw = String(lastVersionId || '').trim();
|
||||||
|
if (!raw) return { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||||
|
|
||||||
|
let match = raw.match(/^fabric-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
|
||||||
|
if (match) return { version: match[2], loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', match[1], match[2]) };
|
||||||
|
|
||||||
|
match = raw.match(/^quilt-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
|
||||||
|
if (match) return { version: match[2], loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', match[1], match[2]) };
|
||||||
|
|
||||||
|
match = raw.match(/^(.+)-forge-([\w.-]+)$/i);
|
||||||
|
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
|
||||||
|
|
||||||
|
match = raw.match(/^forge-(.+)-([\w.-]+)$/i);
|
||||||
|
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
|
||||||
|
|
||||||
|
match = raw.match(/^(.+)-neoforge-([\w.-]+)$/i);
|
||||||
|
if (match) return { version: match[1], loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', match[2], match[1]) };
|
||||||
|
|
||||||
|
if (/^\d+(?:\.\d+)+(?:-[\w.]+)?$/i.test(raw)) return { version: raw, loader: 'vanilla', loaderVersion: '' };
|
||||||
|
return { version: raw, loader: 'vanilla', loaderVersion: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJavaSettings(javaDir, javaArgs) {
|
||||||
|
const args = String(javaArgs || '').trim();
|
||||||
|
const minMatch = args.match(/(?:^|\s)-Xms(\d+)([mMgG])/);
|
||||||
|
const maxMatch = args.match(/(?:^|\s)-Xmx(\d+)([mMgG])/);
|
||||||
|
const toMb = (match) => {
|
||||||
|
if (!match) return null;
|
||||||
|
const num = Number(match[1]);
|
||||||
|
if (!Number.isFinite(num)) return null;
|
||||||
|
return match[2].toLowerCase() === 'g' ? num * 1024 : num;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: String(javaDir || '').trim(),
|
||||||
|
minMemMb: toMb(minMatch),
|
||||||
|
maxMemMb: toMb(maxMatch),
|
||||||
|
extraArgs: args
|
||||||
|
.replace(/(?:^|\s)-Xms\d+[mMgG]/g, ' ')
|
||||||
|
.replace(/(?:^|\s)-Xmx\d+[mMgG]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImportableProfile(profile) {
|
||||||
|
const type = String((profile && profile.type) || '').trim().toLowerCase();
|
||||||
|
if (type === 'latest-release' || type === 'latest-snapshot') return false;
|
||||||
|
const name = String((profile && profile.name) || '').trim();
|
||||||
|
const lastVersionId = String((profile && profile.lastVersionId) || '').trim();
|
||||||
|
return !!(name || lastVersionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCopyRootEntry(name) {
|
||||||
|
if (ROOT_COPY_DIRS.has(name)) return true;
|
||||||
|
return ROOT_COPY_FILE_PATTERNS.some((pattern) => pattern.test(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(inputPath) {
|
||||||
|
const rootDir = resolveLauncherRoot(inputPath);
|
||||||
|
if (!rootDir) return { ok: false, reason: 'not-found' };
|
||||||
|
|
||||||
|
const profileFile = resolveProfileFile(rootDir);
|
||||||
|
const json = readJson(profileFile, {});
|
||||||
|
const profiles = json.profiles || {};
|
||||||
|
const list = [];
|
||||||
|
const defaultRoot = path.normalize(rootDir).toLowerCase();
|
||||||
|
|
||||||
|
for (const [id, profile] of Object.entries(profiles)) {
|
||||||
|
if (!isImportableProfile(profile)) continue;
|
||||||
|
|
||||||
|
const name = String(profile.name || '').trim() || String(profile.lastVersionId || '').trim() || id;
|
||||||
|
const parsed = parseLastVersionId(profile.lastVersionId);
|
||||||
|
const gameDir = path.normalize(String(profile.gameDir || rootDir));
|
||||||
|
const java = parseJavaSettings(profile.javaDir, profile.javaArgs);
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
group: '',
|
||||||
|
notes: 'Importiert aus dem Minecraft Launcher',
|
||||||
|
version: parsed.version,
|
||||||
|
loader: parsed.loader,
|
||||||
|
loaderVersion: parsed.loaderVersion,
|
||||||
|
javaPath: java.path,
|
||||||
|
minMemMb: java.minMemMb,
|
||||||
|
maxMemMb: java.maxMemMb,
|
||||||
|
extraJavaArgs: java.extraArgs,
|
||||||
|
gameDir,
|
||||||
|
hasGameData: fs.existsSync(gameDir),
|
||||||
|
usesDefaultGameDir: gameDir.toLowerCase() === defaultRoot,
|
||||||
|
rootDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return { ok: true, rootDir, count: list.length, instances: list };
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
for (const name of fs.readdirSync(sourceDir)) {
|
||||||
|
if (/^launcher_profiles.*\.json$/i.test(name)) continue;
|
||||||
|
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
|
||||||
|
if (usesDefaultGameDir && !shouldCopyRootEntry(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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = {};
|
||||||
|
if (entry.javaPath || entry.minMemMb || entry.maxMemMb || entry.extraJavaArgs) {
|
||||||
|
patch.java = {
|
||||||
|
path: entry.javaPath || '',
|
||||||
|
minMemMb: entry.minMemMb || null,
|
||||||
|
maxMemMb: entry.maxMemMb || null,
|
||||||
|
extraArgs: entry.extraJavaArgs || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||||
|
copyInto(entry.gameDir, store.gameDir(created.id), !!entry.usesDefaultGameDir);
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
return { id: created.id, name: created.name, copied };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
|
||||||
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 };
|
||||||
@@ -66,6 +66,38 @@ function resolveInstancesDir(inputPath) {
|
|||||||
return fs.existsSync(inputPath) ? inputPath : null;
|
return fs.existsSync(inputPath) ? inputPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
|
||||||
|
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
|
||||||
|
*/
|
||||||
|
function readInstance(dir, folderName, group) {
|
||||||
|
const cfgFile = path.join(dir, 'instance.cfg');
|
||||||
|
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
|
||||||
|
|
||||||
|
const cfg = parseCfg(cfgFile);
|
||||||
|
const pack = readPack(dir);
|
||||||
|
// MultiMC nutzt .minecraft, Prism minecraft
|
||||||
|
const gameDir = ['.minecraft', 'minecraft']
|
||||||
|
.map((n) => path.join(dir, n))
|
||||||
|
.find((p) => fs.existsSync(p)) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
folder: folderName,
|
||||||
|
dir,
|
||||||
|
name: cfg.name || folderName,
|
||||||
|
group: group || '',
|
||||||
|
notes: cfg.notes || '',
|
||||||
|
version: pack.version || cfg.IntendedVersion || '',
|
||||||
|
loader: pack.loader,
|
||||||
|
loaderVersion: pack.loaderVersion,
|
||||||
|
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
|
||||||
|
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
|
||||||
|
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
|
||||||
|
gameDir,
|
||||||
|
hasGameData: !!gameDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
|
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
|
||||||
*/
|
*/
|
||||||
@@ -77,43 +109,51 @@ function scan(inputPath) {
|
|||||||
const list = [];
|
const list = [];
|
||||||
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
const dir = path.join(instancesDir, entry.name);
|
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
|
||||||
const cfgFile = path.join(dir, 'instance.cfg');
|
if (inst) list.push(inst);
|
||||||
if (!fs.existsSync(cfgFile)) continue; // kein Instanzordner
|
|
||||||
|
|
||||||
const cfg = parseCfg(cfgFile);
|
|
||||||
const pack = readPack(dir);
|
|
||||||
// MultiMC nutzt .minecraft, Prism minecraft
|
|
||||||
const gameDir = ['.minecraft', 'minecraft']
|
|
||||||
.map((n) => path.join(dir, n))
|
|
||||||
.find((p) => fs.existsSync(p)) || null;
|
|
||||||
|
|
||||||
list.push({
|
|
||||||
folder: entry.name,
|
|
||||||
dir,
|
|
||||||
name: cfg.name || entry.name,
|
|
||||||
group: groups[entry.name] || '',
|
|
||||||
notes: cfg.notes || '',
|
|
||||||
version: pack.version || cfg.IntendedVersion || '',
|
|
||||||
loader: pack.loader,
|
|
||||||
loaderVersion: pack.loaderVersion,
|
|
||||||
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
|
|
||||||
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
|
|
||||||
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
|
|
||||||
gameDir,
|
|
||||||
hasGameData: !!gameDir,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return { ok: true, instancesDir, count: list.length, instances: list };
|
return { ok: true, instancesDir, count: list.length, instances: list };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
|
||||||
|
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
|
||||||
|
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
|
||||||
|
*/
|
||||||
|
function findInstanceDir(root, tiefe = 2) {
|
||||||
|
if (!root || !fs.existsSync(root)) return null;
|
||||||
|
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
|
||||||
|
if (tiefe <= 0) return null;
|
||||||
|
let eintraege = [];
|
||||||
|
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
|
||||||
|
for (const e of eintraege) {
|
||||||
|
if (!e.isDirectory()) continue;
|
||||||
|
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
|
||||||
|
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
|
||||||
|
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
|
||||||
|
if (treffer) return treffer;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
|
||||||
|
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
|
||||||
|
*/
|
||||||
|
function scanSingle(root) {
|
||||||
|
const dir = findInstanceDir(root);
|
||||||
|
if (!dir) return { ok: false, reason: 'not-found' };
|
||||||
|
const inst = readInstance(dir, path.basename(dir), '');
|
||||||
|
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Übernimmt eine Instanz in den Launcher.
|
* Übernimmt eine Instanz in den Launcher.
|
||||||
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
|
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
|
||||||
*/
|
*/
|
||||||
function importOne(store, entry, copyData = true) {
|
function importOne(store, entry, copyData = true) {
|
||||||
const created = store.createInstance({
|
const created = store.createOrReplaceImportedInstance({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
group: entry.group,
|
group: entry.group,
|
||||||
notes: entry.notes,
|
notes: entry.notes,
|
||||||
@@ -145,4 +185,4 @@ function importOne(store, entry, copyData = true) {
|
|||||||
return { id: created.id, name: created.name, copied };
|
return { id: created.id, name: created.name, copied };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { scan, importOne, resolveInstancesDir };
|
module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };
|
||||||
|
|||||||
174
src/nbt.js
Normal file
174
src/nbt.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* nbt.js – minimaler NBT-Leser und -Schreiber
|
||||||
|
* --------------------------------------------
|
||||||
|
* Nur so viel, wie servers.dat braucht. Diese Datei ist unkomprimiertes NBT
|
||||||
|
* mit einem Wurzel-Compound, darin eine Liste "servers" aus Compounds mit
|
||||||
|
* name, ip, icon und acceptTextures.
|
||||||
|
*
|
||||||
|
* Bewusst kein Fremdpaket: Der benötigte Ausschnitt ist klein und gut
|
||||||
|
* prüfbar, eine Abhängigkeit für zwei Datentypen wäre unverhältnismäßig.
|
||||||
|
*
|
||||||
|
* Alle Zahlen liegen in NBT als Big-Endian vor, Zeichenketten als
|
||||||
|
* modifiziertes UTF-8 mit vorangestellter Länge (2 Byte).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TAG = {
|
||||||
|
END: 0, BYTE: 1, SHORT: 2, INT: 3, LONG: 4, FLOAT: 5, DOUBLE: 6,
|
||||||
|
BYTE_ARRAY: 7, STRING: 8, LIST: 9, COMPOUND: 10, INT_ARRAY: 11, LONG_ARRAY: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lesen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Leser {
|
||||||
|
constructor(buf) { this.buf = buf; this.pos = 0; }
|
||||||
|
pruefe(n) { if (this.pos + n > this.buf.length) throw new Error('NBT endet unerwartet'); }
|
||||||
|
byte() { this.pruefe(1); return this.buf.readInt8(this.pos++); }
|
||||||
|
ubyte() { this.pruefe(1); return this.buf.readUInt8(this.pos++); }
|
||||||
|
short() { this.pruefe(2); const v = this.buf.readInt16BE(this.pos); this.pos += 2; return v; }
|
||||||
|
ushort() { this.pruefe(2); const v = this.buf.readUInt16BE(this.pos); this.pos += 2; return v; }
|
||||||
|
int() { this.pruefe(4); const v = this.buf.readInt32BE(this.pos); this.pos += 4; return v; }
|
||||||
|
long() { this.pruefe(8); const v = this.buf.readBigInt64BE(this.pos); this.pos += 8; return v; }
|
||||||
|
float() { this.pruefe(4); const v = this.buf.readFloatBE(this.pos); this.pos += 4; return v; }
|
||||||
|
double() { this.pruefe(8); const v = this.buf.readDoubleBE(this.pos); this.pos += 8; return v; }
|
||||||
|
text() {
|
||||||
|
const laenge = this.ushort();
|
||||||
|
this.pruefe(laenge);
|
||||||
|
const s = this.buf.toString('utf8', this.pos, this.pos + laenge);
|
||||||
|
this.pos += laenge;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
bytes(n) { this.pruefe(n); const b = this.buf.subarray(this.pos, this.pos + n); this.pos += n; return Buffer.from(b); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function leseWert(l, typ) {
|
||||||
|
switch (typ) {
|
||||||
|
case TAG.BYTE: return l.byte();
|
||||||
|
case TAG.SHORT: return l.short();
|
||||||
|
case TAG.INT: return l.int();
|
||||||
|
case TAG.LONG: return l.long();
|
||||||
|
case TAG.FLOAT: return l.float();
|
||||||
|
case TAG.DOUBLE: return l.double();
|
||||||
|
case TAG.STRING: return l.text();
|
||||||
|
case TAG.BYTE_ARRAY: return l.bytes(l.int());
|
||||||
|
case TAG.INT_ARRAY: { const n = l.int(); const a = []; for (let i = 0; i < n; i++) a.push(l.int()); return a; }
|
||||||
|
case TAG.LONG_ARRAY: { const n = l.int(); const a = []; for (let i = 0; i < n; i++) a.push(l.long()); return a; }
|
||||||
|
case TAG.LIST: {
|
||||||
|
const innerTyp = l.ubyte();
|
||||||
|
const n = l.int();
|
||||||
|
const a = [];
|
||||||
|
for (let i = 0; i < n; i++) a.push(leseWert(l, innerTyp));
|
||||||
|
a.__typ = innerTyp; // Typ merken, damit Schreiben ihn wiederherstellt
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case TAG.COMPOUND: {
|
||||||
|
const o = {};
|
||||||
|
o.__typen = {};
|
||||||
|
for (;;) {
|
||||||
|
const t = l.ubyte();
|
||||||
|
if (t === TAG.END) break;
|
||||||
|
const name = l.text();
|
||||||
|
o.__typen[name] = t;
|
||||||
|
o[name] = leseWert(l, t);
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
default: throw new Error('Unbekannter NBT-Typ: ' + typ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Liest unkomprimiertes NBT.
|
||||||
|
* -> { name, wert }
|
||||||
|
*/
|
||||||
|
function lese(buf) {
|
||||||
|
const l = new Leser(buf);
|
||||||
|
const typ = l.ubyte();
|
||||||
|
if (typ !== TAG.COMPOUND) throw new Error('NBT beginnt nicht mit einem Compound');
|
||||||
|
const name = l.text();
|
||||||
|
return { name, wert: leseWert(l, TAG.COMPOUND) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schreiben
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Schreiber {
|
||||||
|
constructor() { this.teile = []; }
|
||||||
|
byte(v) { const b = Buffer.alloc(1); b.writeInt8(v, 0); this.teile.push(b); }
|
||||||
|
ubyte(v) { const b = Buffer.alloc(1); b.writeUInt8(v, 0); this.teile.push(b); }
|
||||||
|
short(v) { const b = Buffer.alloc(2); b.writeInt16BE(v, 0); this.teile.push(b); }
|
||||||
|
int(v) { const b = Buffer.alloc(4); b.writeInt32BE(v, 0); this.teile.push(b); }
|
||||||
|
long(v) { const b = Buffer.alloc(8); b.writeBigInt64BE(BigInt(v), 0); this.teile.push(b); }
|
||||||
|
float(v) { const b = Buffer.alloc(4); b.writeFloatBE(v, 0); this.teile.push(b); }
|
||||||
|
double(v) { const b = Buffer.alloc(8); b.writeDoubleBE(v, 0); this.teile.push(b); }
|
||||||
|
text(s) {
|
||||||
|
const d = Buffer.from(String(s), 'utf8');
|
||||||
|
const l = Buffer.alloc(2); l.writeUInt16BE(d.length, 0);
|
||||||
|
this.teile.push(l, d);
|
||||||
|
}
|
||||||
|
roh(b) { this.teile.push(b); }
|
||||||
|
fertig() { return Buffer.concat(this.teile); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typ raten, wenn er nicht aus dem Einlesen bekannt ist
|
||||||
|
function typVon(wert) {
|
||||||
|
if (typeof wert === 'string') return TAG.STRING;
|
||||||
|
if (typeof wert === 'bigint') return TAG.LONG;
|
||||||
|
if (typeof wert === 'boolean') return TAG.BYTE;
|
||||||
|
if (Buffer.isBuffer(wert)) return TAG.BYTE_ARRAY;
|
||||||
|
if (Array.isArray(wert)) return TAG.LIST;
|
||||||
|
if (wert && typeof wert === 'object') return TAG.COMPOUND;
|
||||||
|
if (Number.isInteger(wert)) return TAG.INT;
|
||||||
|
return TAG.DOUBLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schreibeWert(s, typ, wert) {
|
||||||
|
switch (typ) {
|
||||||
|
case TAG.BYTE: s.byte(wert ? Number(wert) : 0); break;
|
||||||
|
case TAG.SHORT: s.short(Number(wert) || 0); break;
|
||||||
|
case TAG.INT: s.int(Number(wert) || 0); break;
|
||||||
|
case TAG.LONG: s.long(wert || 0); break;
|
||||||
|
case TAG.FLOAT: s.float(Number(wert) || 0); break;
|
||||||
|
case TAG.DOUBLE: s.double(Number(wert) || 0); break;
|
||||||
|
case TAG.STRING: s.text(wert == null ? '' : wert); break;
|
||||||
|
case TAG.BYTE_ARRAY: { const b = Buffer.isBuffer(wert) ? wert : Buffer.alloc(0); s.int(b.length); s.roh(b); break; }
|
||||||
|
case TAG.INT_ARRAY: { s.int(wert.length); for (const v of wert) s.int(v); break; }
|
||||||
|
case TAG.LONG_ARRAY: { s.int(wert.length); for (const v of wert) s.long(v); break; }
|
||||||
|
case TAG.LIST: {
|
||||||
|
const liste = wert || [];
|
||||||
|
// leere Listen bekommen END als Typ – so macht es Minecraft auch
|
||||||
|
const innerTyp = liste.__typ || (liste.length ? typVon(liste[0]) : TAG.END);
|
||||||
|
s.ubyte(innerTyp);
|
||||||
|
s.int(liste.length);
|
||||||
|
for (const v of liste) schreibeWert(s, innerTyp, v);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TAG.COMPOUND: {
|
||||||
|
const typen = (wert && wert.__typen) || {};
|
||||||
|
for (const schluessel of Object.keys(wert || {})) {
|
||||||
|
if (schluessel === '__typen' || schluessel === '__typ') continue;
|
||||||
|
const t = typen[schluessel] || typVon(wert[schluessel]);
|
||||||
|
s.ubyte(t);
|
||||||
|
s.text(schluessel);
|
||||||
|
schreibeWert(s, t, wert[schluessel]);
|
||||||
|
}
|
||||||
|
s.ubyte(TAG.END);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: throw new Error('Unbekannter NBT-Typ beim Schreiben: ' + typ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schreibe(name, wert) {
|
||||||
|
const s = new Schreiber();
|
||||||
|
s.ubyte(TAG.COMPOUND);
|
||||||
|
s.text(name || '');
|
||||||
|
schreibeWert(s, TAG.COMPOUND, wert);
|
||||||
|
return s.fertig();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { lese, schreibe, TAG };
|
||||||
111
src/preload.js
111
src/preload.js
@@ -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,9 +22,39 @@ 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),
|
||||||
|
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
|
||||||
|
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
|
||||||
|
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
|
||||||
|
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
|
||||||
|
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
|
||||||
|
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
||||||
|
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
||||||
|
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
||||||
|
onAutoBackup: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
|
ipcRenderer.on('backup:auto', l);
|
||||||
|
return () => ipcRenderer.removeListener('backup:auto', l);
|
||||||
|
},
|
||||||
|
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
|
||||||
|
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
||||||
|
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
||||||
|
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
||||||
|
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
|
||||||
|
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
||||||
|
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
||||||
|
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
||||||
|
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
||||||
|
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
||||||
|
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
||||||
|
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
||||||
|
onModUpdateProgress: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
|
ipcRenderer.on('mods:updateProgress', l);
|
||||||
|
return () => ipcRenderer.removeListener('mods:updateProgress', l);
|
||||||
|
},
|
||||||
|
|
||||||
// Instanzen
|
// Instanzen
|
||||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||||
@@ -38,15 +69,38 @@ 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'),
|
||||||
|
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||||
|
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
||||||
|
onBackupProgress: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
|
ipcRenderer.on('backup:progress', l);
|
||||||
|
return () => ipcRenderer.removeListener('backup:progress', l);
|
||||||
|
},
|
||||||
importInstance: () => ipcRenderer.invoke('instances:import'),
|
importInstance: () => ipcRenderer.invoke('instances:import'),
|
||||||
|
createShortcut: (id) => ipcRenderer.invoke('instances:shortcut', id),
|
||||||
|
// Start über eine Desktop-Verknüpfung ("--launch <id>")
|
||||||
|
onLaunchRequest: (cb) => {
|
||||||
|
const l = (_e, id) => cb(id);
|
||||||
|
ipcRenderer.on('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);
|
||||||
@@ -64,6 +118,10 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
authStatus: () => ipcRenderer.invoke('auth:status'),
|
authStatus: () => ipcRenderer.invoke('auth:status'),
|
||||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||||
|
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
||||||
|
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||||
|
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||||
|
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||||
@@ -74,6 +132,12 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.on('auth:health', listener);
|
ipcRenderer.on('auth:health', listener);
|
||||||
return () => ipcRenderer.removeListener('auth:health', listener);
|
return () => ipcRenderer.removeListener('auth:health', listener);
|
||||||
},
|
},
|
||||||
|
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
|
||||||
|
onAccountsChanged: (cb) => {
|
||||||
|
const listener = () => cb();
|
||||||
|
ipcRenderer.on('accounts:changed', listener);
|
||||||
|
return () => ipcRenderer.removeListener('accounts:changed', listener);
|
||||||
|
},
|
||||||
onAuthRefreshed: (cb) => {
|
onAuthRefreshed: (cb) => {
|
||||||
const listener = () => cb();
|
const listener = () => cb();
|
||||||
ipcRenderer.on('auth:refreshed', listener);
|
ipcRenderer.on('auth:refreshed', listener);
|
||||||
@@ -95,6 +159,37 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.on('mmc:progress', l);
|
ipcRenderer.on('mmc:progress', l);
|
||||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||||
},
|
},
|
||||||
|
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||||
|
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||||
|
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||||
|
onCfProgress: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
|
ipcRenderer.on('cf:progress', l);
|
||||||
|
return () => ipcRenderer.removeListener('cf:progress', l);
|
||||||
|
},
|
||||||
|
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
|
||||||
|
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
|
||||||
|
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
|
||||||
|
onMcProgress: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
|
ipcRenderer.on('mc:progress', l);
|
||||||
|
return () => ipcRenderer.removeListener('mc:progress', l);
|
||||||
|
},
|
||||||
|
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
|
||||||
|
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
|
||||||
|
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
|
||||||
|
onTechnicProgress: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
|
ipcRenderer.on('technic:progress', l);
|
||||||
|
return () => ipcRenderer.removeListener('technic:progress', l);
|
||||||
|
},
|
||||||
|
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
|
||||||
|
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
|
||||||
|
onTechnicPlatformProgress: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
|
ipcRenderer.on('technicPlatform:progress', l);
|
||||||
|
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
|
||||||
|
},
|
||||||
|
|
||||||
// Bedrock (Windows-Store-Ausgabe)
|
// Bedrock (Windows-Store-Ausgabe)
|
||||||
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
||||||
@@ -118,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,
|
||||||
|
};
|
||||||
2306
src/renderer.js
2306
src/renderer.js
File diff suppressed because it is too large
Load Diff
163
src/servers.js
Normal file
163
src/servers.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* servers.js – Serverliste einer Instanz (servers.dat)
|
||||||
|
* -----------------------------------------------------
|
||||||
|
* Minecraft legt die Liste der gespeicherten Server als unkomprimiertes NBT
|
||||||
|
* unter <spielordner>/servers.dat ab. Aufbau:
|
||||||
|
*
|
||||||
|
* Compound "" Wurzel
|
||||||
|
* List "servers" Liste von Compounds
|
||||||
|
* String "name" Anzeigename
|
||||||
|
* String "ip" Adresse (optional mit :Port)
|
||||||
|
* String "icon" Base64-PNG des Serversymbols (optional)
|
||||||
|
* Byte "acceptTextures" Ressourcenpakete annehmen (optional)
|
||||||
|
*
|
||||||
|
* Beim Schreiben wird die vorhandene Datei zuerst gesichert – eine kaputte
|
||||||
|
* servers.dat würde alle gespeicherten Server kosten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const nbt = require('./nbt');
|
||||||
|
|
||||||
|
function datei(gameDir) {
|
||||||
|
return path.join(gameDir, 'servers.dat');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Liest die Serverliste.
|
||||||
|
* -> { ok, server: [{ name, ip, icon, acceptTextures }] }
|
||||||
|
*/
|
||||||
|
function lies(gameDir) {
|
||||||
|
const p = datei(gameDir);
|
||||||
|
if (!fs.existsSync(p)) return { ok: true, server: [], leer: true };
|
||||||
|
let roh;
|
||||||
|
try { roh = fs.readFileSync(p); } catch (err) { return { ok: false, message: err.message }; }
|
||||||
|
if (!roh.length) return { ok: true, server: [], leer: true };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { wert } = nbt.lese(roh);
|
||||||
|
const liste = Array.isArray(wert.servers) ? wert.servers : [];
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
server: liste.map((s) => ({
|
||||||
|
name: String(s.name || ''),
|
||||||
|
ip: String(s.ip || ''),
|
||||||
|
icon: typeof s.icon === 'string' ? s.icon : '',
|
||||||
|
acceptTextures: Number(s.acceptTextures || 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: 'servers.dat konnte nicht gelesen werden: ' + err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Schreibt die Serverliste zurück.
|
||||||
|
* Die alte Datei wird vorher als servers.dat.bak gesichert.
|
||||||
|
*/
|
||||||
|
function schreibe(gameDir, server) {
|
||||||
|
const p = datei(gameDir);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(gameDir, { recursive: true });
|
||||||
|
|
||||||
|
const eintraege = (server || []).map((s) => {
|
||||||
|
const c = {
|
||||||
|
__typen: { name: nbt.TAG.STRING, ip: nbt.TAG.STRING },
|
||||||
|
name: String(s.name || ''),
|
||||||
|
ip: String(s.ip || ''),
|
||||||
|
};
|
||||||
|
// icon und acceptTextures nur mitschreiben, wenn vorhanden –
|
||||||
|
// so wie Minecraft es selbst tut
|
||||||
|
if (s.icon) { c.__typen.icon = nbt.TAG.STRING; c.icon = String(s.icon); }
|
||||||
|
if (s.acceptTextures) { c.__typen.acceptTextures = nbt.TAG.BYTE; c.acceptTextures = Number(s.acceptTextures); }
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
const liste = eintraege;
|
||||||
|
liste.__typ = nbt.TAG.COMPOUND;
|
||||||
|
const wurzel = { __typen: { servers: nbt.TAG.LIST }, servers: liste };
|
||||||
|
const daten = nbt.schreibe('', wurzel);
|
||||||
|
|
||||||
|
// erst sichern, dann über eine Zwischendatei ersetzen
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
try { fs.copyFileSync(p, p + '.bak'); } catch { /* Sicherung ist Kür */ }
|
||||||
|
}
|
||||||
|
const tmp = p + '.neu';
|
||||||
|
fs.writeFileSync(tmp, daten);
|
||||||
|
fs.renameSync(tmp, p);
|
||||||
|
return { ok: true, anzahl: eintraege.length };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: 'servers.dat konnte nicht geschrieben werden: ' + err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adresse normalisieren und grob prüfen
|
||||||
|
function pruefeAdresse(ip) {
|
||||||
|
const s = String(ip || '').trim();
|
||||||
|
if (!s) return { ok: false, message: 'Bitte eine Serveradresse angeben.' };
|
||||||
|
if (/\s/.test(s)) return { ok: false, message: 'Die Adresse darf keine Leerzeichen enthalten.' };
|
||||||
|
const m = s.match(/^([^:]+)(?::(\d+))?$/);
|
||||||
|
if (!m) return { ok: false, message: 'Die Adresse sieht nicht wie ein Server aus.' };
|
||||||
|
if (m[2] && (Number(m[2]) < 1 || Number(m[2]) > 65535)) {
|
||||||
|
return { ok: false, message: 'Der Port muss zwischen 1 und 65535 liegen.' };
|
||||||
|
}
|
||||||
|
return { ok: true, ip: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuegeHinzu(gameDir, eintrag) {
|
||||||
|
const geprueft = pruefeAdresse(eintrag && eintrag.ip);
|
||||||
|
if (!geprueft.ok) return geprueft;
|
||||||
|
|
||||||
|
const gelesen = lies(gameDir);
|
||||||
|
if (!gelesen.ok) return gelesen;
|
||||||
|
const liste = gelesen.server;
|
||||||
|
// dieselbe Adresse nicht doppelt aufnehmen
|
||||||
|
if (liste.some((s) => s.ip.toLowerCase() === geprueft.ip.toLowerCase())) {
|
||||||
|
return { ok: false, message: 'Dieser Server steht bereits in der Liste.' };
|
||||||
|
}
|
||||||
|
liste.push({
|
||||||
|
name: String((eintrag && eintrag.name) || geprueft.ip).trim() || geprueft.ip,
|
||||||
|
ip: geprueft.ip,
|
||||||
|
icon: '',
|
||||||
|
acceptTextures: 0,
|
||||||
|
});
|
||||||
|
return schreibe(gameDir, liste);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entferne(gameDir, ip) {
|
||||||
|
const gelesen = lies(gameDir);
|
||||||
|
if (!gelesen.ok) return gelesen;
|
||||||
|
const vorher = gelesen.server.length;
|
||||||
|
const liste = gelesen.server.filter((s) => s.ip.toLowerCase() !== String(ip || '').toLowerCase());
|
||||||
|
if (liste.length === vorher) return { ok: false, message: 'Server nicht gefunden.' };
|
||||||
|
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:
|
||||||
|
* denselben Server nicht in jeder Instanz neu eintippen zu müssen.
|
||||||
|
*/
|
||||||
|
function uebertrage(zielGameDir, eintrag) {
|
||||||
|
return fuegeHinzu(zielGameDir, eintrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { lies, schreibe, fuegeHinzu, entferne, benenneUm, uebertrage, pruefeAdresse, datei };
|
||||||
549
src/services.js
549
src/services.js
@@ -19,10 +19,50 @@ const { execFileSync, spawnSync } = require('child_process');
|
|||||||
|
|
||||||
const UA = 'AeroMC-Launcher/0.2 (Minecraft-Launcher; contact: viper)';
|
const UA = 'AeroMC-Launcher/0.2 (Minecraft-Launcher; contact: viper)';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Arbeitsspeicher
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prüft die Speicherangaben einer Instanz gegen den vorhandenen Arbeitsspeicher.
|
||||||
|
* Ohne diese Prüfung startet Java mit einem kryptischen Fehler, den niemand
|
||||||
|
* mit "zu viel RAM eingetragen" in Verbindung bringt.
|
||||||
|
*
|
||||||
|
* -> { ok, message } bei Fehlern, { ok: true, warnung } bei knappen Angaben
|
||||||
|
*/
|
||||||
|
function pruefeSpeicher(minMb, maxMb, gesamtMb) {
|
||||||
|
const min = Number(minMb) || 0;
|
||||||
|
const max = Number(maxMb) || 0;
|
||||||
|
const gesamt = Number(gesamtMb) || 0;
|
||||||
|
|
||||||
|
if (min && max && min > max) {
|
||||||
|
return { ok: false, message: `Der Mindestspeicher (${min} MB) ist größer als das Maximum (${max} MB).` };
|
||||||
|
}
|
||||||
|
if (max && max < 512) {
|
||||||
|
return { ok: false, message: `${max} MB sind zu wenig – Minecraft braucht mindestens 512 MB.` };
|
||||||
|
}
|
||||||
|
if (gesamt && max && max > gesamt) {
|
||||||
|
return { ok: false, message: `${max} MB übersteigen den vorhandenen Arbeitsspeicher (${gesamt} MB). Minecraft würde nicht starten.` };
|
||||||
|
}
|
||||||
|
// Dem System muss etwas bleiben: Windows und der Launcher selbst brauchen auch Platz
|
||||||
|
if (gesamt && max && max > gesamt * 0.8) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
warnung: `${max} MB sind mehr als 80 % des Arbeitsspeichers (${gesamt} MB). `
|
||||||
|
+ `Für Windows und den Launcher sollte etwas übrig bleiben – üblich sind höchstens ${Math.floor(gesamt * 0.7 / 256) * 256} MB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Java-Erkennung
|
// Java-Erkennung
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// vom Launcher selbst heruntergeladene Laufzeiten (wird beim Start gesetzt)
|
||||||
|
let managedJavaDir = null;
|
||||||
|
function setManagedJavaDir(dir) { managedJavaDir = dir; }
|
||||||
|
|
||||||
function javaCandidateDirs() {
|
function javaCandidateDirs() {
|
||||||
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
|
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
|
||||||
const pfx = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
const pfx = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
||||||
@@ -37,6 +77,8 @@ function javaCandidateDirs() {
|
|||||||
// Runtimes der offiziellen Minecraft-Launcher
|
// Runtimes der offiziellen Minecraft-Launcher
|
||||||
if (appdata) roots.push(path.join(appdata, '.minecraft', 'runtime'));
|
if (appdata) roots.push(path.join(appdata, '.minecraft', 'runtime'));
|
||||||
if (localappdata) roots.push(path.join(localappdata, 'Packages'));
|
if (localappdata) roots.push(path.join(localappdata, 'Packages'));
|
||||||
|
// eigene, vom Launcher geholte Laufzeiten
|
||||||
|
if (managedJavaDir) roots.push(managedJavaDir);
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,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);
|
||||||
@@ -209,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();
|
||||||
@@ -238,8 +307,41 @@ async function modrinthResolveFile({ projectId, gameVersion, loader }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(url, destPath) {
|
/*
|
||||||
|
* 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 } });
|
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
|
||||||
|
async function downloadFile(url, destPath, extraHeaders) {
|
||||||
|
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||||
@@ -248,11 +350,14 @@ async function downloadFile(url, destPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -273,6 +378,426 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prüft die installierten Mods auf neuere Dateien.
|
||||||
|
* Von Hand abgelegte .jar-Dateien (ohne projectId) bleiben unangetastet –
|
||||||
|
* für die weiß niemand, woher sie stammen.
|
||||||
|
* -> [{ projectId, title, alt: {...}, neu: {...} }]
|
||||||
|
*/
|
||||||
|
async function modrinthFindUpdates(mods, gameVersion, loader, onProgress, art = 'mod') {
|
||||||
|
const kandidaten = (mods || []).filter((m) => m && m.projectId);
|
||||||
|
const treffer = [];
|
||||||
|
let geprueft = 0;
|
||||||
|
|
||||||
|
// in kleinen Gruppen abfragen: schneller als nacheinander, ohne die API zu fluten
|
||||||
|
const GRUPPE = 4;
|
||||||
|
for (let i = 0; i < kandidaten.length; i += GRUPPE) {
|
||||||
|
await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => {
|
||||||
|
let file = null;
|
||||||
|
try {
|
||||||
|
file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader, art });
|
||||||
|
} catch { /* Netzfehler -> diesen Eintrag überspringen */ }
|
||||||
|
geprueft++;
|
||||||
|
if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId });
|
||||||
|
if (!file || !file.versionId) return;
|
||||||
|
// schon aktuell?
|
||||||
|
if (m.versionId && file.versionId === m.versionId) return;
|
||||||
|
if (file.filename === m.filename) return;
|
||||||
|
treffer.push({
|
||||||
|
projectId: m.projectId,
|
||||||
|
title: m.title || m.projectId,
|
||||||
|
alt: { filename: m.filename, versionNumber: m.versionNumber || '?' },
|
||||||
|
neu: { filename: file.filename, versionNumber: file.versionNumber, versionId: file.versionId, size: file.size },
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
treffer.sort((a, b) => String(a.title).localeCompare(String(b.title)));
|
||||||
|
return { geprueft: kandidaten.length, uebersprungen: (mods || []).length - kandidaten.length, treffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CurseForge (Modpack-Dateien auflösen)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CURSEFORGE = 'https://api.curseforge.com/v1';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Vorgaben, die beim Bauen gesetzt wurden (scripts/write-build-config.js).
|
||||||
|
* Fehlt die Datei – etwa beim Start aus dem Quelltext –, gilt: keine Vorgabe.
|
||||||
|
*/
|
||||||
|
let BUILD_VORGABEN = {};
|
||||||
|
try { BUILD_VORGABEN = require('./build-config.json'); } catch { /* nicht erzeugt */ }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Adresse der eigenen CurseForge-Weiterleitung. Die darf fest hier stehen –
|
||||||
|
* sie ist kein Geheimnis, der API-Schlüssel liegt auf dem Server.
|
||||||
|
* Über AEROMC_CF_PROXY_URL beim Bauen überschreibbar.
|
||||||
|
*/
|
||||||
|
const STANDARD_WEITERLEITUNG = String(BUILD_VORGABEN.curseforgeProxyUrl || 'https://curse.viper.ipv64.net');
|
||||||
|
|
||||||
|
// Das Token dagegen kommt ausschließlich aus dem Build (AEROMC_CF_PROXY_TOKEN)
|
||||||
|
// und steht bewusst nicht im Quelltext.
|
||||||
|
const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || '');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Entscheidet, wie CurseForge angesprochen wird.
|
||||||
|
* Eigener Schlüssel hat Vorrang: wer einen hat, geht direkt und belastet
|
||||||
|
* kein fremdes Kontingent.
|
||||||
|
* -> { 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) {
|
||||||
|
const s = settings || {};
|
||||||
|
const auth = cfAuthHeaders(s.curseforgeApiKey);
|
||||||
|
if (auth) {
|
||||||
|
return {
|
||||||
|
modus: 'direkt',
|
||||||
|
basis: CURSEFORGE,
|
||||||
|
kopfzeilen: auth.headers,
|
||||||
|
downloadAdresse: (url) => url,
|
||||||
|
downloadKopfzeilen: auth.headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
|
||||||
|
const weiter = eigeneAdresse || STANDARD_WEITERLEITUNG;
|
||||||
|
if (weiter) {
|
||||||
|
// Zur eigenen Adresse gehört auch das eigene Token – sonst schickte der
|
||||||
|
// Launcher das Token unseres Servers an einen fremden.
|
||||||
|
const token = eigeneAdresse
|
||||||
|
? String(s.curseforgeProxyToken || '').trim()
|
||||||
|
: (String(s.curseforgeProxyToken || '').trim() || STANDARD_TOKEN);
|
||||||
|
const kopf = token ? { 'x-aeromc-token': token } : {};
|
||||||
|
return {
|
||||||
|
modus: 'weiterleitung',
|
||||||
|
basis: weiter + '/v1',
|
||||||
|
kopfzeilen: kopf,
|
||||||
|
downloadAdresse: (url) => `${weiter}/v1/download?url=${encodeURIComponent(url)}`,
|
||||||
|
downloadKopfzeilen: kopf,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ausweichadresse auf dem CurseForge-CDN. Manche Autoren untersagen den
|
||||||
|
* Download über die API; dann liefert downloadUrl null, die Datei liegt aber
|
||||||
|
* unter diesem berechenbaren Pfad.
|
||||||
|
*/
|
||||||
|
function forgeCdnUrl(fileId, fileName) {
|
||||||
|
const s = String(fileId || '');
|
||||||
|
if (s.length < 5 || !fileName) return null;
|
||||||
|
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.
|
||||||
|
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
|
||||||
|
* -> Map fileID -> { fileName, url, kopfzeilen }
|
||||||
|
*/
|
||||||
|
async function curseforgeResolveFiles(zugang, files, onProgress) {
|
||||||
|
if (!zugang) throw new Error('Kein Zugang zu CurseForge eingerichtet.');
|
||||||
|
const ids = (files || []).map((f) => Number(f.fileID)).filter(Boolean);
|
||||||
|
const map = new Map();
|
||||||
|
const PAKET = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < ids.length; i += PAKET) {
|
||||||
|
const teil = ids.slice(i, i + PAKET);
|
||||||
|
const res = await fetch(zugang.basis + '/mods/files', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen),
|
||||||
|
body: JSON.stringify({ fileIds: teil }),
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
throw new Error(zugang.modus === 'weiterleitung'
|
||||||
|
? 'Der CurseForge-Server weist die Anfrage zurück (HTTP ' + res.status + ').'
|
||||||
|
: 'CurseForge weist den API-Key zurück (HTTP ' + res.status + ').');
|
||||||
|
}
|
||||||
|
if (res.status === 429) throw new Error('Zu viele Anfragen – bitte einen Moment warten.');
|
||||||
|
if (!res.ok) throw new Error('CurseForge-Abfrage fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
const daten = await res.json();
|
||||||
|
for (const d of (daten && daten.data) || []) {
|
||||||
|
const roh = d.downloadUrl || forgeCdnUrl(d.id, d.fileName);
|
||||||
|
if (!d.fileName || !roh) continue;
|
||||||
|
map.set(Number(d.id), {
|
||||||
|
fileName: d.fileName,
|
||||||
|
url: zugang.downloadAdresse(roh),
|
||||||
|
kopfzeilen: zugang.downloadKopfzeilen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress({ aufgeloest: Math.min(i + PAKET, ids.length), gesamt: ids.length });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
// Projekttitel nachschlagen (für Abhängigkeiten, die nur eine ID liefern)
|
// Projekttitel nachschlagen (für Abhängigkeiten, die nur eine ID liefern)
|
||||||
async function modrinthProjectTitle(projectId) {
|
async function modrinthProjectTitle(projectId) {
|
||||||
try {
|
try {
|
||||||
@@ -284,7 +809,9 @@ async function modrinthProjectTitle(projectId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
scanJava, probeJava, setCacheDir,
|
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
|
||||||
getMcVersions,
|
getMcVersions,
|
||||||
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle, downloadFile,
|
modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
|
||||||
|
modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
|
||||||
|
curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
|
||||||
};
|
};
|
||||||
|
|||||||
121
src/skins.js
Normal file
121
src/skins.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* skins.js – Skin und Umhang über die Minecraft-Dienste ändern
|
||||||
|
* -------------------------------------------------------------
|
||||||
|
* Der Launcher hält bereits gültige Zugangsdaten des angemeldeten Kontos.
|
||||||
|
* Damit lässt sich der Skin direkt setzen, ohne Umweg über den Browser.
|
||||||
|
*
|
||||||
|
* Verwendete Wege (api.minecraftservices.com):
|
||||||
|
* POST /minecraft/profile/skins Skin hochladen (Datei oder Adresse)
|
||||||
|
* DELETE /minecraft/profile/skins/active Skin zurücksetzen (Standard-Steve/Alex)
|
||||||
|
* GET /minecraft/profile aktuelles Profil samt Skin-Adresse
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const API = 'https://api.minecraftservices.com';
|
||||||
|
const UA = 'AeroMC-Launcher';
|
||||||
|
|
||||||
|
// Minecraft nimmt nur PNG, und die Vorlagen sind 64x64 bzw. 64x32
|
||||||
|
const MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
function pruefeDatei(dateiPfad) {
|
||||||
|
if (!dateiPfad || !fs.existsSync(dateiPfad)) return { ok: false, message: 'Datei nicht gefunden.' };
|
||||||
|
if (!/\.png$/i.test(dateiPfad)) return { ok: false, message: 'Minecraft nimmt nur PNG-Dateien.' };
|
||||||
|
const st = fs.statSync(dateiPfad);
|
||||||
|
if (!st.isFile()) return { ok: false, message: 'Das ist keine Datei.' };
|
||||||
|
if (st.size > MAX_BYTES) return { ok: false, message: 'Die Datei ist zu groß (über 512 KB).' };
|
||||||
|
|
||||||
|
// PNG-Kennung und Maße aus dem Kopf lesen, bevor wir sie hochschicken
|
||||||
|
const kopf = Buffer.alloc(33);
|
||||||
|
const fd = fs.openSync(dateiPfad, 'r');
|
||||||
|
try { fs.readSync(fd, kopf, 0, 33, 0); } finally { fs.closeSync(fd); }
|
||||||
|
|
||||||
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
if (!kopf.subarray(0, 8).equals(PNG)) return { ok: false, message: 'Die Datei ist kein gültiges PNG.' };
|
||||||
|
|
||||||
|
const breite = kopf.readUInt32BE(16);
|
||||||
|
const hoehe = kopf.readUInt32BE(20);
|
||||||
|
// 64x64 ist heute üblich, 64x32 sind die alten Skins
|
||||||
|
if (!(breite === 64 && (hoehe === 64 || hoehe === 32))) {
|
||||||
|
return { ok: false, message: `Ein Skin muss 64×64 (oder 64×32) Pixel groß sein – diese Datei ist ${breite}×${hoehe}.` };
|
||||||
|
}
|
||||||
|
return { ok: true, breite, hoehe };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fehlerText(status) {
|
||||||
|
if (status === 401) return 'Die Anmeldung ist abgelaufen. Bitte das Konto neu verbinden.';
|
||||||
|
if (status === 403) return 'Minecraft lehnt die Änderung ab. Hat das Konto Minecraft Java Edition?';
|
||||||
|
if (status === 429) return 'Zu viele Änderungen in kurzer Zeit. Bitte einen Moment warten.';
|
||||||
|
return 'Minecraft-Dienste antworten mit HTTP ' + status + '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Skin setzen.
|
||||||
|
* variante: 'classic' (breite Arme) oder 'slim' (schmale Arme, Alex)
|
||||||
|
*/
|
||||||
|
async function setzeSkin(accessToken, dateiPfad, variante) {
|
||||||
|
const geprueft = pruefeDatei(dateiPfad);
|
||||||
|
if (!geprueft.ok) return geprueft;
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('variant', variante === 'slim' ? 'slim' : 'classic');
|
||||||
|
const daten = fs.readFileSync(dateiPfad);
|
||||||
|
form.append('file', new Blob([daten], { type: 'image/png' }), path.basename(dateiPfad));
|
||||||
|
|
||||||
|
const res = await fetch(API + '/minecraft/profile/skins', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||||
|
|
||||||
|
let profil = null;
|
||||||
|
try { profil = await res.json(); } catch { /* Antwort ohne Rumpf ist auch in Ordnung */ }
|
||||||
|
return { ok: true, variante: variante === 'slim' ? 'slim' : 'classic', profil };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skin auf den Standard zurücksetzen
|
||||||
|
async function setzeSkinZurueck(accessToken) {
|
||||||
|
const res = await fetch(API + '/minecraft/profile/skins/active', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
|
||||||
|
});
|
||||||
|
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Aktuelles Profil abrufen – liefert die Adresse des aktiven Skins,
|
||||||
|
* damit die Oberfläche vorher/nachher zeigen kann.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Mojang liefert Skin-Adressen bis heute als "http://textures.minecraft.net/…".
|
||||||
|
* Die Oberfläche lässt nach ihrer Sicherheitsrichtlinie nur https zu, das Bild
|
||||||
|
* würde also blockiert. textures.minecraft.net beantwortet dieselbe Adresse
|
||||||
|
* auch über https – daher heben wir sie an.
|
||||||
|
*/
|
||||||
|
function aufHttps(url) {
|
||||||
|
const s = String(url || '');
|
||||||
|
return s.startsWith('http://') ? 'https://' + s.slice('http://'.length) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function holeProfil(accessToken) {
|
||||||
|
const res = await fetch(API + '/minecraft/profile', {
|
||||||
|
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||||
|
const daten = await res.json();
|
||||||
|
const aktiv = (daten.skins || []).find((s) => s.state === 'ACTIVE') || null;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
name: daten.name,
|
||||||
|
id: daten.id,
|
||||||
|
skinUrl: aktiv ? aufHttps(aktiv.url) : '',
|
||||||
|
variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps };
|
||||||
205
src/store.js
205
src/store.js
@@ -50,9 +50,18 @@ function newId() {
|
|||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
instancesDir: null, // null => Standard (userData/instances)
|
instancesDir: null, // null => Standard (userData/instances)
|
||||||
javaPath: '', // leer => "java" aus PATH
|
javaPath: '', // leer => "java" aus PATH
|
||||||
|
autoJava: true, // fehlende Java-Version bei Bedarf selbst herunterladen
|
||||||
|
autoBackupEnabled: false, // Sicherungen nach Zeitplan – standardmäßig aus
|
||||||
|
autoBackupEvery: 'daily', // 'daily' | 'weekly'
|
||||||
|
autoBackupBeforeLaunch: false, // zusätzlich vor jedem Spielstart
|
||||||
|
autoBackupKeep: 5, // wie viele automatische Sicherungen behalten werden
|
||||||
|
autoBackupLast: '', // Zeitpunkt der letzten automatischen Sicherung
|
||||||
defaultMinMemMb: 2048,
|
defaultMinMemMb: 2048,
|
||||||
defaultMaxMemMb: 4096,
|
defaultMaxMemMb: 4096,
|
||||||
azureClientId: '', // Azure-App-ID für den Microsoft-Login (Phase 3)
|
azureClientId: '', // Azure-App-ID für den Microsoft-Login (Phase 3)
|
||||||
|
curseforgeApiKey: '', // eigener CurseForge-API-Key (hat Vorrang)
|
||||||
|
curseforgeProxyUrl: '', // sonst: Adresse der eigenen CurseForge-Weiterleitung
|
||||||
|
curseforgeProxyToken: '', // optionales Geheimnis für diese Weiterleitung
|
||||||
globalPreLaunch: '',
|
globalPreLaunch: '',
|
||||||
globalPostExit: '',
|
globalPostExit: '',
|
||||||
lastSelectedId: '', // zuletzt ausgewählte Instanz (beim Start wieder markieren)
|
lastSelectedId: '', // zuletzt ausgewählte Instanz (beim Start wieder markieren)
|
||||||
@@ -66,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: '',
|
||||||
@@ -115,33 +127,140 @@ 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) ----------
|
||||||
|
|
||||||
function listMods(id) {
|
/*
|
||||||
return readJson(modsIndexFile(id), []);
|
* Abgeschaltete Mods heißen "<name>.jar.disabled" – so übergeht Minecraft sie,
|
||||||
|
* ohne dass die Datei verloren geht. In mods.json steht immer der eingeschaltete
|
||||||
|
* Name; ob eine Mod an ist, entscheidet allein die Datei auf der Platte.
|
||||||
|
*/
|
||||||
|
const MOD_ENDUNG = /\.(jar|zip)$/i;
|
||||||
|
const AUS_ENDUNG = /\.(jar|zip)\.disabled$/i;
|
||||||
|
|
||||||
|
function scanModsDir(id, art = 'mod') {
|
||||||
|
const dir = modsDir(id, art);
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
const gefunden = [];
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const aus = AUS_ENDUNG.test(entry.name);
|
||||||
|
if (!aus && !MOD_ENDUNG.test(entry.name)) continue;
|
||||||
|
const filename = aus ? entry.name.replace(/\.disabled$/i, '') : entry.name;
|
||||||
|
gefunden.push({
|
||||||
|
projectId: null,
|
||||||
|
title: filename.replace(MOD_ENDUNG, ''),
|
||||||
|
filename,
|
||||||
|
versionNumber: 'Importiert',
|
||||||
|
enabled: !aus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
gefunden.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||||
|
return gefunden;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMod(id, entry) {
|
function listMods(id, art = 'mod') {
|
||||||
const list = listMods(id).filter((m) => m.filename !== entry.filename);
|
const aufDerPlatte = new Map(scanModsDir(id, art).map((m) => [m.filename, m]));
|
||||||
|
const indexed = readJson(modsIndexFile(id, art), []);
|
||||||
|
const merged = new Map();
|
||||||
|
|
||||||
|
for (const entry of indexed) {
|
||||||
|
if (!entry || !entry.filename) continue;
|
||||||
|
const platte = aufDerPlatte.get(entry.filename);
|
||||||
|
merged.set(entry.filename, Object.assign({}, entry, {
|
||||||
|
enabled: platte ? platte.enabled : true,
|
||||||
|
fehlt: !platte, // steht im Verzeichnis, Datei ist aber weg
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (const [name, entry] of aufDerPlatte) {
|
||||||
|
if (!merged.has(name)) merged.set(name, Object.assign({ fehlt: false }, entry));
|
||||||
|
}
|
||||||
|
return [...merged.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Schaltet eine Mod an oder aus, indem die Datei umbenannt wird.
|
||||||
|
* -> { ok, enabled } oder { ok: false, reason }
|
||||||
|
*/
|
||||||
|
function setModEnabled(id, filename, enabled, art = 'mod') {
|
||||||
|
const dir = modsDir(id, art);
|
||||||
|
const anPfad = path.join(dir, filename);
|
||||||
|
const ausPfad = anPfad + '.disabled';
|
||||||
|
// Pfad darf den mods-Ordner nicht verlassen
|
||||||
|
if (path.dirname(anPfad) !== dir) return { ok: false, reason: 'bad-name' };
|
||||||
|
|
||||||
|
const vonPfad = enabled ? ausPfad : anPfad;
|
||||||
|
const nachPfad = enabled ? anPfad : ausPfad;
|
||||||
|
|
||||||
|
if (fs.existsSync(anPfad) && fs.existsSync(ausPfad)) {
|
||||||
|
// beide Fassungen da – nichts anfassen, sonst geht eine verloren
|
||||||
|
return { ok: false, reason: 'both-exist' };
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(vonPfad)) {
|
||||||
|
if (fs.existsSync(nachPfad)) return { ok: true, enabled, unveraendert: true };
|
||||||
|
return { ok: false, reason: 'not-found' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.renameSync(vonPfad, nachPfad);
|
||||||
|
return { ok: true, enabled };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMod(id, entry, art = 'mod') {
|
||||||
|
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
|
||||||
// zugehörige .jar löschen
|
* AeroMC gesetzt – von Hand hinzugefügte oder vor diesem Feature installierte
|
||||||
const jar = path.join(modsDir(id), filename);
|
* Mods haben keine requiredDeps und werden daher nicht als Ziel erkannt,
|
||||||
try { if (fs.existsSync(jar)) fs.unlinkSync(jar); } catch { /* egal */ }
|
* 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
|
||||||
|
const jar = path.join(modsDir(id, art), filename);
|
||||||
|
for (const p of [jar, jar + '.disabled']) {
|
||||||
|
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* egal */ }
|
||||||
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +349,16 @@ function getInstance(id) {
|
|||||||
return readJson(instanceFile(id), null);
|
return readJson(instanceFile(id), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInstanceName(name) {
|
||||||
|
return String(name || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInstanceByName(name) {
|
||||||
|
const needle = normalizeInstanceName(name);
|
||||||
|
if (!needle) return null;
|
||||||
|
return listInstances().find((inst) => normalizeInstanceName(inst.name) === needle) || null;
|
||||||
|
}
|
||||||
|
|
||||||
// Ordnername aus dem Instanznamen ableiten (wie MultiMC), ungültige Zeichen raus
|
// Ordnername aus dem Instanznamen ableiten (wie MultiMC), ungültige Zeichen raus
|
||||||
function slugForName(name) {
|
function slugForName(name) {
|
||||||
const base = String(name || 'Instanz').trim()
|
const base = String(name || 'Instanz').trim()
|
||||||
@@ -248,20 +377,43 @@ function uniqueFolderId(name) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInstance(data) {
|
function applyInstanceData(inst, data) {
|
||||||
const inst = defaultInstance(data && data.name);
|
if (!data) return inst;
|
||||||
inst.id = uniqueFolderId(inst.name); // Ordner = Instanzname statt Zufalls-ID
|
if (data.name) inst.name = data.name;
|
||||||
if (data) {
|
if (data.icon) inst.icon = data.icon;
|
||||||
if (data.icon) inst.icon = data.icon;
|
if (data.minecraft) Object.assign(inst.minecraft, data.minecraft);
|
||||||
if (data.minecraft) Object.assign(inst.minecraft, data.minecraft);
|
if (data.notes !== undefined) inst.notes = data.notes;
|
||||||
if (data.notes) inst.notes = data.notes;
|
if (data.group !== undefined) inst.group = data.group;
|
||||||
if (data.group !== undefined) inst.group = data.group;
|
return inst;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeInstance(inst) {
|
||||||
ensureDir(gameDir(inst.id));
|
ensureDir(gameDir(inst.id));
|
||||||
writeJson(instanceFile(inst.id), inst);
|
writeJson(instanceFile(inst.id), inst);
|
||||||
return inst;
|
return inst;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createInstance(data) {
|
||||||
|
const inst = applyInstanceData(defaultInstance(data && data.name), data);
|
||||||
|
inst.id = uniqueFolderId(inst.name); // Ordner = Instanzname statt Zufalls-ID
|
||||||
|
return writeInstance(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrReplaceImportedInstance(data) {
|
||||||
|
const existing = findInstanceByName(data && data.name);
|
||||||
|
if (!existing) return createInstance(data);
|
||||||
|
|
||||||
|
const inst = applyInstanceData(defaultInstance(data && data.name), data);
|
||||||
|
inst.id = existing.id;
|
||||||
|
inst.created = existing.created || inst.created;
|
||||||
|
inst.lastPlayed = existing.lastPlayed || null;
|
||||||
|
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
|
||||||
|
|
||||||
|
const p = instancePath(existing.id);
|
||||||
|
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||||
|
return writeInstance(inst);
|
||||||
|
}
|
||||||
|
|
||||||
function updateInstance(id, patch) {
|
function updateInstance(id, patch) {
|
||||||
const inst = getInstance(id);
|
const inst = getInstance(id);
|
||||||
if (!inst) throw new Error('Instanz nicht gefunden: ' + id);
|
if (!inst) throw new Error('Instanz nicht gefunden: ' + id);
|
||||||
@@ -305,15 +457,20 @@ module.exports = {
|
|||||||
instancePath,
|
instancePath,
|
||||||
gameDir,
|
gameDir,
|
||||||
modsDir,
|
modsDir,
|
||||||
|
inhaltsDir,
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
setCustomImage,
|
setCustomImage,
|
||||||
clearCustomImage,
|
clearCustomImage,
|
||||||
listMods,
|
listMods,
|
||||||
addMod,
|
addMod,
|
||||||
removeMod,
|
removeMod,
|
||||||
|
findDependents,
|
||||||
|
setModEnabled,
|
||||||
listInstances,
|
listInstances,
|
||||||
getInstance,
|
getInstance,
|
||||||
|
findInstanceByName,
|
||||||
createInstance,
|
createInstance,
|
||||||
|
createOrReplaceImportedInstance,
|
||||||
updateInstance,
|
updateInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
duplicateInstance,
|
duplicateInstance,
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -80,7 +82,6 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
/* farbige Toolbar-Icons wie in MultiMC */
|
/* farbige Toolbar-Icons wie in MultiMC */
|
||||||
#btn-new svg{color:#3a9c3a}
|
#btn-new svg{color:#3a9c3a}
|
||||||
#btn-import svg{color:#3d7ab5}
|
#btn-import svg{color:#3d7ab5}
|
||||||
#btn-modpack svg{color:#8e5bc7}
|
|
||||||
#btn-open-dir svg{color:#d8a72f}
|
#btn-open-dir svg{color:#d8a72f}
|
||||||
#btn-console svg{color:#5a6470}
|
#btn-console svg{color:#5a6470}
|
||||||
#btn-account svg{color:#3a9c3a}
|
#btn-account svg{color:#3a9c3a}
|
||||||
@@ -112,6 +113,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
.hr{height:1px;background:var(--border);margin:6px 2px}
|
.hr{height:1px;background:var(--border);margin:6px 2px}
|
||||||
.sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)}
|
.sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)}
|
||||||
.sbtn:hover{background:#e2e6ec;border-color:var(--border2)}
|
.sbtn:hover{background:#e2e6ec;border-color:var(--border2)}
|
||||||
|
.sbtn:disabled{opacity:.5;cursor:default}
|
||||||
|
.sbtn:disabled:hover{background:none;border-color:transparent}
|
||||||
.sbtn.play{font-weight:600}
|
.sbtn.play{font-weight:600}
|
||||||
.sbtn.del:hover{color:var(--danger)}
|
.sbtn.del:hover{color:var(--danger)}
|
||||||
|
|
||||||
@@ -138,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}
|
||||||
@@ -159,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)}
|
||||||
@@ -175,8 +191,10 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
.field input,.field select,.field textarea{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
|
.field input,.field select,.field textarea{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
|
||||||
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)}
|
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)}
|
||||||
.field textarea{resize:vertical}
|
.field textarea{resize:vertical}
|
||||||
.field-row{display:flex;gap:12px}.field-row .field{flex:1}
|
.field-row{display:flex;gap:12px;flex-wrap:wrap}.field-row .field{flex:1 1 0;min-width:0}
|
||||||
.field-icon{max-width:210px;min-width:0}
|
.field-icon{max-width:210px;min-width:0}
|
||||||
|
.backup-select-field{margin-top:14px;margin-bottom:0}
|
||||||
|
.backup-select-field select{width:100%}
|
||||||
.hint{font-size:12px}
|
.hint{font-size:12px}
|
||||||
.path-row{display:flex;gap:8px}.path-row input{flex:1}
|
.path-row{display:flex;gap:8px}.path-row input{flex:1}
|
||||||
.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
|
.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
|
||||||
@@ -221,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)}
|
||||||
@@ -245,8 +271,32 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
|||||||
.mod-name{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.mod-name{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.mod-desc{font-size:12px;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
.mod-desc{font-size:12px;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||||
.mod-meta{font-size:11px;color:var(--dim);margin-top:2px}
|
.mod-meta{font-size:11px;color:var(--dim);margin-top:2px}
|
||||||
|
.mod-new{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#2f7d32;color:#fff;vertical-align:middle}
|
||||||
|
.mod-off{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#6b6b6b;color:#fff;vertical-align:middle}
|
||||||
|
.crash-kern{margin:10px 0;padding:10px 12px;border-left:3px solid #c0392b;background:rgba(192,57,43,.08);border-radius:0 4px 4px 0}
|
||||||
|
.crash-zeile{display:flex;gap:8px;margin-bottom:5px;font-size:12px;line-height:1.45}
|
||||||
|
.crash-zeile:last-child{margin-bottom:0}
|
||||||
|
.crash-titel{flex:0 0 132px;color:var(--dim)}
|
||||||
|
.crash-wert{flex:1;word-break:break-word}
|
||||||
|
.crash-verlauf{margin:0;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--dim);white-space:pre-wrap;word-break:break-all}
|
||||||
|
.skin-vorschau{display:flex;gap:14px;margin-top:12px}
|
||||||
|
.skin-spalte{flex:1;display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||||
|
.skin-label{font-size:11px;color:var(--dim)}
|
||||||
|
.skin-bild{height:300px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,.15);padding:6px;text-align:center;overflow:hidden}
|
||||||
|
/* Die gerenderte Figur soll das Feld ausfüllen; eine rohe 64x64-Textur wird
|
||||||
|
ohne Weichzeichnen vergrößert, damit sie nicht verschwommen wirkt. */
|
||||||
|
.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
|
||||||
|
.crash-details{margin-top:12px}
|
||||||
|
.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
|
||||||
|
.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
|
||||||
|
.mod-aus .mod-name,.mod-aus .mod-meta{opacity:.55}
|
||||||
|
.mod-aus .mod-icon{filter:grayscale(1);opacity:.6}
|
||||||
.mod-action{flex:none}
|
.mod-action{flex:none}
|
||||||
|
|
||||||
|
/* Import */
|
||||||
|
.import-pane{min-height:180px}
|
||||||
|
.import-copy{margin:0 0 10px;font-size:13px;line-height:1.5}
|
||||||
|
|
||||||
/* MultiMC-Import */
|
/* MultiMC-Import */
|
||||||
.mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
|
.mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
|
||||||
.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px}
|
.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px}
|
||||||
@@ -284,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) */
|
||||||
@@ -319,6 +373,9 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
|||||||
.appinfo-label{display:block;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
|
.appinfo-label{display:block;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
|
||||||
.appinfo-value{display:block;margin-top:4px;font-weight:600;word-break:break-word}
|
.appinfo-value{display:block;margin-top:4px;font-weight:600;word-break:break-word}
|
||||||
.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-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}
|
||||||
@@ -367,10 +424,21 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
|||||||
.upd-old{color:var(--dim)}
|
.upd-old{color:var(--dim)}
|
||||||
.upd-arrow{color:var(--dim)}
|
.upd-arrow{color:var(--dim)}
|
||||||
.upd-newv{font-weight:700;color:var(--accent)}
|
.upd-newv{font-weight:700;color:var(--accent)}
|
||||||
|
.upd-channel{margin:-6px 0 12px;text-align:center}
|
||||||
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px}
|
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px}
|
||||||
.upd-progress{margin:10px 0}
|
.upd-progress{margin:10px 0}
|
||||||
.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden}
|
.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden}
|
||||||
.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s}
|
.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s}
|
||||||
|
.backup-progress{margin-top:12px}
|
||||||
|
.backup-progress-bar{height:10px;background:var(--border2);border-radius:999px;overflow:hidden}
|
||||||
|
.backup-progress-fill{height:100%;width:0;background:linear-gradient(90deg,#3a7bc8,#5aa1e6);transition:width .2s;background-size:220% 100%}
|
||||||
|
.backup-progress-fill-busy{animation:backup-progress-shift 1.2s linear infinite}
|
||||||
|
.backup-progress-text{margin-top:6px}
|
||||||
|
|
||||||
|
@keyframes backup-progress-shift{
|
||||||
|
from{background-position:0 0}
|
||||||
|
to{background-position:220% 0}
|
||||||
|
}
|
||||||
|
|
||||||
/* Kontextmenü (Rechtsklick auf Instanz) */
|
/* Kontextmenü (Rechtsklick auf Instanz) */
|
||||||
.ctx{
|
.ctx{
|
||||||
|
|||||||
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 };
|
||||||
301
src/updater.js
301
src/updater.js
@@ -17,13 +17,117 @@ const { spawn } = require('child_process');
|
|||||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||||||
const UA = 'AeroMC-Launcher';
|
const UA = 'AeroMC-Launcher';
|
||||||
|
|
||||||
// "1.2.3" -> [1,2,3]; führendes "v" und Zusätze wie "-beta" werden ignoriert
|
/*
|
||||||
|
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
||||||
|
*
|
||||||
|
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
||||||
|
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
||||||
|
* 'unknown' und gar kein automatisches Update anbieten.
|
||||||
|
*/
|
||||||
|
let cachedKind = null;
|
||||||
|
function detectInstallKind() {
|
||||||
|
if (cachedKind) return cachedKind;
|
||||||
|
cachedKind = computeInstallKind();
|
||||||
|
return cachedKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeInstallKind() {
|
||||||
|
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
||||||
|
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
||||||
|
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
||||||
|
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
||||||
|
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
||||||
|
|
||||||
|
const exe = process.execPath || '';
|
||||||
|
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||||||
|
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||||||
|
|
||||||
|
// 3. Setup: NSIS legt neben der EXE immer einen Uninstaller ab. Nur wenn der
|
||||||
|
// da ist, ist es wirklich eine Installation.
|
||||||
|
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||||||
|
|
||||||
|
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUninstaller(dir) {
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
||||||
|
function portableTarget() {
|
||||||
|
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
||||||
|
if (p) return p;
|
||||||
|
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
||||||
|
const exe = process.execPath || '';
|
||||||
|
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDir() {
|
||||||
|
return path.join(os.tmpdir(), 'aeromc-update');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
||||||
|
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
||||||
|
* von einem laufenden Update benutzt werden.
|
||||||
|
*/
|
||||||
|
function cleanupUpdateCache() {
|
||||||
|
try {
|
||||||
|
const dir = updateDir();
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
const p = path.join(dir, name);
|
||||||
|
try {
|
||||||
|
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
||||||
|
fs.rmSync(p, { force: true, recursive: true });
|
||||||
|
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
||||||
|
}
|
||||||
|
} catch { /* egal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
||||||
function parseVersion(v) {
|
function parseVersion(v) {
|
||||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
||||||
|
function parsePreRelease(v) {
|
||||||
|
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
||||||
|
return m ? m[1].split('.') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
|
||||||
|
*
|
||||||
|
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
|
||||||
|
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
|
||||||
|
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
|
||||||
|
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
|
||||||
|
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
|
||||||
|
*/
|
||||||
|
function comparePreRelease(a, b) {
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||||
|
const x = a[i]; const y = b[i];
|
||||||
|
if (x === undefined) return -1;
|
||||||
|
if (y === undefined) return 1;
|
||||||
|
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
||||||
|
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
||||||
|
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
||||||
|
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
||||||
|
if (ny !== null) return 1;
|
||||||
|
if (x !== y) return x < y ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// >0 wenn a neuer als b
|
// >0 wenn a neuer als b
|
||||||
function compareVersions(a, b) {
|
function compareVersions(a, b) {
|
||||||
const x = parseVersion(a); const y = parseVersion(b);
|
const x = parseVersion(a); const y = parseVersion(b);
|
||||||
@@ -31,16 +135,31 @@ function compareVersions(a, b) {
|
|||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
if (x[i] !== y[i]) return x[i] - y[i];
|
if (x[i] !== y[i]) return x[i] - y[i];
|
||||||
}
|
}
|
||||||
return 0;
|
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows-Installer im Release finden (.exe bevorzugt, sonst .zip)
|
/*
|
||||||
function pickAsset(release) {
|
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
||||||
|
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
||||||
|
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
||||||
|
* das falsche.
|
||||||
|
*/
|
||||||
|
function assetKind(name) {
|
||||||
|
const n = String(name || '');
|
||||||
|
if (!/\.exe$/i.test(n)) return null;
|
||||||
|
const portable = /portable/i.test(n);
|
||||||
|
const setup = /setup|install(er)?/i.test(n);
|
||||||
|
if (portable && setup) return null;
|
||||||
|
if (portable) return 'portable';
|
||||||
|
if (setup) return 'setup';
|
||||||
|
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
||||||
|
}
|
||||||
|
|
||||||
|
// passendes Windows-Artefakt im Release finden
|
||||||
|
function pickAsset(release, installKind) {
|
||||||
|
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
||||||
const assets = release.assets || [];
|
const assets = release.assets || [];
|
||||||
return assets.find((a) => /\.exe$/i.test(a.name))
|
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
||||||
|| assets.find((a) => /setup.*\.exe$/i.test(a.name))
|
|
||||||
|| assets.find((a) => /\.zip$/i.test(a.name))
|
|
||||||
|| null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -48,6 +167,7 @@ function pickAsset(release) {
|
|||||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||||
*/
|
*/
|
||||||
async function checkForUpdate(currentVersion) {
|
async function checkForUpdate(currentVersion) {
|
||||||
|
const installKind = detectInstallKind();
|
||||||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||||
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
||||||
const list = await res.json();
|
const list = await res.json();
|
||||||
@@ -59,57 +179,182 @@ async function checkForUpdate(currentVersion) {
|
|||||||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
||||||
const latest = releases[0];
|
const latest = releases[0];
|
||||||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
||||||
const asset = pickAsset(latest);
|
const asset = pickAsset(latest, installKind);
|
||||||
return {
|
return {
|
||||||
available,
|
available,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
|
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
|
||||||
|
installKind,
|
||||||
notes: latest.body || '',
|
notes: latest.body || '',
|
||||||
published: latest.published_at || latest.created_at || null,
|
published: latest.published_at || latest.created_at || null,
|
||||||
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size } : null,
|
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
|
||||||
url: latest.html_url,
|
url: latest.html_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
||||||
|
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
||||||
|
*/
|
||||||
|
function assertMatchingKind(fileName, installKind) {
|
||||||
|
if (installKind !== 'portable' && installKind !== 'setup') {
|
||||||
|
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
||||||
|
}
|
||||||
|
const kind = assetKind(fileName);
|
||||||
|
if (!kind) {
|
||||||
|
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
||||||
|
}
|
||||||
|
if (kind !== installKind) {
|
||||||
|
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
||||||
|
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
||||||
|
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||||||
* onProgress: ({ phase, loaded, total, percent })
|
* onProgress: ({ phase, loaded, total, percent })
|
||||||
*/
|
*/
|
||||||
async function downloadAndInstall(asset, onProgress) {
|
async function downloadAndInstall(asset, onProgress) {
|
||||||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||||||
|
const installKind = detectInstallKind();
|
||||||
|
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
||||||
|
// sonst kontrolliert sich die Angabe nur selbst.
|
||||||
|
assertMatchingKind(asset.name, installKind);
|
||||||
|
|
||||||
const dir = path.join(os.tmpdir(), 'aeromc-update');
|
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
||||||
|
const target = installKind === 'portable' ? portableTarget() : '';
|
||||||
|
if (installKind === 'portable' && !target) {
|
||||||
|
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = updateDir();
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
const dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||||
|
fs.rmSync(dest, { force: true });
|
||||||
|
|
||||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
|
||||||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||||||
const chunks = [];
|
|
||||||
|
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
||||||
|
const out = fs.createWriteStream(dest);
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
for await (const chunk of res.body) {
|
let lastReport = 0;
|
||||||
chunks.push(Buffer.from(chunk));
|
try {
|
||||||
loaded += chunk.length;
|
for await (const chunk of res.body) {
|
||||||
if (onProgress) {
|
const buf = Buffer.from(chunk);
|
||||||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
hash.update(buf);
|
||||||
|
loaded += buf.length;
|
||||||
|
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
||||||
|
// sonst sammeln sich über den ganzen Download hunderte davon an
|
||||||
|
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||||||
|
const onDrain = () => { out.off('error', onError); resolve(); };
|
||||||
|
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||||||
|
out.once('drain', onDrain);
|
||||||
|
out.once('error', onError);
|
||||||
|
});
|
||||||
|
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
||||||
|
if (onProgress && Date.now() - lastReport >= 250) {
|
||||||
|
lastReport = Date.now();
|
||||||
|
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
out.once('error', reject);
|
||||||
|
out.end((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
out.destroy();
|
||||||
|
fs.rmSync(dest, { force: true });
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
const buf = Buffer.concat(chunks);
|
|
||||||
if (total && buf.length !== total) throw new Error('Download unvollständig.');
|
|
||||||
fs.writeFileSync(dest, buf);
|
|
||||||
|
|
||||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
if (total && loaded !== total) {
|
||||||
if (onProgress) onProgress({ phase: 'ready', loaded: buf.length, total: buf.length, percent: 100 });
|
fs.rmSync(dest, { force: true });
|
||||||
|
throw new Error('Download unvollständig.');
|
||||||
|
}
|
||||||
|
|
||||||
return { path: dest, bytes: buf.length, sha256 };
|
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||||||
|
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startet den Installer und löst sich vom Launcher (damit er sich selbst ersetzen kann)
|
/*
|
||||||
function runInstaller(installerPath) {
|
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
||||||
const child = spawn(installerPath, [], { detached: true, stdio: 'ignore' });
|
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
||||||
|
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
||||||
|
*/
|
||||||
|
const SWAP_SCRIPT = `@echo off
|
||||||
|
setlocal
|
||||||
|
set "SRC=%~1"
|
||||||
|
set "DST=%~2"
|
||||||
|
set "LOG=%~dp0update.log"
|
||||||
|
set TRIES=0
|
||||||
|
:loop
|
||||||
|
ping -n 2 127.0.0.1 >nul
|
||||||
|
copy /y "%SRC%" "%DST%" >nul 2>&1
|
||||||
|
if not errorlevel 1 goto done
|
||||||
|
set /a TRIES+=1
|
||||||
|
if %TRIES% GEQ 60 goto fail
|
||||||
|
goto loop
|
||||||
|
:done
|
||||||
|
del /f /q "%SRC%" >nul 2>&1
|
||||||
|
start "" "%DST%"
|
||||||
|
exit /b 0
|
||||||
|
:fail
|
||||||
|
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
||||||
|
exit /b 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wendet das geladene Update an.
|
||||||
|
* setup -> NSIS-Installer starten, er überschreibt die Installation selbst
|
||||||
|
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
||||||
|
* Der Launcher muss sich danach beenden.
|
||||||
|
*/
|
||||||
|
function applyUpdate(file) {
|
||||||
|
const installKind = detectInstallKind();
|
||||||
|
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
||||||
|
// wirklich zu dieser Paketart?
|
||||||
|
assertMatchingKind(path.basename(file.path || ''), installKind);
|
||||||
|
const kind = installKind;
|
||||||
|
|
||||||
|
if (kind !== 'portable') {
|
||||||
|
const child = spawn(file.path, [], { detached: true, stdio: 'ignore' });
|
||||||
|
child.unref();
|
||||||
|
return { started: 'installer' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = file.target || portableTarget();
|
||||||
|
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
|
||||||
|
|
||||||
|
const script = path.join(updateDir(), 'aeromc-swap.cmd');
|
||||||
|
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
|
||||||
|
|
||||||
|
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
|
||||||
|
const child = spawn(process.env.COMSPEC || 'cmd.exe',
|
||||||
|
['/c', 'start', '', '/min', script, file.path, target],
|
||||||
|
{ detached: true, stdio: 'ignore', windowsHide: true });
|
||||||
child.unref();
|
child.unref();
|
||||||
return true;
|
return { started: 'swap', target };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion };
|
// Rückwärtskompatibel: nur für die Setup-Variante
|
||||||
|
function runInstaller(installerPath) {
|
||||||
|
return applyUpdate({ path: installerPath, kind: 'setup' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkForUpdate,
|
||||||
|
downloadAndInstall,
|
||||||
|
applyUpdate,
|
||||||
|
runInstaller,
|
||||||
|
cleanupUpdateCache,
|
||||||
|
compareVersions,
|
||||||
|
parseVersion,
|
||||||
|
detectInstallKind,
|
||||||
|
assetKind,
|
||||||
|
portableTarget,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user