13 Commits
Author SHA1 Message Date
Git Manager GUI 87fded4cb6 Upload via GUI (45 Dateien) 2026-08-08 22:09:57 +02:00
M_Viper 33e533009e README.md aktualisiert 2026-08-08 20:00:10 +00:00
M_Viper 17f8726075 Dateien nach "/" hochladen 2026-08-08 19:06:36 +00:00
M_Viper deec93e5fa Dateien nach "/" hochladen 2026-08-08 19:06:00 +00:00
Git Manager GUI fc57dfa186 Upload via GUI (42 Dateien) 2026-08-06 21:26:45 +02:00
Git Manager GUI bafad25059 Upload via GUI (43 Dateien) 2026-08-05 09:04:48 +02:00
M_Viper ec4992c5c7 README.md aktualisiert 2026-08-05 07:03:48 +00:00
M_Viper 3e28b50f1b README.md aktualisiert 2026-08-03 19:10:09 +00:00
Git Manager GUI 54d248133c Upload via GUI (39 Dateien) 2026-08-03 21:00:24 +02:00
Git Manager GUI 129ab2d939 Upload via GUI (28 Dateien) 2026-08-02 21:56:31 +02:00
M_Viper b250ef16fd README.md aktualisiert 2026-08-02 18:51:21 +00:00
Git Manager GUI 9e601b1962 Upload via GUI (39 Dateien) 2026-08-02 20:18:36 +02:00
Git Manager GUI 176d20ae7b Upload via GUI (39 Dateien) 2026-08-02 19:54:37 +02:00
22 changed files with 3862 additions and 325 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
# beim Bauen erzeugt, enthält Adresse und Token der CurseForge-Weiterleitung
src/build-config.json
# Zugangsdaten des Weiterleitungs-Servers
server/curseforge-proxy/.env
+38
View File
@@ -0,0 +1,38 @@
AeroMC Lizenz- und Nutzungsbedingungen
Copyright (c) M_Viper. Alle Rechte vorbehalten.
Diese Software („AeroMC") sowie sämtlicher zugehöriger Quellcode, Ressourcen und
Dokumentation sind urheberrechtlich geschützt. Mit der Nutzung dieser Software
erkennst du die folgenden Bedingungen an.
1. Erlaubte Nutzung
Die Nutzung von AeroMC ist ausschließlich für den privaten, nicht-kommerziellen
Gebrauch gestattet.
2. Untersagt ist insbesondere
- Die Weitergabe dieses Launchers oder von Teilen davon an Dritte
- Das Verändern, Dekompilieren, Zurückentwickeln (Reverse Engineering) oder
Bearbeiten des Codes
- Die Veröffentlichung auch in veränderter oder abgeleiteter Form
an anderer Stelle als den offiziellen Kanälen des Autors
- Der Verkauf sowie jede sonstige kommerzielle Nutzung
3. Keine Gewährleistung
Die Software wird ohne jegliche Gewährleistung bereitgestellt, weder
ausdrücklich noch stillschweigend, einschließlich, aber nicht beschränkt auf
die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten
Zweck und der Nichtverletzung von Rechten Dritter. Der Autor haftet nicht für
Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung der Software
entstehen.
4. Rechtsfolgen bei Verstoß
Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich
belangt werden. Alle Rechte, die hier nicht ausdrücklich eingeräumt werden,
bleiben beim Autor vorbehalten.
5. Markenhinweis
AeroMC ist kein offizielles Produkt von Mojang oder Microsoft. Minecraft ist
eine Marke von Mojang AB.
Kontakt: https://m-viper.de
+178 -50
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung. Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
Aktueller Stand: Version 1.0.3 Aktueller Stand: Version 1.0.8
</div> </div>
@@ -20,91 +20,211 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Beliebig viele getrennte Minecraft-Instanzen - Beliebig viele getrennte Minecraft-Instanzen
- Unterstützung für Vanilla, Fabric, Quilt, Forge und NeoForge - Unterstützung für Vanilla, Fabric, Quilt, Forge und NeoForge
- Modrinth-Integration mit einfacher Mod-Installation inklusive Pflicht-Abhängigkeiten - Versionsliste mit Filter: Releases, Snapshots oder alle
- Modrinth- **und CurseForge**-Integration für **Mods, Ressourcenpakete und Shader** (Suche wahlweise über beide Quellen, Installation inkl. rekursiver Pflicht-Abhängigkeiten, Update-Prüfung samt Sammel-Update)
- Modpacks direkt von Modrinth **oder von der Technic-Plattform** durchsuchen und mit einem Klick als neue Instanz installieren ohne vorherigen manuellen Download
- Microsoft-Login mit mehreren Konten - Microsoft-Login mit mehreren Konten
- Gemeinsamer Import-Dialog für AeroMC-ZIP, Modrinth-Modpacks, den Minecraft Launcher, CurseForge sowie MultiMC- und Prism-Instanzen - Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, MultiMC/Prism sowie Technic
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen statt Dubletten anzulegen - Reimport überschreibt vorhandene Instanzen mit gleichem Namen
- Export, Kopieren, Gruppieren und Verwalten von Instanzen - Export, Kopieren, Gruppieren, Löschen und Desktop-Verknüpfung pro Instanz
- Mehrfachauswahl mit Strg-Klick für Sammellöschen mehrerer Instanzen - Mehrfachauswahl mit Strg-Klick für Sammellöschen, Tastenkürzel für die wichtigsten Aktionen (Strg+F, Strg+N, Entf)
- Globales Backup und Wiederherstellung für alle Instanzen direkt im Einstellungsbereich - Mods/Modpacks/Instanzen auch per Drag & Drop aufs Fenster ziehen
- Verwaltung von Welten, Screenshots und Spielordnern direkt aus dem Launcher - Mods zusätzlich als eigene Datei hinzufügen, wenn sie nicht über Modrinth/CurseForge zu finden sind
- Bedrock-Bereich für Start und Verwaltung vorhandener Bedrock-Daten unter Windows - Warnt beim Entfernen eines Mods, falls ein anderer installierter Mod ihn noch als Pflicht-Abhängigkeit braucht
- Update-Funktion für neue Launcher-Versionen - Globales Backup inkl. automatischer Zeitplan-Sicherungen, dazu Schnellsicherung einzelner Welten
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
- Serverliste mit Live-Status (Spieleranzahl, MOTD, Version), Übernahme einzelner Einträge in andere Instanzen sowie Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt, ab MC 1.20)
- Online- und Offline-Start
- Eingebaute Konsole/Log-Ansicht 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
- Optionaler Autostart mit Windows, dann im Infobereich statt als Fenster
- Design Hell/Dunkel und Sortierung der Instanzen
## Installation ## Installation
Es gibt zwei Varianten: Es gibt zwei Varianten:
- AeroMC-Setup.exe installiert den Launcher und sorgt im Alltag für den schnelleren Start - **AeroMC-Setup.exe** installiert den Launcher, schneller Start im Alltag
- AeroMC-Portable.exe startet ohne Installation direkt als portable Variante - **AeroMC-Portable.exe** startet ohne Installation, ideal zum Mitnehmen (etwas längerer Start)
Für die meisten Nutzer ist die Setup-Version die bessere Wahl. Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable nur Portable-Updates. Ein automatischer Wechsel zwischen beiden Varianten findet nicht statt.
Die portable Datei ist praktisch zum Mitnehmen, braucht beim Öffnen aber in der Regel etwas länger.
Automatische Updates bleiben dabei sauber getrennt: Die Setup-Version erhält nur Setup-Updates, die portable Variante nur Portable-Updates. Ein Wechsel zwischen beiden Varianten passiert nicht automatisch.
## Voraussetzungen ## Voraussetzungen
- Windows x64 - Windows x64
- Eine legale Minecraft Java Edition mit Microsoft-Konto für Java-Start und Anmeldung - Legale Minecraft Java Edition mit Microsoft-Konto
- Internetverbindung beim ersten Download von Minecraft-Dateien, Loadern oder Mods - Internetverbindung beim ersten Download von Minecraft-Dateien, Loadern oder Inhalten
- Für Bedrock-Funktionen: installierte Minecraft Bedrock Edition aus dem Microsoft Store - Für Bedrock: installierte Minecraft Bedrock Edition aus dem Microsoft Store
## Erste Schritte ## Erste Schritte
1. AeroMC installieren oder die portable Datei starten. 1. AeroMC installieren oder die portable Datei starten.
2. Über Konten ein Microsoft-Konto hinzufügen. 2. Über **Konten** ein Microsoft-Konto hinzufügen.
3. Eine neue Instanz anlegen. 3. Eine neue Instanz anlegen.
4. Minecraft-Version und bei Bedarf einen Loader auswählen. 4. Minecraft-Version und optional einen Loader wählen.
5. Optional Mods hinzufügen oder vorhandene Instanzen bzw. Modpacks über Importieren übernehmen. 5. Optional Mods/Ressourcenpakete/Shader hinzufügen oder bestehende Setups über **Importieren** übernehmen.
6. Instanz starten. 6. Instanz starten (online oder offline).
## Instanzen
Jede Instanz hat:
- Namen und optionale Gruppe
- Minecraft-Version und optionalen Mod-Loader
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
- eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots und Serverliste
- eigene Java-, RAM-, JVM-, Pre-Launch- und Post-Exit-Einstellungen (leer = globale Vorgabe), inkl. Knopf für empfohlene GC-Flags bei den JVM-Argumenten
Über die Seitenleiste einer ausgewählten Instanz erreichst du u. a.:
- Starten / Offline starten
- Instanz und Notizen bearbeiten
- Mods, Ressourcenpakete und Shader
- Welten (inkl. Schnellsicherung einzelner Welten), Datenpakete und Bildschirmfotos
- Server verwalten (Live-Status, Übernahme eines Eintrags in andere Instanzen, Desktop-Verknüpfung pro Server)
- Logdateien und Absturzberichte ansehen
- Minecraft-, Konfigurations- und Instanz-Ordner öffnen
- Desktop-Verknüpfung erstellen
- Exportieren, Kopieren, Gruppe ändern, Löschen
- Bei importierten Modpacks: Modpack aktualisieren
In der Instanzliste: Suche, Sortierung und **Strg + Klick** für Mehrfachauswahl.
### Tastenkürzel
| Taste | Wirkung |
|---|---|
| Strg+F | Suche fokussieren |
| Strg+N | Neue Instanz anlegen |
| Entf | Markierte Instanz(en) löschen |
| Esc | Aktuellen Dialog schließen |
Greifen nicht, während ein Textfeld fokussiert ist oder ein Dialog mit eigener Suche offen ist.
## Import ## Import
Über Importieren in der oberen Leiste können mehrere Quellen übernommen werden: Über **Importieren**:
- AeroMC-ZIP-Archive mit instance.json | Quelle | Typische Nutzung |
- Modrinth-Modpacks im .mrpack-Format |---|---|
- Profile aus dem offiziellen Minecraft Launcher, typischerweise aus C:\Users\DeinName\AppData\Roaming\.minecraft | ZIP-Archiv | AeroMC-Export (`instance.json`) sowie einzelne MultiMC/Prism-Instanzen (auch gezippt) |
- CurseForge-Instanzen, typischerweise aus C:\Users\DeinName\curseforge\minecraft\Instances | Modpack | Modrinth-Katalog direkt durchsuchen und installieren, oder Datei importieren: `.mrpack` sowie CurseForge-ZIP (`manifest.json`) |
- MultiMC- und Prism-Instanzen | Minecraft Launcher | Profile aus dem offiziellen Launcher |
| CurseForge | Instanzen aus dem CurseForge-Ordner |
| MultiMC / Prism | Vorhandene MultiMC- oder Prism-Instanzen |
| Technic | Technic-Katalog direkt durchsuchen und installieren, oder eine vorhandene Technic-Installation importieren (`%APPDATA%\.technic\modpacks`) |
Wenn bereits eine Instanz mit demselben Namen existiert, wird sie beim Import ersetzt statt zusätzlich angelegt. Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
Beim Import aus dem offiziellen Minecraft Launcher werden nur echte benutzerdefinierte Profile angezeigt. Standardprofile wie Latest Release oder Latest Snapshot werden ausgeblendet. Nutzt ein Profil den gemeinsamen Standard-Spielordner, übernimmt AeroMC daraus nur spielrelevante Daten wie Mods, Welten und Konfiguration statt den kompletten Launcher-Cache zu kopieren. Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache.
Im Reiter **Modpack** desselben Dialogs lässt sich zusätzlich direkt im Modrinth-Katalog suchen und ein Modpack mit einem Klick installieren ganz ohne vorherigen manuellen Download der `.mrpack`-Datei. Im Reiter **Technic** gibt es dasselbe für den Technic-Katalog.
Alternativ: `.jar`-, `.mrpack`- oder `.zip`-Dateien direkt aufs Launcher-Fenster ziehen. AeroMC erkennt anhand des Inhalts, ob es sich um einen Mod (landet in der gerade offenen/ausgewählten Instanz), ein Modpack oder einen Instanz-Export handelt.
## Mods, Ressourcenpakete und Shader
Im Inhalts-Dialog einer Instanz:
- Katalog durchsuchen (Quelle Modrinth oder CurseForge, Sortierung, Kategorien)
- Installation inkl. **automatischer** Installation aller Pflicht-Abhängigkeiten (rekursiv, auch quellübergreifend)
- Eigene Datei hinzufügen (`.jar`/`.zip`) für Mods, die nicht über Modrinth/CurseForge zu finden sind, oder per Drag & Drop aufs Fenster ziehen
- Installierte Einträge aktivieren, deaktivieren, entfernen beim Entfernen warnt AeroMC, falls ein anderer installierter Mod das noch als Pflicht-Abhängigkeit braucht
- Nach Updates suchen und alle aktualisieren
- Ordner der jeweiligen Inhaltsart öffnen
Ressourcenpakete und Shader funktionieren auch in Vanilla-Instanzen. Mods benötigen einen Mod-Loader. Für CurseForge-Inhalte wird ein eigener API-Key oder eine Weiterleitungs-Adresse benötigt (siehe **Einstellungen → Netzwerk**).
## Skin ändern
Über **Konten → Skin ändern…** lässt sich der Skin des aktiven Microsoft-Kontos direkt im Launcher ändern:
- PNG-Datei auswählen, sofortige Prüfung auf gültige Maße
- Live-Vorschau am 3D-Modell vor dem Anwenden
- Modell wählen: klassisch oder schlank (slim)
- Auf einen früheren Standard-Skin zurücksetzen
Die Änderung wird direkt bei Mojang für das angemeldete Konto hinterlegt und gilt somit auch außerhalb von AeroMC.
## Backup und Wiederherstellung ## Backup und Wiederherstellung
Im Bereich Einstellungen > Backup können komplette Sicherungen aller Instanzen erstellt und wieder eingespielt werden. Unter **Optionen → Backup**:
- Backups werden automatisch unter Dokumente\AeroMC Launcher\Backups gespeichert - Manuelles globales Backup (alle Instanzen + Launcher-Einstellungen)
- Gesichert werden alle Instanzen sowie die Launcher-Einstellungen - Automatische Sicherungen (täglich oder wöchentlich, einstellbare Anzahl)
- Microsoft-Konten und Tokens werden bewusst nicht mitgesichert - Optional zusätzlich vor jedem Spielstart sichern
- Vorhandene Backups werden im Wiederherstellen-Bereich direkt in einem Dropdown aufgelistet - Wiederherstellung aus vorhandenem Backup
Wichtig: Beim Wiederherstellen werden alle aktuell vorhandenen Instanzen mit dem Inhalt des gewählten Backups überschrieben. Die Launcher-Einstellungen werden ebenfalls aus dem Backup übernommen. Speicherort: `Dokumente\AeroMC Launcher\Backups`.
## Was AeroMC verwaltet **Wichtig:** Konten und Tokens werden nicht mitgesichert. Beim Wiederherstellen werden aktuelle Instanzen und Launcher-Einstellungen durch den Backup-Inhalt ersetzt.
- Minecraft-Versionen und gemeinsame Spieldateien ## Einstellungen
- Instanzbezogene Mods und Konfigurationen
- Welten und Screenshots
- Mehrere Microsoft-Konten
- Java-Auswahl pro Instanz
Bei der Instanzliste unterstützt AeroMC außerdem Strg-Klick für Mehrfachauswahl und Sammellöschen. ### Allgemein
- Design (Dunkel / Hell)
- Instanzen sortieren (nach letztem Start / nach Name)
- Instanz-Verzeichnis
- Bedrock-Bereich anzeigen
- Windows-Benachrichtigung bei fertigem Hintergrund-Download/-Backup (nur wenn das Fenster nicht im Vordergrund ist)
- Autostart: mit Windows starten, im Infobereich statt als Fenster solange aktiv, versteckt „Schließen" (X) den Launcher nur, statt ihn zu beenden (über das Symbol im Infobereich wieder öffnen oder wirklich beenden)
Alle Nutzerdaten werden lokal unter %APPDATA%\aeromc gespeichert. ### Darstellung
- Block-Symbole räumlich (3D)
- Skin-Hintergrund: Quelle, Pose, Deckkraft, optional automatischer Wechsel zwischen mehreren angemeldeten Konten (mit einstellbarem Zeitabstand)
### Spiel
- Standard-RAM (min./max.)
- Fenstergröße bzw. maximiert starten
### Start & Java
- Globale Java-Auswahl und Erkennung
- Fehlende Java-Version automatisch herunterladen
- Konsole: während des Spiels, nach Beenden schließen, bei Absturz anzeigen, Historie-Limit
- Globale Pre-Launch- / Post-Exit-Befehle
- Optionaler Wrapper-Befehl
### Netzwerk
- HTTP-Proxy (Host, Port, optional Benutzer/Passwort)
- CurseForge: eigener API-Key und/oder Weiterleitungs-Adresse inkl. Token
Ohne eigenen Key und ohne Weiterleitung funktionieren Modrinth-Inhalte weiterhin. Für CurseForge-Downloads wird einer der beiden Wege benötigt.
### Konten
- Mehrere Microsoft-Konten verwalten, aktiv setzen, entfernen
### Backup
- Manuell, Zeitplan und Wiederherstellung
### Info
- Version, Autor und Links
- Update-Suche läuft automatisch kurz nach dem Start (Status erscheint grün, wenn alles aktuell ist) sowie jederzeit manuell per Knopf
## Bedrock ## Bedrock
Der Bedrock-Bereich ist für bereits installierte Bedrock-Versionen unter Windows gedacht. AeroMC kann Bedrock nicht selbst installieren, aber vorhandene Welten, Pakete, Add-ons und Ordner leichter zugänglich machen. Der Bedrock-Bereich startet die vorhandene Windows-Store-Version und erleichtert den Zugriff auf Welten, Pakete, Add-ons und Ordner. Zusätzliche Add-on-Dateien lassen sich direkt über AeroMC installieren. AeroMC installiert Bedrock selbst (die App aus dem Microsoft Store) nicht. Fehlt die Installation, wird der Bereich automatisch ausgeblendet.
## Konsole, Logs und Crash-Reports
- Konsole über die obere Leiste oder bei Absturz
- Pro Instanz: gespeicherte Logdateien und Crash-Reports einsehen, kopieren und Ordner öffnen
## Datenort
Nutzerdaten liegen lokal unter `%APPDATA%\aeromc`.
## Links ## Links
- Webseite: https://m-viper.de - Webseite: https://m-viper.de
- Gitseite: https://git.viper.ipv64.net - Launcher-Webseite: https://aeromc.viper.ipv64.net
- Autor: M_Viper - Autor: M_Viper
## Rechtliches ## Rechtliches
@@ -112,6 +232,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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aeromc", "name": "aeromc",
"version": "1.0.1-beta", "version": "1.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aeromc", "name": "aeromc",
"version": "1.0.1-beta", "version": "1.0.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
+6 -7
View File
@@ -1,16 +1,14 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.0.3", "version": "1.0.8",
"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",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
"main": "src/main.js", "main": "src/main.js",
"author": "M_Viper", "author": "M_Viper",
"repository": { "license": "SEE LICENSE IN LICENSE",
"type": "git", "private": true,
"url": "https://git.viper.ipv64.net"
},
"license": "MIT",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"prebuild": "node scripts/write-build-config.js", "prebuild": "node scripts/write-build-config.js",
@@ -25,7 +23,8 @@
"compression": "store", "compression": "store",
"files": [ "files": [
"src/**/*", "src/**/*",
"package.json" "package.json",
"LICENSE"
], ],
"win": { "win": {
"target": "nsis", "target": "nsis",
+73 -11
View File
@@ -14,7 +14,12 @@ const path = require('path');
const { execFile, spawn } = require('child_process'); const { execFile, spawn } = require('child_process');
const PKG_FAMILY = 'Microsoft.MinecraftUWP_8wekyb3d8bbwe'; const PKG_FAMILY = 'Microsoft.MinecraftUWP_8wekyb3d8bbwe';
const APP_ID = PKG_FAMILY + '!App'; // Der Teil nach "!" (Application Id) ist NICHT stabil Microsoft hat ihn
// schon mindestens einmal geändert (früher "App", inzwischen "Game").
// Deshalb zur Laufzeit über Get-StartApps auflösen statt fest zu verdrahten;
// dieser Wert dient nur als letzter Rückfallwert, falls die Auflösung
// fehlschlägt (z. B. PowerShell blockiert).
const FALLBACK_APP_ID = PKG_FAMILY + '!Game';
function comMojangDir() { function comMojangDir() {
return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY, return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY,
@@ -49,30 +54,87 @@ async function detect() {
// Start // Start
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let cachedAppId = null;
/*
* Ermittelt die aktuell gültige AUMID über Get-StartApps das ist derselbe
* Weg, den auch das Startmenü nutzt, und bildet daher immer die Application
* Id ab, die Windows gerade tatsächlich zum Starten akzeptiert.
*/
async function resolveAppId() {
if (cachedAppId) return cachedAppId;
const out = await runPwsh(
`(Get-StartApps | Where-Object { $_.AppID -like '${PKG_FAMILY}*' } `
+ `| Select-Object -First 1 -ExpandProperty AppID)`);
cachedAppId = out || FALLBACK_APP_ID;
return cachedAppId;
}
/* /*
* Startet Bedrock direkt. * Startet Bedrock direkt.
* Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen sonst * Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen sonst
* öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments. * öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments.
*/ */
function launch() { async function launch() {
const target = 'shell:appsFolder\\' + APP_ID; const appId = await resolveAppId();
const target = 'shell:appsFolder\\' + appId;
// Wichtig: Es darf pro Klick nur EINE Aktivierung tatsächlich lostreten.
// Die App-Aktivierung lässt sich nicht abbrechen wenn wir bei einer
// Verzögerung (Xbox-Dienste, Kaltstart) automatisch einen zweiten Weg
// probieren, laufen im Zweifel beide an und Bedrock öffnet sich doppelt.
// Die anderen Wege dienen daher nur als Rückfall, falls das Spawnen
// selbst scheitert (Programm nicht gefunden o. Ä.), nicht als
// Reaktion auf einen bloß langsamen Start.
const versuche = [ const versuche = [
// 1) genau der Aufruf, der sich unter Windows als zuverlässig erwiesen hat // 1) explorer direkt, ohne zusätzliche Anführungszeichen entspricht
// exakt dem manuellen "explorer.exe shell:appsFolder\...", der
// nachweislich zuverlässig funktioniert
['explorer.exe', [target], { windowsVerbatimArguments: true }],
// 2) über die Eingabeaufforderung
['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }],
// 3) Rückfallweg über eine versteckte PowerShell aus einem
// windowless/detached Kindprozess heraus offenbar nicht überall
// zuverlässig, daher nur letzter Versuch
['powershell', ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', ['powershell', ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
`Start-Process explorer.exe -ArgumentList '${target}'`], {}], `Start-Process explorer.exe -ArgumentList '${target}'`], {}],
// 2) explorer direkt, ohne zusätzliche Anführungszeichen
['explorer.exe', [target], { windowsVerbatimArguments: true }],
// 3) über die Eingabeaufforderung
['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }],
]; ];
let methode = null;
for (const [cmd, args, extra] of versuche) { for (const [cmd, args, extra] of versuche) {
try { try {
const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra)); const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra));
p.unref(); p.unref();
return { ok: true, method: cmd }; methode = cmd;
} catch { /* nächsten Weg versuchen */ } break; // nur diesen einen Weg tatsächlich auslösen
} catch { /* Spawnen selbst fehlgeschlagen nächsten Weg versuchen */ }
}
if (!methode) return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' };
// Aktivierung kann dauern (v. a. beim ersten Start nach längerer Pause)
// hier nur zur ehrlichen Rückmeldung abwarten, OHNE bei Zeitüberschreitung
// einen weiteren Versuch zu starten (siehe Hinweis oben).
const bestaetigt = await waitForBedrockProcess(15000);
return { ok: true, method: methode, confirmed: bestaetigt };
}
function isBedrockRunning() {
return new Promise((resolve) => {
execFile('tasklist', ['/FI', 'IMAGENAME eq Minecraft.Windows.exe', '/FO', 'CSV', '/NH'],
{ windowsHide: true, timeout: 5000 },
(err, stdout) => resolve(!err && /Minecraft\.Windows\.exe/i.test(String(stdout || ''))));
});
}
function warten(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}
async function waitForBedrockProcess(timeoutMs) {
const start = Date.now();
// sofort prüfen (falls schon ein Fenster lief) und danach im Kurztakt
for (;;) {
if (await isBedrockRunning()) return true;
if (Date.now() - start >= timeoutMs) return false;
await warten(500);
} }
return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"curseforgeProxyUrl": "", "curseforgeProxyUrl": "",
"curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J" "curseforgeProxyToken": ""
} }
+25 -2
View File
@@ -47,11 +47,18 @@ function neuesterBericht(gameDir, seit) {
/* /*
* Zieht die aussagekräftigen Zeilen aus einem Bericht. * Zieht die aussagekräftigen Zeilen aus einem Bericht.
* -> { beschreibung, fehler, verdaechtigeMods, verlauf: [] } * -> { beschreibung, fehler, verdaechtigeMods, diagnose, empfehlung, verlauf: [] }
*/ */
function auswerten(text) { function auswerten(text) {
const zeilen = String(text || '').split(/\r?\n/); const zeilen = String(text || '').split(/\r?\n/);
const befund = { beschreibung: '', fehler: '', verdaechtigeMods: '', verlauf: [] }; const befund = {
beschreibung: '',
fehler: '',
verdaechtigeMods: '',
diagnose: '',
empfehlung: '',
verlauf: [],
};
for (let i = 0; i < zeilen.length; i++) { for (let i = 0; i < zeilen.length; i++) {
const z = zeilen[i]; const z = zeilen[i];
@@ -86,6 +93,22 @@ function auswerten(text) {
const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim())); const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim()));
if (treffer) befund.fehler = treffer.trim(); if (treffer) befund.fehler = treffer.trim();
} }
const volltext = zeilen.join('\n');
const shutdownWatchdog = /Description:\s*Client shutdown from post-main/i.test(volltext)
&& /ClientShutdownWatchdog/i.test(volltext);
const webMapJetty = /journeymap_webmap:\s*JourneyMap WebMap\b/i.test(volltext)
&& /JettyServerThreadPool/i.test(volltext)
&& /ServerConnector.*\{0\.0\.0\.0:\d+\}/i.test(volltext);
if (shutdownWatchdog && webMapJetty) {
const version = volltext.match(/journeymap_webmap:\s*JourneyMap WebMap\s+([^\s\r\n]+)/i);
const name = `JourneyMap WebMap${version ? ` ${version[1]}` : ''}`;
befund.verdaechtigeMods = name;
befund.diagnose = `${name} hat seinen eingebetteten Jetty-Webserver beim Beenden nicht gestoppt. `
+ 'Dessen nicht beendete Server-Threads haben den Minecraft-Shutdown-Watchdog ausgelöst.';
befund.empfehlung = 'AeroMC beendet einen nach dem regulären Client-Shutdown verbliebenen Java-Prozess nun '
+ 'nach einer kurzen Schonfrist automatisch. JourneyMap WebMap kann installiert und aktiviert bleiben.';
}
return befund; return befund;
} }
+221 -6
View File
@@ -10,7 +10,7 @@
<body> <body>
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) --> <!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
<header class="toolbar"> <header class="toolbar">
<button id="btn-new" class="tbtn" data-ic="plus">Instanz hinzufügen</button> <button id="btn-new" class="tbtn" data-ic="plus" title="Neue Instanz (Strg+N)">Instanz hinzufügen</button>
<button id="btn-import" class="tbtn" data-ic="import" title="Instanzen importieren">Importieren</button> <button id="btn-import" class="tbtn" data-ic="import" title="Instanzen importieren">Importieren</button>
<button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button> <button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button>
<span class="tbsep"></span> <span class="tbsep"></span>
@@ -19,7 +19,7 @@
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button> <button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
<span class="tbgrow"></span> <span class="tbgrow"></span>
<div class="search-wrap"> <div class="search-wrap">
<input id="search" type="text" placeholder="Suchen…" autocomplete="off" /> <input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" />
</div> </div>
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button> <button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button> <button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
@@ -51,6 +51,7 @@
<button id="p-mods" class="sbtn">Mods anzeigen</button> <button id="p-mods" class="sbtn">Mods anzeigen</button>
<button id="p-worlds" class="sbtn">Welten verwalten</button> <button id="p-worlds" class="sbtn">Welten verwalten</button>
<button id="p-screens" class="sbtn">Bildschirmfotos verwalten</button> <button id="p-screens" class="sbtn">Bildschirmfotos verwalten</button>
<button id="p-modpack" class="sbtn hidden">Modpack aktualisieren</button>
<button id="p-logs" class="sbtn">Logdateien ansehen</button> <button id="p-logs" class="sbtn">Logdateien ansehen</button>
<button id="p-servers" class="sbtn">Server verwalten</button> <button id="p-servers" class="sbtn">Server verwalten</button>
<div class="hr"></div> <div class="hr"></div>
@@ -60,7 +61,7 @@
<div class="hr"></div> <div class="hr"></div>
<button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button> <button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button>
<button id="p-export" class="sbtn">Instanz exportieren</button> <button id="p-export" class="sbtn">Instanz exportieren</button>
<button id="p-delete" class="sbtn del">Löschen</button> <button id="p-delete" class="sbtn del" title="Entf">Löschen</button>
<button id="p-copy" class="sbtn">Instanz kopieren</button> <button id="p-copy" class="sbtn">Instanz kopieren</button>
</div> </div>
</aside> </aside>
@@ -129,6 +130,7 @@
<option value="forge">Forge</option> <option value="forge">Forge</option>
<option value="neoforge">NeoForge</option> <option value="neoforge">NeoForge</option>
<option value="quilt">Quilt</option> <option value="quilt">Quilt</option>
<option value="optifine">OptiFine</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
@@ -138,6 +140,23 @@
</label> </label>
</div> </div>
<div id="f-optifine-tools" class="field-row hidden" style="align-items:flex-start">
<div class="field" style="flex:1">
<span>OptiFine einrichten</span>
<p class="hint" style="margin:2px 0 8px">
OptiFine bietet kein automatisches Download-API (Downloads laufen bei OptiFine bewusst über eine Werbeseite).
Lade den Installer zuerst selbst von <b>optifine.net</b> herunter, wähle ihn dann hier aus AeroMC bereitet
die passende Minecraft-Version vor und startet den echten OptiFine-Installer. Wähle darin als Zielordner den
automatisch in die Zwischenablage kopierten Pfad, klicke „Install" und bestätige danach hier.
</p>
<div class="path-row">
<button type="button" id="f-optifine-install" class="btn">Installer wählen und starten…</button>
<button type="button" id="f-optifine-confirm" class="btn btn-primary hidden">Fertig Version übernehmen</button>
</div>
<small id="f-optifine-status" class="hint"></small>
</div>
</div>
<label class="field"> <label class="field">
<span>Notizen <small class="opt">(optional)</small></span> <span>Notizen <small class="opt">(optional)</small></span>
<textarea id="f-notes" rows="2" placeholder="Modliste, Server-IP, To-dos …"></textarea> <textarea id="f-notes" rows="2" placeholder="Modliste, Server-IP, To-dos …"></textarea>
@@ -157,9 +176,24 @@
</label> </label>
</div> </div>
<small id="f-mem-hint" class="hint"></small> <small id="f-mem-hint" class="hint"></small>
<label class="field">
<span>Java (java.exe)</span>
<div class="path-row">
<input id="f-java" type="text" placeholder="leer = globale Einstellung bzw. automatisch" />
<button id="f-java-pick" class="btn" type="button">Wählen…</button>
</div>
<div class="path-row">
<select id="f-java-detected"><option value=""> gefundene Java-Installationen </option></select>
</div>
<small id="f-java-hint" class="hint"></small>
</label>
<label class="field"> <label class="field">
<span>Zusätzliche JVM-Argumente</span> <span>Zusätzliche JVM-Argumente</span>
<div class="path-row">
<input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" /> <input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" />
<button id="f-extra-args-aikar" class="btn btn-mini" type="button"
title="Fügt eine bewährte Sammlung von Garbage-Collector-Flags ein (bekannt als „Aikar's Flags"), die bei größeren Modpacks Ruckler durch Garbage Collection reduzieren kann.">GC-Flags einfügen</button>
</div>
</label> </label>
<label class="field"> <label class="field">
<span>Pre-Launch-Befehl <small class="opt">(läuft vor dem Start)</small></span> <span>Pre-Launch-Befehl <small class="opt">(läuft vor dem Start)</small></span>
@@ -241,6 +275,22 @@
</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>
<section class="settings-card">
<h5>Autostart</h5>
<label class="checkbox-field">
<input id="s-autostart" type="checkbox" /> <span>Mit Windows starten (im Infobereich)</span>
</label>
<small class="hint">Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.</small>
</section>
</div> </div>
</div> </div>
@@ -282,6 +332,14 @@
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" /> <input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
</label> </label>
</div> </div>
<label class="checkbox-field">
<input id="s-bgskin-cycle" type="checkbox" /> <span>Bei mehreren angemeldeten Konten automatisch durchwechseln</span>
</label>
<label class="field">
<span>Wechsel alle <small class="opt">(Sekunden)</small></span>
<input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" />
<small class="hint">Läuft nur, wenn 2 oder mehr Konten angemeldet sind, und beginnt nach dem letzten Konto wieder von vorn.</small>
</label>
</section> </section>
</div> </div>
</div> </div>
@@ -412,8 +470,12 @@
<h5>CurseForge</h5> <h5>CurseForge</h5>
<label class="field"> <label class="field">
<span>Eigener API-Key <small class="opt">(optional)</small></span> <span>Eigener API-Key <small class="opt">(optional)</small></span>
<div class="path-row">
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" /> <input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
<small class="hint">Kostenlos unter console.curseforge.com. Ist hier ein Schlüssel eingetragen, geht der Launcher direkt zu CurseForge und nutzt keine Weiterleitung.</small> <button type="button" id="s-cf-test" class="btn">Prüfen</button>
</div>
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
<small id="s-cf-test-status" class="hint"></small>
</label> </label>
<div class="field-row"> <div class="field-row">
<label class="field"> <label class="field">
@@ -556,6 +618,27 @@
</div> </div>
</div> </div>
<!-- Eingabe-Dialog (ersetzt window.prompt, das in Electron nicht geht) -->
<div id="modal-prompt" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3 id="prompt-title">Eingabe</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p id="prompt-text" class="hint"></p>
<label class="field" style="margin-top:10px">
<span id="prompt-label">Name</span>
<input id="prompt-input" type="text" autocomplete="off" />
</label>
</div>
<div class="modal-foot">
<button class="btn" data-close>Abbrechen</button>
<button id="prompt-ok" class="btn btn-primary">OK</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Mods (Modrinth) ===================== --> <!-- ===================== Dialog: Mods (Modrinth) ===================== -->
<div id="modal-mods" class="modal hidden"> <div id="modal-mods" class="modal hidden">
<div class="modal-card modal-lg"> <div class="modal-card modal-lg">
@@ -573,11 +656,21 @@
<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>
<option value="datapack">Datenpakete</option>
</select>
<input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" /> <input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" />
<button id="m-search-btn" class="btn btn-primary">Suchen</button> <button id="m-search-btn" class="btn btn-primary">Suchen</button>
</div> </div>
<div class="mods-filters"> <div class="mods-filters">
<select id="m-source" title="Quelle">
<option value="modrinth">Modrinth</option>
<option value="curseforge">CurseForge</option>
</select>
<select id="m-sort" title="Sortierung"> <select id="m-sort" title="Sortierung">
<option value="downloads">Beliebt</option> <option value="downloads">Beliebt</option>
<option value="relevance">Relevanz</option> <option value="relevance">Relevanz</option>
@@ -613,6 +706,7 @@
</div> </div>
<div id="m-pane-installed" class="hidden"> <div id="m-pane-installed" class="hidden">
<div class="mmc-bar"> <div class="mmc-bar">
<button id="m-add-file" class="btn btn-mini" data-ic="plus">Datei hinzufügen…</button>
<button id="m-check-updates" class="btn btn-mini">Nach Updates suchen</button> <button id="m-check-updates" class="btn btn-mini">Nach Updates suchen</button>
<button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button> <button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button>
<span id="m-update-status" class="hint"></span> <span id="m-update-status" class="hint"></span>
@@ -693,6 +787,10 @@
<h4>Ressourcenpakete <button id="content-open-rp" class="btn btn-mini" data-ic="folder"></button></h4> <h4>Ressourcenpakete <button id="content-open-rp" class="btn btn-mini" data-ic="folder"></button></h4>
<div id="content-rp" class="mods-list"></div> <div id="content-rp" class="mods-list"></div>
</div> </div>
<div class="mods-col">
<h4>Datenpakete <button id="content-open-dp" class="btn btn-mini" data-ic="folder"></button></h4>
<div id="content-dp" class="mods-list"></div>
</div>
</div> </div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
@@ -710,6 +808,12 @@
<button class="modal-close" data-close></button> <button class="modal-close" data-close></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="screens-toolbar">
<button id="screens-select-all" class="btn btn-mini">Alle auswählen</button>
<button id="screens-select-none" class="btn btn-mini">Auswahl aufheben</button>
<button id="screens-delete-sel" class="btn btn-danger btn-mini" disabled>Auswahl löschen</button>
<span id="screens-sel-hint" class="hint">Klick auf Vorschaubild zum Auswählen</span>
</div>
<div id="screens-grid" class="screens-grid"></div> <div id="screens-grid" class="screens-grid"></div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
@@ -720,6 +824,26 @@
</div> </div>
</div> </div>
<!-- ===================== Dialog: Modpack aktualisieren ===================== -->
<div id="modal-modpack" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3>Modpack aktualisieren</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p id="mp-status" class="hint">Suche …</p>
<div id="mp-kern" class="crash-kern hidden"></div>
<p class="hint">Welten, Bildschirmfotos und deine Optionen bleiben unangetastet. Mods und Konfigurationsdateien des Pakets werden ersetzt; was die neue Fassung nicht mehr enthält, wird entfernt.</p>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
<button id="mp-do" class="btn btn-primary hidden">Jetzt aktualisieren</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Serverliste ===================== --> <!-- ===================== Dialog: Serverliste ===================== -->
<div id="modal-servers" class="modal hidden"> <div id="modal-servers" class="modal hidden">
<div class="modal-card modal-lg"> <div class="modal-card modal-lg">
@@ -861,6 +985,22 @@
</div> </div>
</div> </div>
<!-- ===================== Dialog: Lizenz ===================== -->
<div id="modal-license" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3>Lizenz</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<pre id="license-text" class="crash-voll">Lade …</pre>
</div>
<div class="modal-foot">
<button class="btn" data-close>Schließen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Update ===================== --> <!-- ===================== 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">
@@ -904,6 +1044,7 @@
<button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button> <button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
<button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button> <button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
<button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button> <button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
</div> </div>
<div id="import-pane-zip" class="import-pane"> <div id="import-pane-zip" class="import-pane">
@@ -912,8 +1053,41 @@
</div> </div>
<div id="import-pane-modpack" class="import-pane hidden"> <div id="import-pane-modpack" class="import-pane hidden">
<p class="import-copy">Importiert ein Modpack als neue Instanz.</p> <p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
<p class="hint">Unterstützt werden Modrinth (.mrpack oder ZIP mit modrinth.index.json) und CurseForge (ZIP mit manifest.json). Für CurseForge ist ein eigener API-Key in den Netzwerk-Einstellungen nötig.</p> <p class="hint">Katalog-Suche läuft über Modrinth oder CurseForge. Für CurseForge wird ein eigener API-Key in den Netzwerk-Einstellungen gebraucht (die Weiterleitung reicht nur für den Datei-Import). Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json).</p>
<div class="field-row" style="margin-top:12px;align-items:flex-end">
<label class="field" style="width:150px">
<span>Quelle</span>
<select id="mp-source">
<option value="modrinth">Modrinth</option>
<option value="curseforge">CurseForge</option>
</select>
</label>
<label class="field" style="flex:1">
<span>Suche</span>
<input id="mp-query" type="text" placeholder="z. B. Fabulously Optimized, Create …" autocomplete="off" />
</label>
<label class="field" style="width:140px">
<span>MC-Version <small class="opt">(opt.)</small></span>
<input id="mp-mc" type="text" placeholder="1.21.1" autocomplete="off" />
</label>
<button id="mp-search-btn" class="btn btn-primary">Suchen</button>
</div>
<div class="field-row" style="margin-top:8px">
<label class="field">
<span>Sortierung</span>
<select id="mp-sort">
<option value="relevance">Relevanz</option>
<option value="downloads">Downloads</option>
<option value="follows">Follower</option>
<option value="newest">Neu</option>
<option value="updated">Aktualisiert</option>
</select>
</label>
</div>
<div id="mp-status" class="hint" style="margin-top:8px">Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.</div>
<div id="mp-results" class="mods-list" style="margin-top:10px;max-height:280px;overflow:auto"></div>
</div> </div>
<div id="import-pane-mc" class="import-pane hidden"> <div id="import-pane-mc" class="import-pane hidden">
@@ -987,11 +1161,51 @@
<div id="mmc-list" class="mods-list"></div> <div id="mmc-list" class="mods-list"></div>
</div> </div>
</div> </div>
<div id="import-pane-technic" class="import-pane hidden">
<p class="import-copy">Modpack direkt von der Technic-Plattform installieren oder eine bereits vorhandene Technic-Installation importieren.</p>
<div class="field-row" style="align-items:flex-end">
<label class="field" style="flex:1">
<span>Technic-Katalog durchsuchen</span>
<input id="tp-query" type="text" placeholder="z. B. Hexxit, Tekxit …" autocomplete="off" />
</label>
<button id="tp-search-btn" class="btn btn-primary">Suchen</button>
</div>
<div id="tp-status" class="hint" style="margin-top:8px">Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.</div>
<div id="tp-results" class="mods-list" style="margin-top:10px;max-height:220px;overflow:auto"></div>
<div class="modal-sep" style="margin:16px 0"></div>
<label class="field">
<span>Oder: vorhandenen Technic-Ordner importieren</span>
<div class="path-row">
<input id="technic-path" type="text" placeholder="z. B. C:\Users\DeinName\AppData\Roaming\.technic" />
<button id="technic-pick" class="btn">Wählen…</button>
<button id="technic-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="technic-status" class="hint">Leer lassen nutzt automatisch den Standardpfad unter Windows (%APPDATA%\.technic).</small>
</label>
<div id="technic-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="technic-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="technic-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Mods, Welten, Konfiguration)</span>
</label>
</div>
<div id="technic-list" class="mods-list"></div>
<p class="hint" style="margin-top:8px">Minecraft-Version und Mod-Loader werden nach bestem Wissen aus dem Pack erkannt bitte nach dem Import kurz prüfen, falls ein Pack ungewöhnlich aufgebaut ist.</p>
</div>
</div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<div id="mc-progress" class="hint hidden"></div> <div id="mc-progress" class="hint hidden"></div>
<div id="mmc-progress" class="hint hidden"></div> <div id="mmc-progress" class="hint hidden"></div>
<div id="cf-progress" class="hint hidden"></div> <div id="cf-progress" class="hint hidden"></div>
<div id="technic-progress" class="hint hidden"></div>
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button> <button class="btn" data-close>Abbrechen</button>
<button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button> <button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button>
@@ -999,6 +1213,7 @@
<button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button> <button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button> <button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button> <button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="technic-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
</div> </div>
</div> </div>
</div> </div>
+91 -13
View File
@@ -21,6 +21,7 @@ const { spawn, spawnSync } = require('child_process');
const services = require('./services'); const services = require('./services');
const loaders = require('./loaders'); const loaders = require('./loaders');
const forge = require('./forge'); const forge = require('./forge');
const processwatchdog = require('./processwatchdog');
const UA = 'AeroMC-Launcher/0.2'; const UA = 'AeroMC-Launcher/0.2';
const RESOURCES = 'https://resources.download.minecraft.net'; const RESOURCES = 'https://resources.download.minecraft.net';
@@ -253,20 +254,28 @@ async function prepareAssets(vj, sharedDir, onProgress) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function pickJava(vj, preferredPath) { function pickJava(vj, preferredPath) {
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
let preferred = null;
if (preferredPath && preferredPath.trim()) { if (preferredPath && preferredPath.trim()) {
const info = services.probeJava(preferredPath); const info = services.probeJava(preferredPath);
if (info) return { path: preferredPath, major: info.major, forced: true }; if (info) {
preferred = { path: preferredPath, major: info.major, forced: true };
if (info.major === required) return preferred;
}
} }
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
const jvms = services.scanJava(); const jvms = services.scanJava();
// exakt passende oder nächsthöhere 64-bit-Version // Mod-Loader und Mods unterstützen neuere Java-Versionen nicht automatisch.
// Deshalb ausschließlich die von Mojang angegebene Major-Version verwenden.
const x64 = jvms.filter((j) => j.arch === 'x64'); const x64 = jvms.filter((j) => j.arch === 'x64');
const exact = x64.find((j) => j.major === required); const exact = x64.find((j) => j.major === required);
if (exact) return { path: exact.path, major: exact.major }; if (exact) return { path: exact.path, major: exact.major };
const higher = x64.filter((j) => j.major >= required).sort((a, b) => a.major - b.major)[0]; const fallback = preferred || x64[0] || jvms[0];
if (higher) return { path: higher.path, major: higher.major }; return fallback ? {
const any = jvms[0]; path: fallback.path,
return any ? { path: any.path, major: any.major, mismatch: required } : null; major: fallback.major,
forced: !!fallback.forced,
mismatch: required,
} : null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -387,6 +396,14 @@ async function prepareAndLaunch(opts, onProgress) {
} }
} }
if (!java) return { ok: false, message: 'Keine Java-Installation gefunden. Bitte in den Einstellungen Java festlegen.' }; if (!java) return { ok: false, message: 'Keine Java-Installation gefunden. Bitte in den Einstellungen Java festlegen.' };
if (java.mismatch) {
return {
ok: false,
requiredJava: java.mismatch,
message: `Minecraft ${vj.id} benötigt Java ${java.mismatch}, gefunden wurde nur Java ${java.major || 'unbekannt'}. `
+ 'Bitte Auto-Java aktivieren oder eine passende Java-Installation auswählen.',
};
}
// Mod-Loader // Mod-Loader
let effectiveVj = vj; let effectiveVj = vj;
@@ -447,6 +464,37 @@ async function prepareAndLaunch(opts, onProgress) {
classpath.unshift(path.join(libDir, rel.replace(/\//g, path.sep))); classpath.unshift(path.join(libDir, rel.replace(/\//g, path.sep)));
} }
effectiveVj = mergeLoader(vj, fvj); effectiveVj = mergeLoader(vj, fvj);
} else if (opts.loader === 'optifine') {
/*
* OptiFine hat keine öffentliche Installations-API (Downloads laufen bewusst
* über eine Werbeseite). AeroMC lädt hier nichts selbst herunter der Nutzer
* hat den offiziellen Installer bereits einmal sichtbar laufen lassen (siehe
* optifine:setup/optifine:confirm in main.js), der dabei ein normales
* Versions-Profil unter <sharedDir>/versions/<loaderVersion>/ abgelegt hat,
* genau wie Fabric/Forge es tun. Wir lesen es nur noch ein und mergen es.
*/
const ofId = opts.loaderVersion;
if (!ofId) {
return { ok: false, message: 'Für diese Instanz ist keine OptiFine-Version hinterlegt. Bitte im Bearbeiten-Dialog einrichten.' };
}
const ofJsonPath = path.join(opts.sharedDir, 'versions', ofId, ofId + '.json');
if (!fs.existsSync(ofJsonPath)) {
return { ok: false, message: `OptiFine-Profil "${ofId}" wurde nicht gefunden. Bitte die Einrichtung im Bearbeiten-Dialog wiederholen.` };
}
let ofvj;
try { ofvj = JSON.parse(fs.readFileSync(ofJsonPath, 'utf8')); }
catch (err) { return { ok: false, message: 'OptiFine-Profil ist beschädigt: ' + err.message }; }
for (const lib of ofvj.libraries || []) {
const art = lib.downloads && lib.downloads.artifact;
const rel = (art && art.path) || forge.mavenPath(lib.name);
const dest = path.join(libDir, rel.replace(/\//g, path.sep));
if (!fs.existsSync(dest) && art && art.url) {
await downloadIfNeeded(art.url, dest, art.sha1 || null);
}
classpath.unshift(dest);
}
effectiveVj = mergeLoader(vj, ofvj);
} }
const finalClasspath = dedupeClasspath(classpath, opts.sharedDir); const finalClasspath = dedupeClasspath(classpath, opts.sharedDir);
@@ -478,6 +526,10 @@ async function prepareAndLaunch(opts, onProgress) {
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente) // Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
if (opts.mcMaximized) args.push('--fullscreen'); if (opts.mcMaximized) args.push('--fullscreen');
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight)); else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
// Ab 1.20: direkt einem Server beitreten (Quick Play)
if (opts.quickPlayServer) {
args.push('--quickPlayMultiplayer', String(opts.quickPlayServer));
}
ensureDir(opts.gameDir); ensureDir(opts.gameDir);
@@ -499,14 +551,33 @@ async function prepareAndLaunch(opts, onProgress) {
spawnArgs = [...w.slice(1), java.path, ...args]; spawnArgs = [...w.slice(1), java.path, ...args];
} }
const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false }); const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false });
child.stdout.on('data', (d) => emit('log', { line: d.toString() })); const shutdownWatchdog = opts.wrapperCommand && opts.wrapperCommand.trim()
child.stderr.on('data', (d) => emit('log', { line: d.toString() })); ? null
: processwatchdog.createProcessWatchdog({
isRunning: () => child.exitCode === null && child.signalCode === null,
kill: () => child.kill(),
onForce: (graceMs) => emit('log', {
line: `[Launcher] Regulärer Client-Shutdown war nach ${graceMs / 1000} Sekunden noch nicht beendet; `
+ 'verbliebenen Java-Prozess beendet.\n',
}),
onError: () => emit('log', {
line: '[Launcher] Der nach dem regulären Client-Shutdown verbliebene Java-Prozess konnte nicht beendet werden.\n',
}),
});
child.stdout.on('data', (d) => {
emit('log', { line: d.toString() });
if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stdout');
});
child.stderr.on('data', (d) => {
emit('log', { line: d.toString() });
if (shutdownWatchdog) shutdownWatchdog.feed(d, 'stderr');
});
// Ende genau einmal melden 'exit' und 'error' können beide kommen // Ende genau einmal melden 'exit' und 'error' können beide kommen
let beendetGemeldet = false; let beendetGemeldet = false;
const meldeEnde = (code) => { const meldeEnde = (code, cleanShutdown) => {
if (beendetGemeldet) return; if (beendetGemeldet) return;
beendetGemeldet = true; beendetGemeldet = true;
emit('exit', { code }); emit('exit', { code, cleanShutdown: !!cleanShutdown });
// Post-Exit-Befehl // Post-Exit-Befehl
if (opts.postExitCommand && opts.postExitCommand.trim()) { if (opts.postExitCommand && opts.postExitCommand.trim()) {
emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' }); emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' });
@@ -517,12 +588,19 @@ async function prepareAndLaunch(opts, onProgress) {
} catch { /* egal */ } } catch { /* egal */ }
} }
}; };
child.on('exit', (code) => meldeEnde(code)); child.on('exit', (code) => {
// vor end() lesen: sawCleanShutdown wird zwar nicht von end()/clear()
// zurückgesetzt, aber so bleibt die Reihenfolge unabhängig von internen Details
const cleanShutdown = !!(shutdownWatchdog && shutdownWatchdog.sawCleanShutdown);
if (shutdownWatchdog) shutdownWatchdog.end();
meldeEnde(shutdownWatchdog && shutdownWatchdog.forced ? 0 : code, cleanShutdown);
});
// ohne diesen Listener würde ein Fehlstart (z. B. falscher Java-Pfad) als // ohne diesen Listener würde ein Fehlstart (z. B. falscher Java-Pfad) als
// unbehandeltes 'error'-Ereignis den Hauptprozess beenden // unbehandeltes 'error'-Ereignis den Hauptprozess beenden
child.on('error', (err) => { child.on('error', (err) => {
if (shutdownWatchdog) shutdownWatchdog.end();
emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' }); emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' });
meldeEnde(-1); meldeEnde(-1, false);
}); });
return { ok: true, pid: child.pid, java }; return { ok: true, pid: child.pid, java };
+970 -85
View File
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
'use strict';
/*
* mcping.js "Server List Ping" (SLP), das Protokoll, das auch der
* Vanilla-Mehrspieler-Bildschirm nutzt, um MOTD/Spielerzahl/Version
* abzufragen, ohne sich wirklich zu verbinden.
*
* Ablauf (siehe minecraft.wiki/w/Server_List_Ping):
* 1) Handshake-Paket senden (Protokollversion, Adresse, Port, next state=1)
* 2) Status-Request-Paket senden (leer)
* 3) Status-Response-Paket empfangen: enthält einen JSON-String
* Alle Pakete sind mit ihrer Länge als VarInt vorangestellt.
*/
const net = require('net');
function encodeVarInt(value) {
const bytes = [];
let v = value;
do {
let temp = v & 0b01111111;
v = v >>> 7;
if (v !== 0) temp |= 0b10000000;
bytes.push(temp);
} while (v !== 0);
return Buffer.from(bytes);
}
function encodeString(str) {
const strBuf = Buffer.from(str, 'utf8');
return Buffer.concat([encodeVarInt(strBuf.length), strBuf]);
}
function encodeUShort(n) {
const b = Buffer.alloc(2);
b.writeUInt16BE(n & 0xffff, 0);
return b;
}
// liest einen VarInt aus buf ab offset -> { value, next } oder null,
// wenn noch nicht genug Bytes angekommen sind
function decodeVarInt(buf, offset) {
let numRead = 0;
let result = 0;
let read;
do {
if (offset + numRead >= buf.length) return null;
read = buf[offset + numRead];
result |= (read & 0b01111111) << (7 * numRead);
numRead++;
if (numRead > 5) throw new Error('VarInt zu groß');
} while ((read & 0b10000000) !== 0);
return { value: result >>> 0, next: offset + numRead };
}
// "description" kann ein reiner String oder eine Chat-Komponente
// ({text, extra:[...]}) sein hier auf reinen Text reduzieren
function extractMotdText(desc) {
if (!desc) return '';
if (typeof desc === 'string') return desc;
let text = String(desc.text || '');
if (Array.isArray(desc.extra)) {
for (const part of desc.extra) text += extractMotdText(part);
}
return text;
}
// Formatierungscodes (§a, §l, …) sind fürs Spiel gedacht, nicht für unsere Liste
function stripFormatting(text) {
return String(text || '').replace(/§./g, '').replace(/\s+/g, ' ').trim();
}
/*
* Fragt einen Server ab.
* -> { ok, motd, version, protocol, online, max, sample, favicon, latencyMs }
* oder { ok: false, message }
*/
function pingServer(host, port = 25565, timeoutMs = 4000) {
return new Promise((resolve) => {
const start = Date.now();
const socket = new net.Socket();
let buffer = Buffer.alloc(0);
let finished = false;
const done = (result) => {
if (finished) return;
finished = true;
try { socket.destroy(); } catch { /* egal */ }
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.on('timeout', () => done({ ok: false, message: 'Zeitüberschreitung.' }));
socket.on('error', (err) => done({ ok: false, message: err.code === 'ENOTFOUND' ? 'Adresse nicht gefunden.' : err.message }));
socket.connect(port, host, () => {
const handshakeBody = Buffer.concat([
encodeVarInt(0x00),
encodeVarInt(-1), // Protokollversion "unbekannt" für den Status-Abfrage-Zweck egal
encodeString(host),
encodeUShort(port),
encodeVarInt(1), // next state: 1 = status
]);
const handshakePacket = Buffer.concat([encodeVarInt(handshakeBody.length), handshakeBody]);
const requestBody = encodeVarInt(0x00);
const requestPacket = Buffer.concat([encodeVarInt(requestBody.length), requestBody]);
socket.write(Buffer.concat([handshakePacket, requestPacket]));
});
socket.on('data', (chunk) => {
if (finished) return;
buffer = Buffer.concat([buffer, chunk]);
try {
const lenInfo = decodeVarInt(buffer, 0);
if (!lenInfo) return; // Länge noch nicht vollständig angekommen
const packetEnd = lenInfo.next + lenInfo.value;
if (buffer.length < packetEnd) return; // Paket noch nicht vollständig da
const idInfo = decodeVarInt(buffer, lenInfo.next);
if (!idInfo) return;
const jsonLenInfo = decodeVarInt(buffer, idInfo.next);
if (!jsonLenInfo) return;
const jsonStart = jsonLenInfo.next;
const jsonEnd = jsonStart + jsonLenInfo.value;
if (buffer.length < jsonEnd) return;
const data = JSON.parse(buffer.toString('utf8', jsonStart, jsonEnd));
done({
ok: true,
latencyMs: Date.now() - start,
motd: stripFormatting(extractMotdText(data.description)),
version: (data.version && data.version.name) || '',
protocol: data.version ? data.version.protocol : null,
online: data.players ? data.players.online : null,
max: data.players ? data.players.max : null,
sample: (data.players && Array.isArray(data.players.sample))
? data.players.sample.map((p) => String(p.name || '')).filter(Boolean)
: [],
favicon: typeof data.favicon === 'string' ? data.favicon : '',
});
} catch (err) {
done({ ok: false, message: 'Ungültige Antwort: ' + err.message });
}
});
});
}
module.exports = { pingServer };
+58 -10
View File
@@ -14,17 +14,25 @@ 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'),
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion), loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
// Mods (Modrinth) // Mods (Modrinth)
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts), modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
modList: (id) => ipcRenderer.invoke('mods:list', id), curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
modInstall: (id, project) => ipcRenderer.invoke('mods:install', id, project), // art: 'mod' | 'resourcepack' | 'shader'
modRemove: (id, filename) => ipcRenderer.invoke('mods:remove', id, filename), modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
modSetEnabled: (id, filename, enabled) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled), modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
systemMemory: () => ipcRenderer.invoke('system:memory'), systemMemory: () => ipcRenderer.invoke('system:memory'),
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb), checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit), crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
@@ -37,12 +45,14 @@ contextBridge.exposeInMainWorld('api', {
serversList: (id) => ipcRenderer.invoke('servers:list', id), serversList: (id) => ipcRenderer.invoke('servers:list', id),
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag), serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip), serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag), serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
logsList: (id) => ipcRenderer.invoke('logs:list', id), logsList: (id) => ipcRenderer.invoke('logs:list', id),
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name), logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id), logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
modCheckUpdates: (id) => ipcRenderer.invoke('mods:checkUpdates', id), modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
modApplyUpdates: (id, projectIds) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds), modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
onModUpdateProgress: (cb) => { onModUpdateProgress: (cb) => {
const l = (_e, d) => cb(d); const l = (_e, d) => cb(d);
ipcRenderer.on('mods:updateProgress', l); ipcRenderer.on('mods:updateProgress', l);
@@ -62,12 +72,14 @@ contextBridge.exposeInMainWorld('api', {
// Welten & Ressourcenpakete // Welten & Ressourcenpakete
worlds: (id) => ipcRenderer.invoke('instance:worlds', id), worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id), resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name), deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub), openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id), screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name), screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name), openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
launchInstance: (id, accountId) => ipcRenderer.invoke('instances:launch', id, accountId), launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
exportInstance: (id) => ipcRenderer.invoke('instances:export', id), exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
createGlobalBackup: () => ipcRenderer.invoke('backup:global'), createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
listGlobalBackups: () => ipcRenderer.invoke('backup:list'), listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
@@ -78,14 +90,26 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('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), createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
// Start über eine Desktop-Verknüpfung ("--launch <id>") serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
onLaunchRequest: (cb) => { onLaunchRequest: (cb) => {
const l = (_e, id) => cb(id); const l = (_e, payload) => cb(payload);
ipcRenderer.on('instance:launch-request', l); ipcRenderer.on('instance:launch-request', l);
return () => ipcRenderer.removeListener('instance:launch-request', l); return () => ipcRenderer.removeListener('instance:launch-request', l);
}, },
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
notifyReady: () => ipcRenderer.send('renderer:ready'),
importModpack: () => ipcRenderer.invoke('modpack:import'), importModpack: () => ipcRenderer.invoke('modpack:import'),
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
onLaunchProgress: (cb) => { onLaunchProgress: (cb) => {
const listener = (_e, data) => cb(data); const listener = (_e, data) => cb(data);
ipcRenderer.on('launch:progress', listener); ipcRenderer.on('launch:progress', listener);
@@ -160,6 +184,21 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('mc:progress', l); ipcRenderer.on('mc:progress', l);
return () => ipcRenderer.removeListener('mc:progress', l); return () => ipcRenderer.removeListener('mc:progress', l);
}, },
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
onTechnicProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('technic:progress', l);
return () => ipcRenderer.removeListener('technic:progress', l);
},
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
onTechnicPlatformProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('technicPlatform:progress', l);
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
},
// Bedrock (Windows-Store-Ausgabe) // Bedrock (Windows-Store-Ausgabe)
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'), bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
@@ -183,6 +222,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);
@@ -193,4 +240,5 @@ contextBridge.exposeInMainWorld('api', {
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title), pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
pickJava: () => ipcRenderer.invoke('dialog:pickJava'), pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
appInfo: () => ipcRenderer.invoke('app:info'), appInfo: () => ipcRenderer.invoke('app:info'),
appLicense: () => ipcRenderer.invoke('app:license'),
}); });
+101
View File
@@ -0,0 +1,101 @@
'use strict';
const DEFAULT_GRACE_MS = 15000;
const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/;
const FATAL_OUTPUT = [
/---- Minecraft Crash Report ----/i,
/\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i,
/\b(?:This crash report has been saved to|Saving crash report to)\b/i,
/\bA fatal error has been detected by the Java Runtime Environment\b/i,
/Exception in thread "(?:Render thread|Client thread|main)"/i,
/\[(?:Render thread|Client thread|main)\/FATAL\]/i,
];
function createProcessWatchdog(options) {
const graceMs = options.graceMs || DEFAULT_GRACE_MS;
const schedule = options.setTimer || setTimeout;
const cancel = options.clearTimer || clearTimeout;
const buffers = { stdout: '', stderr: '' };
let timer = null;
let armed = false;
let crashed = false;
let ended = false;
let forced = false;
// bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen
// wurde im Gegensatz zu `armed` wird das NICHT durch markCrash()
// zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt
// weiterhin als "war ein regulärer Shutdown" erkennbar bleibt
let sawCleanShutdown = false;
function clear() {
if (timer !== null) cancel(timer);
timer = null;
armed = false;
}
function markCrash() {
crashed = true;
clear();
}
function scheduleKill() {
if (timer !== null) cancel(timer);
timer = schedule(() => {
timer = null;
if (ended || crashed || !armed || !options.isRunning()) return;
try {
forced = options.kill() === true;
} catch {
forced = false;
}
if (forced && options.onForce) options.onForce(graceMs);
else if (!forced && options.isRunning() && options.onError) options.onError();
}, graceMs);
if (timer && typeof timer.unref === 'function') timer.unref();
}
function inspectLine(line) {
const isCleanShutdown = CLEAN_SHUTDOWN.test(line);
if (isCleanShutdown) sawCleanShutdown = true;
if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) {
markCrash();
return;
}
if (ended || crashed || armed || !isCleanShutdown) return;
armed = true;
scheduleKill();
}
function feed(chunk, stream) {
if (ended) return;
const key = stream === 'stderr' ? 'stderr' : 'stdout';
const text = buffers[key] + String(chunk || '');
const lines = text.split(/\r?\n/);
buffers[key] = lines.pop() || '';
for (const line of lines) inspectLine(line);
if (armed && !crashed && !ended && lines.length) scheduleKill();
}
function end() {
ended = true;
clear();
}
return {
feed,
end,
get armed() { return armed; },
get crashed() { return crashed; },
get forced() { return forced; },
get sawCleanShutdown() { return sawCleanShutdown; },
};
}
module.exports = {
DEFAULT_GRACE_MS,
CLEAN_SHUTDOWN,
FATAL_OUTPUT,
createProcessWatchdog,
};
+1086 -73
View File
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -135,6 +135,23 @@ function entferne(gameDir, ip) {
return schreibe(gameDir, liste); return schreibe(gameDir, liste);
} }
/*
* Nur den Anzeigenamen ändern die Adresse bleibt gleich.
* ip identifiziert den Eintrag (wie beim Entfernen).
*/
function benenneUm(gameDir, ip, neuerName) {
const name = String(neuerName || '').trim();
if (!name) return { ok: false, message: 'Bitte einen Namen angeben.' };
const gelesen = lies(gameDir);
if (!gelesen.ok) return gelesen;
const liste = gelesen.server;
const treffer = liste.find((s) => s.ip.toLowerCase() === String(ip || '').toLowerCase());
if (!treffer) return { ok: false, message: 'Server nicht gefunden.' };
if (treffer.name === name) return { ok: true, unveraendert: true };
treffer.name = name;
return schreibe(gameDir, liste);
}
/* /*
* Einen Server in eine andere Instanz übertragen der eigentliche Zweck: * Einen Server in eine andere Instanz übertragen der eigentliche Zweck:
* denselben Server nicht in jeder Instanz neu eintippen zu müssen. * denselben Server nicht in jeder Instanz neu eintippen zu müssen.
@@ -143,4 +160,4 @@ function uebertrage(zielGameDir, eintrag) {
return fuegeHinzu(zielGameDir, eintrag); return fuegeHinzu(zielGameDir, eintrag);
} }
module.exports = { lies, schreibe, fuegeHinzu, entferne, uebertrage, pruefeAdresse, datei }; module.exports = { lies, schreibe, fuegeHinzu, entferne, benenneUm, uebertrage, pruefeAdresse, datei };
+365 -18
View File
@@ -227,10 +227,31 @@ 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 },
datapack: { projectType: 'datapack', ordner: 'datapacks', label: 'Datenpakete', loaderNoetig: false },
modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false },
};
function inhaltsArt(art) {
return INHALTSARTEN[art] || INHALTSARTEN.mod;
}
async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0, art = 'mod' } = {}) {
const typ = inhaltsArt(art);
const facets = [['project_type:' + typ.projectType]];
if (gameVersion) facets.push(['versions:' + gameVersion]); if (gameVersion) facets.push(['versions:' + gameVersion]);
if (loader && loader !== 'vanilla') facets.push(['categories:' + loader]); // Der Loader-Filter gilt nur für Mods. Ressourcenpakete und Shader werden
// bei Modrinth nicht nach Mod-Loader eingeordnet der Filter würde die
// Trefferliste leeren.
if (typ.loaderNoetig && loader && loader !== 'vanilla') facets.push(['categories:' + loader]);
if (category) facets.push(['categories:' + category]); if (category) facets.push(['categories:' + category]);
const url = new URL(MODRINTH + '/search'); const url = new URL(MODRINTH + '/search');
url.searchParams.set('query', query); url.searchParams.set('query', query);
@@ -251,17 +272,24 @@ async function modrinthSearch({ query = '', gameVersion = '', loader = '', categ
description: h.description, description: h.description,
author: h.author, author: h.author,
downloads: h.downloads, downloads: h.downloads,
follows: h.follows || 0,
iconUrl: h.icon_url || '', iconUrl: h.icon_url || '',
categories: h.categories || [], categories: h.categories || [],
// bei Modpacks hilfreich: welche Spielversionen/Loader das Projekt angibt
versions: h.versions || [],
displayCategories: h.display_categories || h.categories || [],
})), })),
}; };
} }
// findet die passende Datei-Version für Spielversion + Loader // findet die passende Datei-Version für Spielversion + Loader
async function modrinthResolveFile({ projectId, gameVersion, loader }) { async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod' }) {
const typ = inhaltsArt(art);
const url = new URL(MODRINTH + '/project/' + projectId + '/version'); const url = new URL(MODRINTH + '/project/' + projectId + '/version');
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion])); if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
if (loader && loader !== 'vanilla') url.searchParams.set('loaders', JSON.stringify([loader])); // Nur Mods werden nach Loader eingeordnet; bei Ressourcenpaketen und
// Shadern würde der Filter alle Dateien ausschließen.
if (typ.loaderNoetig && loader && loader !== 'vanilla') url.searchParams.set('loaders', JSON.stringify([loader]));
const res = await fetch(url, { headers: { 'User-Agent': UA } }); const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status); if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status);
const versions = await res.json(); const versions = await res.json();
@@ -280,6 +308,38 @@ async function modrinthResolveFile({ projectId, gameVersion, loader }) {
}; };
} }
/*
* Neueste passende Modpack-Version von Modrinth.
* gameVersion optional ohne Filter kommt die neueste Version.
*/
async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Modpack-Versionen fehlgeschlagen: HTTP ' + res.status);
const versions = await res.json();
if (!Array.isArray(versions) || !versions.length) return null;
const v = versions[0];
const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0];
if (!file || !file.url) return null;
return {
versionId: v.id,
versionNumber: v.version_number,
name: v.name || v.version_number,
filename: file.filename,
url: file.url,
size: file.size || 0,
gameVersions: v.game_versions || [],
loaders: v.loaders || [],
};
}
async function downloadBuffer(url, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
return Buffer.from(await res.arrayBuffer());
}
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt // extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
async function downloadFile(url, destPath, extraHeaders) { async function downloadFile(url, destPath, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
@@ -291,11 +351,14 @@ async function downloadFile(url, destPath, extraHeaders) {
} }
// lädt eine Mod in <modsDir>; gibt Metadaten für die Instanz zurück // lädt eine Mod in <modsDir>; gibt Metadaten für die Instanz zurück
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir }) { async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
const file = await modrinthResolveFile({ projectId, gameVersion, loader }); const typ = inhaltsArt(art);
const file = await modrinthResolveFile({ projectId, gameVersion, loader, art });
if (!file) { if (!file) {
return { ok: false, reason: 'no-match', return { ok: false, reason: 'no-match',
message: `Keine passende Version für ${gameVersion} / ${loader} gefunden.` }; message: typ.loaderNoetig
? `Keine passende Version für ${gameVersion} / ${loader} gefunden.`
: `Keine passende Version für ${gameVersion} gefunden.` };
} }
const dest = path.join(modsDir, file.filename); const dest = path.join(modsDir, file.filename);
const bytes = await downloadFile(file.url, dest); const bytes = await downloadFile(file.url, dest);
@@ -322,7 +385,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir
* für die weiß niemand, woher sie stammen. * für die weiß niemand, woher sie stammen.
* -> [{ projectId, title, alt: {...}, neu: {...} }] * -> [{ projectId, title, alt: {...}, neu: {...} }]
*/ */
async function modrinthFindUpdates(mods, gameVersion, loader, onProgress) { async function modrinthFindUpdates(mods, gameVersion, loader, onProgress, art = 'mod') {
const kandidaten = (mods || []).filter((m) => m && m.projectId); const kandidaten = (mods || []).filter((m) => m && m.projectId);
const treffer = []; const treffer = [];
let geprueft = 0; let geprueft = 0;
@@ -333,8 +396,8 @@ async function modrinthFindUpdates(mods, gameVersion, loader, onProgress) {
await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => { await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => {
let file = null; let file = null;
try { try {
file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader }); file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader, art });
} catch { /* Netzfehler -> diese Mod überspringen */ } } catch { /* Netzfehler -> diesen Eintrag überspringen */ }
geprueft++; geprueft++;
if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId }); if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId });
if (!file || !file.versionId) return; if (!file || !file.versionId) return;
@@ -383,16 +446,42 @@ const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || '');
* kein fremdes Kontingent. * kein fremdes Kontingent.
* -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null * -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null
*/ */
/*
* CurseForge kennt zwei Schlüsselarten:
* - Klassisch (console.curseforge.com): Header x-api-key, oft $2a$10$…
* - Studios / Profil-PAT: Header "Authorization: Key cfc_pat_…"
*/
function cfAuthHeaders(apiKey) {
let key = String(apiKey || '').trim();
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
key = key.replace(/[\r\n\t]/g, '').trim();
// Komplette Kopfzeile eingefügt?
const m = key.match(/^authorization\s*:\s*key\s+(.+)$/i);
if (m) key = m[1].trim();
if (key.toLowerCase().startsWith('key ')) key = key.slice(4).trim();
if (!key) return null;
// Neues Studios-Format
if (/^cfc_pat_/i.test(key) || /^cf[a-z]_/i.test(key)) {
return { key, headers: { Authorization: 'Key ' + key } };
}
// Klassisch
return { key, headers: { 'x-api-key': key } };
}
function curseforgeZugang(settings) { function curseforgeZugang(settings) {
const s = settings || {}; const s = settings || {};
const key = String(s.curseforgeApiKey || '').trim(); const auth = cfAuthHeaders(s.curseforgeApiKey);
if (key) { if (auth) {
return { return {
modus: 'direkt', modus: 'direkt',
basis: CURSEFORGE, basis: CURSEFORGE,
kopfzeilen: { 'x-api-key': key }, kopfzeilen: auth.headers,
downloadAdresse: (url) => url, downloadAdresse: (url) => url,
downloadKopfzeilen: { 'x-api-key': key }, downloadKopfzeilen: auth.headers,
}; };
} }
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, ''); const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
@@ -426,6 +515,263 @@ function forgeCdnUrl(fileId, fileName) {
return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`; return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`;
} }
const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 };
// classId je Inhaltsart bei gameId 432 (Minecraft). modpack=4471 ist die auf
// CurseForge selbst verwendete Kategorie-ID (siehe curseforge.com/minecraft/modpacks).
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552, modpack: 4471 };
function cfLoaderType(loader) {
return CF_LOADER[String(loader || '').toLowerCase()] || 0;
}
/*
* 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.',
};
}
if (!CF_CLASS[art]) {
// Kein zugeordneter CurseForge-Klassencode für diese Inhaltsart -> lieber gar
// keine Treffer als falsch einsortierte (z. B. Mods unter "Datenpakete").
return { total: 0, hits: [], error: 'CurseForge unterstützt diese Inhaltsart hier nicht bitte Modrinth nutzen.' };
}
const classId = CF_CLASS[art];
const url = new URL(CURSEFORGE + '/mods/search');
url.searchParams.set('gameId', '432');
url.searchParams.set('classId', String(classId));
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 || [],
};
}
// Beste Modpack-Datei eines CurseForge-Projekts auflösen (für den Katalog-Import,
// analog zu modrinthResolveModpack).
async function curseforgeResolveModpackFile(zugang, modId, gameVersion) {
return curseforgePickFile(zugang, modId, gameVersion, '', 'modpack');
}
async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' };
const rawId = String(projectId || '').replace(/^cf-/, '');
const modId = Number(rawId);
if (!modId) return { ok: false, message: 'Ungültige CurseForge-Projekt-ID.' };
const file = await curseforgePickFile(zugang, modId, gameVersion, loader, art);
if (!file || !file.url) {
return {
ok: false,
reason: 'no-match',
message: `Keine passende Datei für ${gameVersion || '?'} / ${loader || 'any'} gefunden.`,
};
}
const dest = path.join(modsDir, file.filename);
// Download: direkt mit Key oder über Weiterleitung
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
const bytes = await downloadFile(url, dest, headers);
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
const depIds = (file.dependencies || [])
.filter((d) => d.relationType === 3 && d.modId)
.map((d) => d.modId);
return {
ok: true,
dependencies: depIds.map((id) => ({ projectId: 'cf-' + id, required: true })),
entry: {
projectId: 'cf-' + modId,
title: title || file.filename,
filename: file.filename,
versionId: String(file.fileId),
versionNumber: file.versionNumber,
gameVersion,
loader,
source: 'curseforge',
bytes,
},
};
}
/* /*
* Löst die Dateien eines CurseForge-Modpacks auf. * Löst die Dateien eines CurseForge-Modpacks auf.
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }] * zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
@@ -479,7 +825,8 @@ async function modrinthProjectTitle(projectId) {
module.exports = { module.exports = {
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher, scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
getMcVersions, getMcVersions,
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle, modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
modrinthFindUpdates, downloadFile, modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
curseforgeResolveFiles, forgeCdnUrl, curseforgeZugang, curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
curseforgeResolveModpackFile,
}; };
+59 -18
View File
@@ -75,9 +75,13 @@ 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
autostart: false, // mit Windows starten, im Infobereich statt als Fenster
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist) bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk
proxyHost: '', proxyHost: '',
@@ -124,12 +128,29 @@ 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', datapack: 'datapacks',
};
const INHALT_INDEX = {
mod: 'mods.json', resourcepack: 'resourcepacks.json', shader: 'shaders.json', datapack: 'datapacks.json',
};
function inhaltsDir(id, art = 'mod') {
return path.join(gameDir(id), INHALT_ORDNER[art] || INHALT_ORDNER.mod);
} }
function modsIndexFile(id) { function modsDir(id, art = 'mod') {
return path.join(instancePath(id), 'mods.json'); return inhaltsDir(id, art);
}
function modsIndexFile(id, art = 'mod') {
return path.join(instancePath(id), INHALT_INDEX[art] || INHALT_INDEX.mod);
} }
// ---------- installierte Mods (Metadaten je Instanz) ---------- // ---------- installierte Mods (Metadaten je Instanz) ----------
@@ -142,8 +163,8 @@ function modsIndexFile(id) {
const MOD_ENDUNG = /\.(jar|zip)$/i; const MOD_ENDUNG = /\.(jar|zip)$/i;
const AUS_ENDUNG = /\.(jar|zip)\.disabled$/i; const AUS_ENDUNG = /\.(jar|zip)\.disabled$/i;
function scanModsDir(id) { function scanModsDir(id, art = 'mod') {
const dir = modsDir(id); const dir = modsDir(id, art);
if (!fs.existsSync(dir)) return []; if (!fs.existsSync(dir)) return [];
const gefunden = []; const gefunden = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -163,9 +184,9 @@ function scanModsDir(id) {
return gefunden; return gefunden;
} }
function listMods(id) { function listMods(id, art = 'mod') {
const aufDerPlatte = new Map(scanModsDir(id).map((m) => [m.filename, m])); const aufDerPlatte = new Map(scanModsDir(id, art).map((m) => [m.filename, m]));
const indexed = readJson(modsIndexFile(id), []); const indexed = readJson(modsIndexFile(id, art), []);
const merged = new Map(); const merged = new Map();
for (const entry of indexed) { for (const entry of indexed) {
@@ -186,8 +207,8 @@ function listMods(id) {
* Schaltet eine Mod an oder aus, indem die Datei umbenannt wird. * Schaltet eine Mod an oder aus, indem die Datei umbenannt wird.
* -> { ok, enabled } oder { ok: false, reason } * -> { ok, enabled } oder { ok: false, reason }
*/ */
function setModEnabled(id, filename, enabled) { function setModEnabled(id, filename, enabled, art = 'mod') {
const dir = modsDir(id); const dir = modsDir(id, art);
const anPfad = path.join(dir, filename); const anPfad = path.join(dir, filename);
const ausPfad = anPfad + '.disabled'; const ausPfad = anPfad + '.disabled';
// Pfad darf den mods-Ordner nicht verlassen // Pfad darf den mods-Ordner nicht verlassen
@@ -212,18 +233,36 @@ function setModEnabled(id, filename, enabled) {
} }
} }
function addMod(id, entry) { function addMod(id, entry, art = 'mod') {
const list = listMods(id).filter((m) => m.filename !== entry.filename); const list = listMods(id, art).filter((m) => m.filename !== entry.filename);
list.push(entry); list.push(entry);
writeJson(modsIndexFile(id), list); writeJson(modsIndexFile(id, art), list);
return list; return list;
} }
function removeMod(id, filename) { /*
const list = listMods(id).filter((m) => m.filename !== filename); * Findet installierte Mods, die den per "filename" identifizierten Mod als
writeJson(modsIndexFile(id), list); * Pflicht-Abhängigkeit brauchen (requiredDeps wird beim Installieren über
* AeroMC gesetzt von Hand hinzugefügte oder vor diesem Feature installierte
* Mods haben keine requiredDeps und werden daher nicht als Ziel erkannt,
* können aber selbst weiterhin als Abhängiger auftauchen).
* -> [Titel, Titel, ...] der abhängigen Mods
*/
function findDependents(id, filename, art = 'mod') {
const list = listMods(id, art);
const target = list.find((m) => m.filename === filename);
if (!target || !target.projectId) return [];
return list
.filter((m) => m.filename !== filename
&& Array.isArray(m.requiredDeps) && m.requiredDeps.includes(target.projectId))
.map((m) => m.title || m.filename);
}
function removeMod(id, filename, art = 'mod') {
const list = listMods(id, art).filter((m) => m.filename !== filename);
writeJson(modsIndexFile(id, art), list);
// zugehörige Datei löschen auch, wenn sie gerade abgeschaltet ist // zugehörige Datei löschen auch, wenn sie gerade abgeschaltet ist
const jar = path.join(modsDir(id), filename); const jar = path.join(modsDir(id, art), filename);
for (const p of [jar, jar + '.disabled']) { for (const p of [jar, jar + '.disabled']) {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* egal */ } try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* egal */ }
} }
@@ -423,12 +462,14 @@ module.exports = {
instancePath, instancePath,
gameDir, gameDir,
modsDir, modsDir,
inhaltsDir,
iconDataUri, iconDataUri,
setCustomImage, setCustomImage,
clearCustomImage, clearCustomImage,
listMods, listMods,
addMod, addMod,
removeMod, removeMod,
findDependents,
setModEnabled, setModEnabled,
listInstances, listInstances,
getInstance, getInstance,
+30 -2
View File
@@ -24,6 +24,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
--text:#e4e4e6; --dim:#9a9aa2; --text:#e4e4e6; --dim:#9a9aa2;
--sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb; --sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
--danger:#e06c6c; --play:#5cb85c; --danger:#e06c6c; --play:#5cb85c;
--input:#1e1e1e;
} }
:root[data-theme="dark"] body{background:var(--win)} :root[data-theme="dark"] body{background:var(--win)}
:root[data-theme="dark"] .search-wrap input, :root[data-theme="dark"] .search-wrap input,
@@ -65,7 +66,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
:root[data-theme="dark"] .field>span{color:var(--text)} :root[data-theme="dark"] .field>span{color:var(--text)}
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc} :root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc} :root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
:root[data-theme="dark"] .mods-filters select{background:#1e1e1e;color:var(--text);border-color:var(--border)} :root[data-theme="dark"] .mods-filters select,
:root[data-theme="dark"] .mods-search select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)} :root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
:root[data-theme="dark"] .modal-close{color:#aaa} :root[data-theme="dark"] .modal-close{color:#aaa}
:root[data-theme="dark"] .modal-close:hover{color:#fff} :root[data-theme="dark"] .modal-close:hover{color:#fff}
@@ -139,6 +141,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px} .group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px}
.card.dragging{opacity:.4} .card.dragging{opacity:.4}
/* Datei von außen übers Fenster gezogen (.jar/.mrpack/.zip) */
body.file-drop-active::after{
content:"Datei loslassen zum Importieren";
position:fixed;inset:0;z-index:9999;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);
color:#fff;font-size:20px;font-weight:600;
border:3px dashed var(--accent);
pointer-events:none;
}
/* ---------- Leerer Zustand ---------- */ /* ---------- Leerer Zustand ---------- */
.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px} .empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px}
.empty h2{color:#333;margin:0} .empty h2{color:#333;margin:0}
@@ -160,6 +173,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* ---------- Modals ---------- */ /* ---------- Modals ---------- */
.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50} .modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50}
/* Bestätigungsdialog immer über anderen Dialogen (z. B. Screenshots) */
#modal-confirm,#modal-prompt{z-index:120}
.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden} .modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}
.modal-sm{width:420px}.modal-lg{width:820px} .modal-sm{width:420px}.modal-lg{width:820px}
#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)} #modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)}
@@ -224,9 +239,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* Mods */ /* Mods */
.mods-body{min-height:420px} .mods-body{min-height:420px}
.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px} .mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px}
.mods-search{display:flex;gap:8px;margin-bottom:10px} .mods-search{display:flex;gap:8px;margin-bottom:10px;align-items:stretch}
.mods-search select{
flex:0 0 auto;min-width:140px;max-width:180px;
padding:7px 10px;border:1px solid var(--border);border-radius:3px;
background:var(--input,#fff);color:var(--text);
font-family:inherit;font-size:12px;
}
.mods-search select:focus{outline:none;border-color:var(--accent)}
.mods-filters{display:flex;gap:8px;margin-bottom:12px} .mods-filters{display:flex;gap:8px;margin-bottom:12px}
.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px} .mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
.mods-filters select:focus{outline:none;border-color:var(--accent)}
#m-more{display:block;width:100%;margin-top:8px} #m-more{display:block;width:100%;margin-top:8px}
.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)} .mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)}
.mods-search input:focus{outline:none;border-color:var(--accent)} .mods-search input:focus{outline:none;border-color:var(--accent)}
@@ -311,6 +334,10 @@ img.account-av{object-fit:cover;image-rendering:auto}
.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer} .screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer}
.screen-thumb:hover .screen-del{opacity:1} .screen-thumb:hover .screen-del{opacity:1}
.screen-thumb.selected{outline:2px solid var(--sel-br);outline-offset:1px;background:var(--sel)}
.screen-thumb .screen-check{position:absolute;top:4px;left:4px;width:18px;height:18px;margin:0;cursor:pointer;z-index:2}
.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
.screens-toolbar .hint{font-size:11px}
.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1} .screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
/* Konsole (dunkel wie ein Log) */ /* Konsole (dunkel wie ein Log) */
@@ -348,6 +375,7 @@ img.account-av{object-fit:cover;image-rendering:auto}
.info-card{max-width:760px} .info-card{max-width:760px}
.info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--border2)} .info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--border2)}
.info-status{font-size:12px;color:var(--dim)} .info-status{font-size:12px;color:var(--dim)}
.modal-sep{border-top:1px solid var(--border2)}
.info-meta{margin-top:10px} .info-meta{margin-top:10px}
.info-meta .settings-kv-row{align-items:center} .info-meta .settings-kv-row{align-items:center}
.info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px} .info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px}
+175
View File
@@ -0,0 +1,175 @@
'use strict';
/*
* technicdiscover.js Technic-Modpacks direkt über die Technic-Plattform
* durchsuchen und installieren, ohne vorher manuell eine Datei herunterladen
* zu müssen. Genutzt wird dieselbe öffentliche API wie bei Prism Launcher:
*
* Suche: GET https://api.technicpack.net/search?build=multimc&q=<term>
* Beliebt: GET https://api.technicpack.net/trending?build=multimc
* Pack-Infos: GET https://api.technicpack.net/modpack/<slug>?build=multimc
* -> entweder "url" (ein fertiges ZIP) oder "solder" (Solder-Server)
* Solder: GET <solder>modpack/<slug> Liste der Builds
* GET <solder>modpack/<slug>/<build> Mods dieser Fassung
* { minecraft, forge, mods: [{ name, url, ... }] }
*
* "build=multimc" ist die Kennung, mit der sich auch andere Drittlauncher
* bei der Technic-Plattform melden kein echter Minecraft-Build, sondern
* nur ein Client-Identifier, den die API erwartet.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const forge = require('./forge');
const technicimport = require('./technicimport');
const API_BASE = 'https://api.technicpack.net/';
const API_BUILD = 'multimc';
const UA = 'AeroMC-Launcher/1.0';
async function getJson(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function getBuffer(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return Buffer.from(await res.arrayBuffer());
}
/*
* query leer -> beliebte Packs (trending), sonst Suchtreffer.
* -> [{ slug, name, iconUrl }]
*/
async function search(query) {
const q = String(query || '').trim();
const url = q
? `${API_BASE}search?build=${API_BUILD}&q=${encodeURIComponent(q)}`
: `${API_BASE}trending?build=${API_BUILD}`;
const data = await getJson(url);
const packs = Array.isArray(data.modpacks) ? data.modpacks : [];
return packs
.filter((p) => p && p.slug && p.slug !== 'vanilla')
.map((p) => ({ slug: p.slug, name: p.name || p.slug, iconUrl: p.iconUrl || '' }));
}
/*
* Entpackt einen ZIP-Buffer nach destDir.
* Enthält der ZIP-Inhalt bereits eine erkennbare Ordnerstruktur (mods/,
* config/, …), wird sie 1:1 übernommen. Liegen nur einzelne Dateien direkt
* im ZIP-Wurzelverzeichnis (typisch für ältere, einzeln gepackte Solder-
* Mods), landen sie stattdessen im mods-Ordner.
*/
function extractZipBuffer(buf, destDir) {
const idx = forge.readZipIndex(buf);
const namen = Object.keys(idx);
const hatOrdnerStruktur = namen.some((n) => /^(mods|config|resources|resourcepacks|scripts|structures|bin)\//i.test(n));
for (const name of namen) {
if (name.endsWith('/')) continue; // reines Verzeichnis wird beim Schreiben automatisch angelegt
const relPfad = hatOrdnerStruktur ? name : path.posix.join('mods', name);
const zielPfad = path.join(destDir, ...relPfad.split('/'));
if (!zielPfad.startsWith(destDir + path.sep)) continue; // Pfad-Ausbruch verhindern
fs.mkdirSync(path.dirname(zielPfad), { recursive: true });
fs.writeFileSync(zielPfad, forge.readZipEntry(buf, idx[name]));
}
}
// Solder-Basis-URLs enden mal mit "/api/", mal ohne abschließenden Slash
function solderUrl(basis, pfad) {
const b = String(basis || '').replace(/\/+$/, '');
return `${b}/${pfad}`;
}
async function installSolderBuild(solderBasis, slug, gameDir, onProgress) {
const info = await getJson(solderUrl(solderBasis, 'modpack/' + encodeURIComponent(slug)));
const build = info.recommended || info.latest || (info.builds || [])[0];
if (!build) throw new Error('Keine Fassung (Build) für dieses Modpack gefunden.');
const daten = await getJson(solderUrl(solderBasis, `modpack/${encodeURIComponent(slug)}/${encodeURIComponent(build)}`));
const mods = Array.isArray(daten.mods) ? daten.mods : [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i];
if (onProgress) onProgress({ current: i + 1, total: mods.length, name: mod.name || '' });
if (!mod || !mod.url) continue;
try {
const buf = await getBuffer(mod.url);
extractZipBuffer(buf, gameDir);
} catch { /* einzelner Mod fehlgeschlagen Rest trotzdem weiter versuchen */ }
}
let loader = 'vanilla';
const loaderVersion = daten.forge || '';
if (loaderVersion) loader = 'forge';
return { version: daten.minecraft || '', loader, loaderVersion };
}
async function installSingleZip(url, gameDir) {
const buf = await getBuffer(url);
// in einen Temp-Ordner entpacken, damit wir wie beim Ordner-Import
// (technicimport.js) Version/Loader aus bin/modpack.jar erkennen können,
// bevor die Dateien an ihren endgültigen Platz kommen
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'technic-dl-'));
try {
extractZipBuffer(buf, tmp);
let base = tmp;
if (!fs.existsSync(path.join(base, 'bin')) && !fs.existsSync(path.join(base, 'mods'))) {
// manche Packs haben einen umschließenden Ordner im ZIP
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
.find((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
if (sub) base = sub;
}
const { version, loader, loaderVersion } = technicimport.detectVersion(base);
fs.cpSync(base, gameDir, { recursive: true });
return { version, loader, loaderVersion };
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
}
}
/*
* Lädt ein Modpack von der Technic-Plattform und legt daraus eine neue
* Instanz an. onProgress(info) wird bei Solder-Packs pro Mod aufgerufen.
* -> { ok, instance } oder { ok: false, message }
*/
async function installFromPlatform(store, slug, onProgress) {
let created = null;
try {
const detail = await getJson(`${API_BASE}modpack/${encodeURIComponent(slug)}?build=${API_BUILD}`);
// die Technic-API antwortet bei unbekanntem Slug mit HTTP 200 und
// {"error": "..."} statt einem Fehlerstatus daher explizit prüfen
if (detail.error) throw new Error(detail.error);
created = store.createOrReplaceImportedInstance({
name: detail.displayName || detail.name || slug,
notes: detail.description || '',
});
const gameDir = store.gameDir(created.id);
fs.mkdirSync(gameDir, { recursive: true });
let ergebnis;
if (detail.solder) {
ergebnis = await installSolderBuild(detail.solder, slug, gameDir, onProgress);
} else if (detail.url) {
ergebnis = await installSingleZip(detail.url, gameDir);
} else {
throw new Error('Für dieses Modpack ist kein Download hinterlegt.');
}
store.updateInstance(created.id, {
minecraft: {
version: ergebnis.version || detail.minecraft || '',
loader: ergebnis.loader || 'vanilla',
loaderVersion: ergebnis.loaderVersion || '',
},
});
return { ok: true, instance: store.getInstance(created.id) };
} catch (err) {
// fehlgeschlagene Installation nicht als leere Karteileiche stehen lassen
if (created) { try { store.deleteInstance(created.id); } catch { /* egal */ } }
return { ok: false, message: 'Installation fehlgeschlagen: ' + err.message };
}
}
module.exports = { search, installFromPlatform };
+172
View File
@@ -0,0 +1,172 @@
'use strict';
/*
* technicimport.js Instanzen aus dem Technic Launcher übernehmen
* -------------------------------------------------------------------
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner Welten,
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
* bei einem klassischen .minecraft.
*
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
* schönen Anzeigenamen "installedPacks" im Wurzelordner listet Packs nur
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft wie bei den
* anderen Importern über die tatsächlich vorhandenen Ordner/Dateien.
*/
const fs = require('fs');
const path = require('path');
const forge = require('./forge');
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
const SKIP_DIRS = new Set(['bin']);
function technicRoot() {
return path.join(process.env.APPDATA || '', '.technic');
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
catch { return null; }
}
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
// oder ein einzelner Pack-Ordner angegeben wurde
function resolveModpacksDir(inputPath) {
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
if (!fs.existsSync(root)) return null;
const direct = path.join(root, 'modpacks');
if (fs.existsSync(direct)) return direct;
if (path.basename(root).toLowerCase() === 'modpacks') return root;
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
}
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
function looksLikePack(dir) {
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
return markers.some((name) => fs.existsSync(path.join(dir, name)));
}
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
// als stabile API dokumentiert ist
function readInstalledPacksMeta(root) {
const data = readJson(path.join(root, 'installedPacks'));
const packs = data && data.installedPacks;
return packs && typeof packs === 'object' ? packs : {};
}
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
// bestmöglich schlägt die Erkennung fehl, bleibt die Instanz einfach
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
function detectVersion(packDir) {
const bin = path.join(packDir, 'bin');
const jarFile = path.join(bin, 'modpack.jar');
if (fs.existsSync(jarFile)) {
try {
const buf = fs.readFileSync(jarFile);
const idx = forge.readZipIndex(buf);
const entry = idx['version.json'];
if (entry) {
// manche Jars speichern JSON mit BOM ohne Entfernen bricht JSON.parse
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
const profile = JSON.parse(text);
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
let loader = 'vanilla';
let loaderVersion = '';
for (const lib of libs) {
const name = String((lib && lib.name) || '');
if (/net\.minecraftforge:forge:/i.test(name)) {
loader = 'forge';
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
loader = 'neoforge';
loaderVersion = name.split(':')[2] || '';
}
}
const id = String(profile.id || profile.inheritsFrom || '');
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
if (version) return { version, loader, loaderVersion };
}
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP weiter unten Fallback */ }
}
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
const legacy = path.join(bin, 'version');
try {
if (fs.existsSync(legacy)) {
const version = fs.readFileSync(legacy, 'utf8').trim();
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
}
} catch { /* egal, bleibt leer */ }
return { version: '', loader: 'vanilla', loaderVersion: '' };
}
function scan(inputPath) {
const modpacksDir = resolveModpacksDir(inputPath);
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
? path.dirname(modpacksDir) : modpacksDir;
const meta = readInstalledPacksMeta(root);
const list = [];
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(modpacksDir, entry.name);
if (!looksLikePack(dir)) continue;
const { version, loader, loaderVersion } = detectVersion(dir);
const info = meta[entry.name] || {};
list.push({
folder: entry.name,
dir,
name: info.displayName || entry.name,
group: '',
notes: '',
version,
loader,
loaderVersion,
gameDir: dir,
hasGameData: true,
});
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
}
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
if (SKIP_DIRS.has(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
}
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
notes: entry.notes,
minecraft: {
version: entry.version,
loader: entry.loader,
loaderVersion: entry.loaderVersion,
},
});
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion };
+10 -1
View File
@@ -230,8 +230,17 @@ async function downloadAndInstall(asset, onProgress) {
const dir = updateDir(); const dir = updateDir();
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
const dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_')); let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
try {
fs.rmSync(dest, { force: true }); fs.rmSync(dest, { force: true });
} catch {
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
const parsed = path.parse(dest);
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
}
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } }); 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);