4 Commits
1.0.3 ... main

Author SHA1 Message Date
Git Manager GUI
129ab2d939 Upload via GUI (28 Dateien) 2026-08-02 21:56:31 +02:00
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
13 changed files with 1504 additions and 190 deletions

177
README.md
View File

@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
Aktueller Stand: Version 1.0.3
Aktueller Stand: Version 1.0.4
</div>
@@ -20,86 +20,167 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Beliebig viele getrennte Minecraft-Instanzen
- Unterstützung für Vanilla, Fabric, Quilt, Forge und NeoForge
- Modrinth-Integration mit einfacher Mod-Installation inklusive Pflicht-Abhängigkeiten
- Versionsliste mit Filter: Releases, Snapshots oder alle
- Modrinth-Integration für **Mods, Ressourcenpakete und Shader** (inkl. Pflicht-Abhängigkeiten und Update-Prüfung)
- Microsoft-Login mit mehreren Konten
- Gemeinsamer Import-Dialog für AeroMC-ZIP, Modrinth-Modpacks, den Minecraft Launcher, CurseForge sowie MultiMC- und Prism-Instanzen
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen statt Dubletten anzulegen
- Export, Kopieren, Gruppieren und Verwalten von Instanzen
- Mehrfachauswahl mit Strg-Klick für Sammellöschen mehrerer Instanzen
- Globales Backup und Wiederherstellung für alle Instanzen direkt im Einstellungsbereich
- Verwaltung von Welten, Screenshots und Spielordnern direkt aus dem Launcher
- Bedrock-Bereich für Start und Verwaltung vorhandener Bedrock-Daten unter Windows
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge sowie MultiMC/Prism
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen
- Export, Kopieren, Gruppieren, Löschen und Desktop-Verknüpfung pro Instanz
- Mehrfachauswahl mit Strg-Klick für Sammellöschen
- Globales Backup inkl. automatischer Zeitplan-Sicherungen
- Welten, Ressourcenpakete, Screenshots, Serverliste und Logdateien verwalten
- Online- und Offline-Start
- Eingebaute Konsole/Log-Ansicht
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, einstellbare Deckkraft)
- Eigene Icons (inkl. eigenes Bild) und Notizen pro Instanz
- Java pro Instanz oder global, inkl. automatischem Download fehlender Versionen (Eclipse Temurin)
- RAM-, JVM-, Pre-Launch- und Post-Exit-Einstellungen (global und pro Instanz)
- Optionaler Wrapper-Befehl und HTTP-Proxy für Downloads
- Bedrock-Bereich für vorhandene Bedrock-Installation unter Windows
- Update-Funktion für neue Launcher-Versionen
- Design Hell/Dunkel und Sortierung der Instanzen
## Installation
Es gibt zwei Varianten:
- AeroMC-Setup.exe installiert den Launcher und sorgt im Alltag für den schnelleren Start
- AeroMC-Portable.exe startet ohne Installation direkt als portable Variante
- **AeroMC-Setup.exe** installiert den Launcher, schneller Start im Alltag
- **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.
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.
Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable nur Portable-Updates. Ein automatischer Wechsel zwischen beiden Varianten findet nicht statt.
## Voraussetzungen
- Windows x64
- Eine legale Minecraft Java Edition mit Microsoft-Konto für Java-Start und Anmeldung
- Internetverbindung beim ersten Download von Minecraft-Dateien, Loadern oder Mods
- Für Bedrock-Funktionen: installierte Minecraft Bedrock Edition aus dem Microsoft Store
- Legale Minecraft Java Edition mit Microsoft-Konto
- Internetverbindung beim ersten Download von Minecraft-Dateien, Loadern oder Inhalten
- Für Bedrock: installierte Minecraft Bedrock Edition aus dem Microsoft Store
## Erste Schritte
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.
4. Minecraft-Version und bei Bedarf einen Loader auswählen.
5. Optional Mods hinzufügen oder vorhandene Instanzen bzw. Modpacks über Importieren übernehmen.
6. Instanz starten.
4. Minecraft-Version und optional einen Loader wählen.
5. Optional Mods/Ressourcenpakete/Shader hinzufügen oder bestehende Setups über **Importieren** übernehmen.
6. Instanz starten (online oder offline).
## Instanzen
Jede Instanz hat:
- Namen und optionale Gruppe
- Minecraft-Version und optionalen Mod-Loader
- optionales Icon (vorgegeben oder eigenes Bild) und Notizen
- eigene Mods, Ressourcenpakete, Shader, Welten, Screenshots und Serverliste
- eigene Java-, RAM-, JVM-, Pre-Launch- und Post-Exit-Einstellungen (leer = globale Vorgabe)
Über die Seitenleiste einer ausgewählten Instanz erreichst du u. a.:
- Starten / Offline starten
- Instanz und Notizen bearbeiten
- Mods, Ressourcenpakete und Shader
- Welten und Bildschirmfotos
- Server verwalten
- Logdateien ansehen
- 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.
## Import
Über Importieren in der oberen Leiste können mehrere Quellen übernommen werden:
Über **Importieren**:
- AeroMC-ZIP-Archive mit instance.json
- Modrinth-Modpacks im .mrpack-Format
- Profile aus dem offiziellen Minecraft Launcher, typischerweise aus C:\Users\DeinName\AppData\Roaming\.minecraft
- CurseForge-Instanzen, typischerweise aus C:\Users\DeinName\curseforge\minecraft\Instances
- MultiMC- und Prism-Instanzen
| Quelle | Typische Nutzung |
|---|---|
| ZIP-Archiv | AeroMC-Export (`instance.json`) sowie einzelne MultiMC/Prism-Instanzen (auch gezippt) |
| Modpack | Modrinth `.mrpack` |
| Minecraft Launcher | Profile aus dem offiziellen Launcher |
| CurseForge | Instanzen aus dem CurseForge-Ordner |
| MultiMC / Prism | Vorhandene MultiMC- oder Prism-Instanzen |
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.
## Mods, Ressourcenpakete und Shader
Im Inhalts-Dialog einer Instanz:
- Katalog durchsuchen (Sortierung, Kategorien)
- Installation inkl. Abhängigkeiten
- Installierte Einträge aktivieren, deaktivieren, entfernen
- 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.
## 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
- Gesichert werden alle Instanzen sowie die Launcher-Einstellungen
- Microsoft-Konten und Tokens werden bewusst nicht mitgesichert
- Vorhandene Backups werden im Wiederherstellen-Bereich direkt in einem Dropdown aufgelistet
- Manuelles globales Backup (alle Instanzen + Launcher-Einstellungen)
- Automatische Sicherungen (täglich oder wöchentlich, einstellbare Anzahl)
- Optional zusätzlich vor jedem Spielstart sichern
- Wiederherstellung aus vorhandenem Backup
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
- Instanzbezogene Mods und Konfigurationen
- Welten und Screenshots
- Mehrere Microsoft-Konten
- Java-Auswahl pro Instanz
## Einstellungen
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
Alle Nutzerdaten werden lokal unter %APPDATA%\aeromc gespeichert.
### Darstellung
- Block-Symbole räumlich (3D)
- Skin-Hintergrund: Quelle, Pose, Deckkraft
### 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 und manuelle Update-Prüfung
## 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. AeroMC installiert Bedrock nicht selbst. 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
@@ -109,9 +190,9 @@ Der Bedrock-Bereich ist für bereits installierte Bedrock-Versionen unter Window
## Rechtliches
AeroMC ist kein offizielles Produkt von Mojang oder Microsoft.
AeroMC ist kein offizielles Produkt von Mojang oder Microsoft.
Minecraft ist eine Marke von Mojang AB.
## Lizenz
MIT
MIT

4
package-lock.json generated
View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.0.3",
"version": "1.0.4",
"description": "AeroMC Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"main": "src/main.js",

View File

@@ -1,4 +1,4 @@
{
"curseforgeProxyUrl": "",
"curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J"
"curseforgeProxyToken": ""
}

View File

@@ -51,6 +51,7 @@
<button id="p-mods" class="sbtn">Mods anzeigen</button>
<button id="p-worlds" class="sbtn">Welten verwalten</button>
<button id="p-screens" class="sbtn">Bildschirmfotos verwalten</button>
<button id="p-modpack" class="sbtn hidden">Modpack aktualisieren</button>
<button id="p-logs" class="sbtn">Logdateien ansehen</button>
<button id="p-servers" class="sbtn">Server verwalten</button>
<div class="hr"></div>
@@ -157,6 +158,17 @@
</label>
</div>
<small id="f-mem-hint" class="hint"></small>
<label class="field">
<span>Java (java.exe)</span>
<div class="path-row">
<input id="f-java" type="text" placeholder="leer = globale Einstellung bzw. automatisch" />
<button id="f-java-pick" class="btn" type="button">Wählen…</button>
</div>
<div class="path-row">
<select id="f-java-detected"><option value=""> gefundene Java-Installationen </option></select>
</div>
<small id="f-java-hint" class="hint"></small>
</label>
<label class="field">
<span>Zusätzliche JVM-Argumente</span>
<input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" />
@@ -412,8 +424,12 @@
<h5>CurseForge</h5>
<label class="field">
<span>Eigener API-Key <small class="opt">(optional)</small></span>
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
<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>
<div class="path-row">
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
<button type="button" id="s-cf-test" class="btn">Prüfen</button>
</div>
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
<small id="s-cf-test-status" class="hint"></small>
</label>
<div class="field-row">
<label class="field">
@@ -556,6 +572,27 @@
</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) ===================== -->
<div id="modal-mods" class="modal hidden">
<div class="modal-card modal-lg">
@@ -573,11 +610,20 @@
<div id="m-warn" class="mods-warn hidden"></div>
<div class="mods-search">
<select id="m-art" title="Was soll gesucht werden?">
<option value="mod">Mods</option>
<option value="resourcepack">Ressourcenpakete</option>
<option value="shader">Shader</option>
</select>
<input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" />
<button id="m-search-btn" class="btn btn-primary">Suchen</button>
</div>
<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">
<option value="downloads">Beliebt</option>
<option value="relevance">Relevanz</option>
@@ -693,6 +739,10 @@
<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>
<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 class="modal-foot">
@@ -710,6 +760,12 @@
<button class="modal-close" data-close></button>
</div>
<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>
<div class="modal-foot">
@@ -720,6 +776,26 @@
</div>
</div>
<!-- ===================== Dialog: Modpack aktualisieren ===================== -->
<div id="modal-modpack" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3>Modpack aktualisieren</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p id="mp-status" class="hint">Suche …</p>
<div id="mp-kern" class="crash-kern hidden"></div>
<p class="hint">Welten, Bildschirmfotos und deine Optionen bleiben unangetastet. Mods und Konfigurationsdateien des Pakets werden ersetzt; was die neue Fassung nicht mehr enthält, wird entfernt.</p>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
<button id="mp-do" class="btn btn-primary hidden">Jetzt aktualisieren</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Serverliste ===================== -->
<div id="modal-servers" class="modal hidden">
<div class="modal-card modal-lg">
@@ -912,8 +988,34 @@
</div>
<div id="import-pane-modpack" class="import-pane hidden">
<p class="import-copy">Importiert ein Modpack als neue Instanz.</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="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
<p class="hint">Suche läuft über Modrinth. Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json) dafür ist ein API-Key oder eine Weiterleitung in den Netzwerk-Einstellungen nötig.</p>
<div class="field-row" style="margin-top:12px;align-items:flex-end">
<label class="field" style="flex:1">
<span>Suche (Modrinth)</span>
<input id="mp-query" type="text" placeholder="z. B. Fabulously Optimized, Create …" autocomplete="off" />
</label>
<label 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 id="import-pane-mc" class="import-pane hidden">

View File

@@ -478,6 +478,10 @@ async function prepareAndLaunch(opts, onProgress) {
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
if (opts.mcMaximized) args.push('--fullscreen');
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);

View File

@@ -90,13 +90,34 @@ let mainWindow = null;
const hatEinzelSperre = app.requestSingleInstanceLock();
if (!hatEinzelSperre) app.quit();
/*
* Instanz-Kennung aus den Startargumenten lesen.
*
* Wichtig: Bei einem zweiten Start sortiert Chromium die Argumente um
* alle Schalter wandern nach vorn, alles andere ans Ende. Aus
* --launch 1.21.11
* wird dabei
* --launch --allow-file-access-from-files <pfad> 1.21.11
* und die Kennung steht nicht mehr hinter ihrem Schalter. Deshalb wird die
* Form "--launch=<kennung>" verwendet: die bleibt ein einziges Argument.
*/
function instanzAusArgumenten(argv) {
const args = argv || [];
for (let i = 0; i < args.length; i++) {
const a = String(args[i]);
if (a === '--launch' && args[i + 1]) return String(args[i + 1]);
const args = (argv || []).map(String);
for (const a of args) {
if (a.startsWith('--launch=')) return a.slice('--launch='.length);
}
// Notnagel für Verknüpfungen aus früheren Fassungen (und den Aufruf von
// Hand): Steht "--launch" allein da, ist die Kennung durch das Umsortieren
// ans Ende gerutscht das letzte Argument, das kein Schalter ist.
if (args.includes('--launch')) {
const i = args.indexOf('--launch');
if (args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1];
for (let j = args.length - 1; j > 0; j--) {
if (!args[j].startsWith('-')) return args[j];
}
}
return null;
}
@@ -253,6 +274,8 @@ ipcMain.handle('java:detect', (_e, javaPath) => detectJava(javaPath));
// automatische Java-Suche über das System
ipcMain.handle('java:scan', () => services.scanJava(false)); // Knopf sucht immer frisch
// aus dem Zwischenspeicher für Dialoge, die nur die Liste anzeigen wollen
ipcMain.handle('java:list', () => services.scanJava(true));
// ---------------------------------------------------------------------------
// IPC Minecraft-Versionen (Mojang)
@@ -264,41 +287,89 @@ ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData
// IPC Mods (Modrinth)
// ---------------------------------------------------------------------------
ipcMain.handle('mods:search', (_e, opts) => services.modrinthSearch(opts));
ipcMain.handle('curseforge:testKey', async (_e, apiKey) => {
try { return await services.curseforgeTestKey(apiKey); }
catch (err) { return { ok: false, message: err.message || String(err) }; }
});
ipcMain.handle('mods:list', (_e, id) => store.listMods(id));
ipcMain.handle('mods:search', async (_e, opts) => {
const o = opts || {};
const source = o.source || 'modrinth';
try {
if (source === 'curseforge') {
const zugang = services.curseforgeZugang(store.getSettings());
return await services.curseforgeSearch(zugang, o);
}
return await services.modrinthSearch(o);
} catch (err) {
return {
total: 0,
hits: [],
error: (err && err.message) ? err.message : String(err),
needKey: !!(err && err.code === 'need-key'),
};
}
});
ipcMain.handle('mods:install', async (_e, id, project) => {
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
ipcMain.handle('mods:install', async (_e, id, project, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
if (!loader || loader === 'vanilla') return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' };
// Nur Mods brauchen einen Loader Ressourcenpakete und Shader laufen auch
// in einer Vanilla-Instanz.
if (typ.loaderNoetig && (!loader || loader === 'vanilla')) {
return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' };
}
const modsDir = store.modsDir(id);
const done = new Set(store.listMods(id).map((m) => m.projectId));
const modsDir = store.inhaltsDir(id, art || 'mod');
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId));
const added = [];
let firstError = null;
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
const zugang = istCf ? services.curseforgeZugang(store.getSettings()) : null;
// installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten
async function installOne(proj, isDep) {
if (done.has(proj.projectId)) return;
done.add(proj.projectId);
try {
const res = await services.modrinthInstall({
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir,
});
if (!res.ok) {
if (!isDep && !firstError) firstError = res.message;
return;
}
store.addMod(id, res.entry);
added.push(res.entry);
for (const dep of res.dependencies || []) {
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
const title = await services.modrinthProjectTitle(dep.project_id);
await installOne({ projectId: dep.project_id, title }, true);
let res;
if (String(proj.projectId || '').startsWith('cf-') || proj.source === 'curseforge') {
res = await services.curseforgeInstall({
zugang, projectId: proj.projectId, title: proj.title,
gameVersion, loader, modsDir, art: art || 'mod',
});
if (!res.ok) {
if (!isDep && !firstError) firstError = res.message;
return;
}
store.addMod(id, res.entry, art || 'mod');
added.push(res.entry);
for (const dep of res.dependencies || []) {
if (dep.required && dep.projectId && !done.has(dep.projectId)) {
await installOne({ projectId: dep.projectId, title: dep.projectId, source: 'curseforge' }, true);
}
}
} else {
res = await services.modrinthInstall({
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod',
});
if (!res.ok) {
if (!isDep && !firstError) firstError = res.message;
return;
}
store.addMod(id, res.entry, art || 'mod');
added.push(res.entry);
for (const dep of res.dependencies || []) {
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
const title = await services.modrinthProjectTitle(dep.project_id);
await installOne({ projectId: dep.project_id, title }, true);
}
}
}
} catch (err) {
@@ -317,7 +388,7 @@ ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
catch { return []; }
});
ipcMain.handle('mods:remove', (_e, id, filename) => store.removeMod(id, filename));
ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod'));
// Vorhandener Arbeitsspeicher Grundlage für die Prüfung der RAM-Angaben
function gesamtSpeicherMb() {
@@ -356,6 +427,11 @@ ipcMain.handle('servers:remove', (_e, id, ip) => {
return servers.entferne(store.gameDir(id), ip);
});
ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.benenneUm(store.gameDir(id), ip, neuerName);
});
// denselben Server in weitere Instanzen übertragen
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
const erledigt = [];
@@ -400,28 +476,29 @@ ipcMain.handle('crash:openFolder', (_e, id) => {
});
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled) => {
const res = store.setModEnabled(id, filename, !!enabled);
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
if (res.ok) return res;
const ordner = services.inhaltsArt(art || 'mod').ordner;
const gruende = {
'not-found': 'Die Datei liegt nicht mehr im mods-Ordner.',
'both-exist': 'Es liegen beide Fassungen im mods-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.',
'not-found': `Die Datei liegt nicht mehr im ${ordner}-Ordner.`,
'both-exist': `Es liegen beide Fassungen im ${ordner}-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.`,
'bad-name': 'Ungültiger Dateiname.',
};
return { ok: false, message: gruende[res.reason] || res.reason };
});
// sucht für die installierten Mods nach neueren Dateien (lädt noch nichts)
ipcMain.handle('mods:checkUpdates', async (_e, id) => {
ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
try {
const res = await services.modrinthFindUpdates(store.listMods(id), gameVersion, loader, (p) => {
const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
});
}, art || 'mod');
return { ok: true, ...res };
} catch (err) {
return { ok: false, message: 'Update-Suche fehlgeschlagen: ' + err.message };
@@ -429,28 +506,29 @@ ipcMain.handle('mods:checkUpdates', async (_e, id) => {
});
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds) => {
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
const modsDir = store.modsDir(id);
const sorte = art || 'mod';
const modsDir = store.inhaltsDir(id, sorte);
const gewuenscht = new Set(projectIds || []);
const erneuert = [];
const fehler = [];
for (const mod of store.listMods(id)) {
for (const mod of store.listMods(id, sorte)) {
if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue;
try {
const res = await services.modrinthInstall({
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir,
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir, art: sorte,
});
if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; }
store.addMod(id, res.entry);
store.addMod(id, res.entry, sorte);
// alte Datei erst weg, wenn die neue liegt und nie, wenn sie gleich heißt
if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename);
// war die Mod abgeschaltet, bleibt sie es auch nach dem Update
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false);
if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename, sorte);
// war der Eintrag abgeschaltet, bleibt er es auch nach dem Update
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false, sorte);
erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber });
} catch (err) {
fehler.push({ title: mod.title, message: err.message });
@@ -582,6 +660,7 @@ function listDir(dir, dirsOnly) {
ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true));
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false));
function safeSub(id, sub, name) {
const base = path.join(store.gameDir(id), sub);
@@ -677,38 +756,203 @@ async function importMrpack(buf, zip) {
const gameDir = store.gameDir(created.id);
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const files = index.files || [];
const eingespielt = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: false });
// Herkunft merken, damit sich das Paket später aktualisieren lässt
const herkunft = await modpackHerkunft(buf, index);
store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) });
return { ok: true, instance: store.getInstance(created.id), fileCount: eingespielt.dateien.length };
}
/*
* Spielt ein .mrpack in ein Spielverzeichnis ein.
* schonePlayerDaten: bei einer Aktualisierung bleiben Welten, Bildschirmfotos
* und die Optionen unangetastet sonst wären die Einstellungen des Spielers weg.
* -> { dateien: [relative Pfade] }
*/
const SPIELERDATEN = [/^saves\//i, /^screenshots\//i, /^logs\//i, /^options\.txt$/i, /^servers\.dat$/i];
async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) {
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const geschuetzt = (rel) => schonePlayerDaten && SPIELERDATEN.some((r) => r.test(rel));
const dateien = [];
const files = (index.files || []).filter((f) => !(f.env && f.env.client === 'unsupported'));
let i = 0;
for (const f of files) {
i++;
if (f.env && f.env.client === 'unsupported') continue;
const url = f.downloads && f.downloads[0];
if (!url || !f.path) continue;
const dest = path.join(gameDir, f.path.replace(/\//g, path.sep));
const rel = String(f.path).replace(/\\/g, '/');
if (geschuetzt(rel)) continue;
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
}
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher/0.11' } });
if (!res.ok) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
try {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
dateien.push(rel);
} catch { /* einzelne Datei überspringen */ }
}
// overrides einspielen
for (const prefix of ['overrides/', 'client-overrides/']) {
for (const name of Object.keys(zip)) {
if (name.startsWith(prefix) && !name.endsWith('/')) {
const dest = path.join(gameDir, name.slice(prefix.length).replace(/\//g, path.sep));
if (!inside(dest)) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
}
if (!name.startsWith(prefix) || name.endsWith('/')) continue;
const rel = name.slice(prefix.length);
if (geschuetzt(rel)) continue;
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
dateien.push(rel);
}
}
return { ok: true, instance: store.getInstance(created.id), fileCount: files.length };
return { dateien };
}
/*
* Woher stammt das Paket? Modrinth kennt jede veröffentlichte Datei an ihrer
* Prüfsumme darüber finden wir Projekt und Fassung, ohne den Nutzer zu fragen.
*/
async function modpackHerkunft(buf, index) {
const basis = {
quelle: 'modrinth',
name: index.name || '',
versionNummer: index.versionId || '',
projectId: '',
versionId: '',
};
try {
const sha1 = require('crypto').createHash('sha1').update(buf).digest('hex');
const res = await fetch(`https://api.modrinth.com/v2/version_file/${sha1}?algorithm=sha1`,
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
if (res.ok) {
const v = await res.json();
basis.projectId = v.project_id || '';
basis.versionId = v.id || '';
basis.versionNummer = v.version_number || basis.versionNummer;
}
} catch { /* offline oder unbekanntes Paket dann eben ohne Herkunft */ }
return basis;
}
// ---- Modpack aktualisieren (Modrinth) ----
/*
* Sucht eine neuere Fassung des installierten Modpacks.
* -> { ok, verfuegbar, aktuelleNummer, neueNummer, ... }
*/
ipcMain.handle('modpack:check', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const mp = inst.modpack;
if (!mp || !mp.projectId) {
return { ok: false, message: 'Für diese Instanz ist kein Modrinth-Modpack hinterlegt. '
+ 'Aktualisieren geht nur bei Instanzen, die aus einem .mrpack entstanden sind.' };
}
try {
const res = await fetch(`https://api.modrinth.com/v2/project/${mp.projectId}/version`,
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
if (!res.ok) return { ok: false, message: 'Modrinth antwortet mit HTTP ' + res.status };
const versionen = await res.json();
if (!Array.isArray(versionen) || !versionen.length) return { ok: true, verfuegbar: false };
const neueste = versionen[0];
const verfuegbar = neueste.id !== mp.versionId;
return {
ok: true,
verfuegbar,
name: mp.name,
aktuelleNummer: mp.versionNummer || '?',
neueNummer: neueste.version_number,
neueVersionId: neueste.id,
hinweise: neueste.changelog || '',
spielVersion: (neueste.game_versions || [])[0] || '',
};
} catch (err) {
return { ok: false, message: 'Suche fehlgeschlagen: ' + err.message };
}
});
/*
* Spielt eine neuere Fassung ein. Dateien der alten Fassung, die es nicht mehr
* gibt, werden entfernt; Welten, Bildschirmfotos und Optionen bleiben.
*/
ipcMain.handle('modpack:update', async (_e, id, versionId) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const mp = inst.modpack;
if (!mp || !mp.projectId) return { ok: false, message: 'Kein Modpack hinterlegt.' };
try {
const vres = await fetch(`https://api.modrinth.com/v2/version/${versionId}`,
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
if (!vres.ok) return { ok: false, message: 'Fassung nicht abrufbar (HTTP ' + vres.status + ').' };
const version = await vres.json();
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' };
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' });
}
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!dl.ok) return { ok: false, message: 'Download fehlgeschlagen (HTTP ' + dl.status + ').' };
const buf = Buffer.from(await dl.arrayBuffer());
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) return { ok: false, message: 'Die geladene Datei ist kein .mrpack.' };
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, ''));
const gameDir = store.gameDir(id);
const neu = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: true });
// Was die alte Fassung mitbrachte und die neue nicht mehr kennt, muss weg
// sonst bleiben alte Mods liegen und der Loader stolpert darüber.
const behalten = new Set(neu.dateien.map((d) => d.toLowerCase()));
const entfernt = [];
for (const alt of mp.dateien || []) {
if (behalten.has(String(alt).toLowerCase())) continue;
if (SPIELERDATEN.some((r) => r.test(alt))) continue;
const p = path.join(gameDir, String(alt).replace(/\//g, path.sep));
if (!p.startsWith(gameDir + path.sep)) continue;
try { if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); entfernt.push(alt); } } catch { /* egal */ }
}
// Minecraft-Version und Loader können sich mit der neuen Fassung ändern
const deps = index.dependencies || {};
let loader = 'vanilla'; let loaderVersion = '';
if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; }
else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; }
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
store.updateInstance(id, {
minecraft: { version: deps.minecraft || inst.minecraft.version, loader, loaderVersion },
modpack: Object.assign({}, mp, {
versionId: version.id,
versionNummer: version.version_number,
dateien: neu.dateien,
}),
});
return {
ok: true,
neueNummer: version.version_number,
eingespielt: neu.dateien.length,
entfernt: entfernt.length,
spielVersion: deps.minecraft || '',
};
} catch (err) {
return { ok: false, message: 'Aktualisierung fehlgeschlagen: ' + err.message };
}
});
// ---- Modpack-Import (CurseForge .zip mit manifest.json) ----
// "forge-47.2.0" / "fabric-0.15.7" -> { loader, loaderVersion }
@@ -789,6 +1033,64 @@ async function importCurseForge(buf, zip, zugang) {
};
}
ipcMain.handle('modpack:search', async (_e, opts) => {
try {
const q = Object.assign({}, opts || {}, { art: 'modpack' });
return await services.modrinthSearch(q);
} catch (err) {
return { total: 0, hits: [], error: err.message };
}
});
ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
try {
const projectId = project && (project.projectId || project.id);
if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
});
}
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
if (!file) {
return {
ok: false,
message: gameVersion
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
: 'Keine Modpack-Version gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack',
detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …',
});
}
const buf = await services.downloadBuffer(file.url);
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) {
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
}
const result = await importMrpack(buf, zip);
if (result && result.ok && result.instance) {
// Herkunft mit aktueller Version-ID ergänzen, falls der Index sie nicht trägt
const inst = result.instance;
const mp = Object.assign({}, inst.modpack || {}, {
projectId: projectId,
versionId: file.versionId,
versionNumber: file.versionNumber,
title: (project && project.title) || (inst.modpack && inst.modpack.title) || inst.name,
});
store.updateInstance(inst.id, { modpack: mp });
result.instance = store.getInstance(inst.id);
}
return result;
} catch (err) {
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
}
});
ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Modpack importieren',
@@ -827,7 +1129,9 @@ ipcMain.handle('instances:shortcut', (_e, id) => {
const ziel = verknuepfungsZiel();
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
target: ziel,
args: `--launch "${id}"`,
// Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium
// an ihrem Schalter bleibt (siehe instanzAusArgumenten)
args: `--launch="${id}"`,
cwd: path.dirname(ziel),
description: 'AeroMC ' + inst.name,
icon: ziel,
@@ -1032,7 +1336,7 @@ function kontoSpieltNichtMehr(kontoId) {
meldeKontenAenderung();
}
ipcMain.handle('instances:launch', async (_e, id, accountId) => {
ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!inst.minecraft.version) {
@@ -1081,6 +1385,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
auth: null,
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
};
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start

View File

@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('api', {
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
scanJava: () => ipcRenderer.invoke('java:scan'),
listJava: () => ipcRenderer.invoke('java:list'),
// Minecraft-Versionen (Mojang)
mcVersions: () => ipcRenderer.invoke('mc:versions'),
@@ -21,10 +22,12 @@ contextBridge.exposeInMainWorld('api', {
// Mods (Modrinth)
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
modList: (id) => ipcRenderer.invoke('mods:list', id),
modInstall: (id, project) => ipcRenderer.invoke('mods:install', id, project),
modRemove: (id, filename) => ipcRenderer.invoke('mods:remove', id, filename),
modSetEnabled: (id, filename, enabled) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled),
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
// art: 'mod' | 'resourcepack' | 'shader'
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
systemMemory: () => ipcRenderer.invoke('system:memory'),
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
@@ -37,12 +40,13 @@ contextBridge.exposeInMainWorld('api', {
serversList: (id) => ipcRenderer.invoke('servers:list', id),
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
logsList: (id) => ipcRenderer.invoke('logs:list', id),
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
modCheckUpdates: (id) => ipcRenderer.invoke('mods:checkUpdates', id),
modApplyUpdates: (id, projectIds) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds),
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
onModUpdateProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('mods:updateProgress', l);
@@ -62,12 +66,13 @@ contextBridge.exposeInMainWorld('api', {
// Welten & Ressourcenpakete
worlds: (id) => ipcRenderer.invoke('instance:worlds', 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),
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
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),
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
@@ -86,6 +91,10 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('instance:launch-request', l);
},
importModpack: () => ipcRenderer.invoke('modpack:import'),
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
onLaunchProgress: (cb) => {
const listener = (_e, data) => cb(data);
ipcRenderer.on('launch:progress', listener);

View File

@@ -620,6 +620,99 @@ async function runModpackImport() {
}
}
function fmtDownloads(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
async function searchModpacks() {
const query = el('mp-query').value.trim();
const gameVersion = el('mp-mc').value.trim();
const sort = el('mp-sort').value || 'relevance';
const box = el('mp-results');
const st = el('mp-status');
st.textContent = 'Suche …';
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
try {
const res = await window.api.modpackSearch({
query, gameVersion, sort, limit: 30, offset: 0, art: 'modpack',
});
if (res.error) {
st.textContent = 'Fehler: ' + res.error;
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
return;
}
const hits = res.hits || [];
st.textContent = hits.length
? (res.total + ' Treffer' + (gameVersion ? ' für ' + gameVersion : ''))
: 'Keine Treffer.';
box.innerHTML = '';
if (!hits.length) {
box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>';
return;
}
for (const h of hits) {
const item = document.createElement('div');
item.className = 'mod-item';
const meta = [
h.author ? ('von ' + h.author) : '',
h.downloads != null ? (fmtDownloads(h.downloads) + ' Downloads') : '',
].filter(Boolean).join(' · ');
const cats = (h.displayCategories || h.categories || []).slice(0, 4).join(', ');
item.innerHTML =
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(h.title || h.slug || h.projectId)}</div>
<div class="bd-meta">${escapeHtml(meta)}${cats ? ' · ' + escapeHtml(cats) : ''}</div>
<div class="bd-meta" style="opacity:.85">${escapeHtml((h.description || '').slice(0, 120))}${(h.description || '').length > 120 ? '…' : ''}</div>
</div>
<div class="mod-action"></div>`;
const btn = document.createElement('button');
btn.className = 'btn btn-primary btn-mini';
btn.textContent = 'Installieren';
btn.addEventListener('click', () => installModpackFromSearch(h, btn));
item.querySelector('.mod-action').appendChild(btn);
box.appendChild(item);
}
} catch (err) {
st.textContent = 'Fehler: ' + (err.message || err);
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
}
}
async function installModpackFromSearch(hit, btn) {
if (!hit || !hit.projectId) return;
const gameVersion = el('mp-mc').value.trim();
const prev = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '…'; }
status('Installiere Modpack: ' + (hit.title || hit.slug || '') + ' …');
try {
const res = await window.api.modpackInstallFromModrinth({
projectId: hit.projectId,
title: hit.title || hit.slug,
gameVersion,
});
status('Bereit'); el('status-right').textContent = '';
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack installiert: ${res.instance.name} (${res.fileCount || 0} Dateien)`);
if (currentDetailId !== res.instance.id) {
try { await selectInstance(res.instance.id); } catch { /* egal */ }
}
return;
}
toast(res.message || 'Installation fehlgeschlagen.', true);
} catch (err) {
status('Bereit'); el('status-right').textContent = '';
toast('Installation fehlgeschlagen: ' + (err.message || err), true);
} finally {
if (btn) { btn.disabled = false; btn.textContent = prev || 'Installieren'; }
}
}
async function scanMc() {
const folder = el('mc-path').value.trim();
el('mc-status').textContent = 'Suche Launcher-Profile …';
@@ -1214,7 +1307,7 @@ function applyIcons(root = document) {
// ---------- Instanz-Kacheln ----------
const SINGLE_SELECTION_BUTTON_IDS = [
'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens', 'p-logs', 'p-servers',
'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens', 'p-logs', 'p-servers', 'p-modpack',
'p-mcfolder', 'p-configfolder', 'p-instfolder', 'p-shortcut', 'p-export', 'p-copy',
];
@@ -1292,6 +1385,9 @@ async function refreshSelectionPanel() {
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
// "Modpack aktualisieren" nur bei Instanzen, die aus einem Modrinth-Paket stammen
el('p-modpack').classList.toggle('hidden', !(inst.modpack && inst.modpack.projectId));
const iconData = inst.customImage ? await window.api.instanceIcon(id) : null;
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
@@ -1611,6 +1707,7 @@ async function openEdit(inst) {
el('f-min-mem').value = inst.java.minMemMb || '';
el('f-max-mem').value = inst.java.maxMemMb || '';
zeigeSpeicherHinweis();
fuelleInstanzJava(inst);
el('f-extra-args').value = inst.java.extraArgs || '';
el('f-prelaunch').value = inst.preLaunchCommand || '';
el('f-postexit').value = inst.postExitCommand || '';
@@ -1653,6 +1750,7 @@ async function saveInstance() {
let targetId = editingId;
if (editingId) {
payload.java = {
path: el('f-java').value.trim(),
minMemMb: minMem,
maxMemMb: maxMem,
extraArgs: el('f-extra-args').value.trim(),
@@ -1684,14 +1782,14 @@ async function selectInstance(id) {
let launching = false;
async function launch(id, accountId) {
async function launch(id, accountId, launchOpts) {
if (launching) return;
launching = true;
status('Vorbereiten …');
el('status-right').textContent = '';
appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n');
try {
const res = await window.api.launchInstance(id, accountId);
const res = await window.api.launchInstance(id, accountId, launchOpts || null);
if (res.ok) {
status('Läuft (PID ' + res.pid + ')');
toast('Minecraft gestartet.');
@@ -1711,6 +1809,36 @@ async function launch(id, accountId) {
}
}
// ---------- Java je Instanz ----------
async function fuelleInstanzJava(inst) {
const feld = el('f-java');
if (!feld) return;
feld.value = (inst && inst.java && inst.java.path) || '';
const sel = el('f-java-detected');
sel.innerHTML = '<option value=""> gefundene Java-Installationen </option>';
try {
for (const j of await window.api.listJava()) {
const o = document.createElement('option');
o.value = j.path;
o.textContent = `Java ${j.major} (${j.arch}) ${j.path}`;
sel.appendChild(o);
}
} catch { /* Liste bleibt leer */ }
sel.value = feld.value && [...sel.options].some((o) => o.value === feld.value) ? feld.value : '';
// Der häufigste Stolperstein: global ist eine feste Fassung eingetragen,
// die dann auch für alte Minecraft-Versionen genommen wird.
try {
const s = await window.api.getSettings();
el('f-java-hint').textContent = s.javaPath
? `Achtung: In den Einstellungen ist ${s.javaPath} fest vorgegeben und gilt für alle Instanzen. `
+ 'Trage hier etwas ein, wenn diese Instanz eine andere Fassung braucht ältere Minecraft-Versionen verlangen oft Java 8.'
: 'Leer lassen: Der Launcher nimmt die Fassung, die diese Minecraft-Version verlangt, und lädt sie bei Bedarf selbst.';
} catch { /* egal */ }
}
// ---------- Arbeitsspeicher ----------
let systemSpeicher = null;
@@ -1728,6 +1856,52 @@ async function zeigeSpeicherHinweis() {
+ `(Vorschlag: ${empfohlen} MB).`;
}
// ---------- Modpack aktualisieren ----------
let modpackNeueVersion = null;
async function openModpackUpdate() {
if (!currentDetailId) return;
modpackNeueVersion = null;
el('mp-status').textContent = 'Suche nach einer neueren Fassung …';
el('mp-kern').classList.add('hidden');
el('mp-do').classList.add('hidden');
show('modal-modpack');
const res = await window.api.modpackCheck(currentDetailId);
if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Suche fehlgeschlagen.'); return; }
if (!res.verfuegbar) {
el('mp-status').textContent = `${res.name || 'Das Modpack'} ist auf dem neuesten Stand (${res.aktuelleNummer}).`;
return;
}
modpackNeueVersion = res.neueVersionId;
el('mp-status').textContent = `Für ${res.name || 'dieses Modpack'} gibt es eine neuere Fassung.`;
const zeile = (t, w) => (w ? `<div class="crash-zeile"><div class="crash-titel">${t}</div><div class="crash-wert">${escapeHtml(w)}</div></div>` : '');
el('mp-kern').innerHTML = zeile('Installiert', res.aktuelleNummer) + zeile('Neu', res.neueNummer)
+ zeile('Minecraft', res.spielVersion)
+ (res.hinweise ? zeile('Änderungen', res.hinweise.slice(0, 400)) : '');
el('mp-kern').classList.remove('hidden');
el('mp-do').classList.remove('hidden');
}
async function doModpackUpdate() {
if (!modpackNeueVersion) return;
const btn = el('mp-do');
btn.disabled = true;
el('mp-status').textContent = 'Wird aktualisiert das kann einige Minuten dauern …';
const res = await window.api.modpackUpdate(currentDetailId, modpackNeueVersion);
btn.disabled = false;
if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.'); return; }
el('mp-status').textContent = `✔ Auf ${res.neueNummer} aktualisiert: ${res.eingespielt} Dateien eingespielt`
+ (res.entfernt ? `, ${res.entfernt} entfernt` : '') + '.';
el('mp-do').classList.add('hidden');
toast('Modpack aktualisiert.');
await reload();
await selectInstance(currentDetailId);
}
// ---------- Serverliste ----------
let zuUebertragenderServer = null;
@@ -1766,6 +1940,37 @@ async function renderServers() {
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
const starten = document.createElement('button');
starten.className = 'btn btn-primary btn-mini';
starten.textContent = 'Starten';
starten.title = 'Instanz starten und direkt diesem Server beitreten (Quick Play, ab MC 1.20)';
starten.addEventListener('click', () => {
hide('modal-servers');
launch(currentDetailId, null, { quickPlayServer: s.ip });
});
aktionen.appendChild(starten);
const umbenennen = document.createElement('button');
umbenennen.className = 'btn btn-mini';
umbenennen.textContent = 'Umbenennen';
umbenennen.title = 'Anzeigenamen ändern';
umbenennen.addEventListener('click', async () => {
const aktuell = s.name || s.ip;
const eingabe = await askPrompt({
title: 'Server umbenennen',
text: 'Adresse: ' + s.ip,
label: 'Name',
value: aktuell,
okLabel: 'Speichern',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.serversRename(currentDetailId, s.ip, eingabe);
if (!res.ok) { toast(res.message || 'Umbenennen fehlgeschlagen.', true); return; }
if (!res.unveraendert) toast('Umbenannt: ' + eingabe.trim());
await renderServers();
});
aktionen.appendChild(umbenennen);
const kopieren = document.createElement('button');
kopieren.className = 'btn btn-mini';
kopieren.textContent = 'Übertragen…';
@@ -2103,34 +2308,80 @@ function openConsole() {
// ---------- Welten & Ressourcenpakete ----------
async function openContent() {
const inst = await window.api.getInstance(currentDetailId);
el('content-title').textContent = 'Welten & Ressourcenpakete ' + inst.name;
el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete ' + inst.name;
await renderContentLists();
show('modal-content');
}
async function renderContentLists() {
renderContentList('content-worlds', await window.api.worlds(currentDetailId), 'saves', '🌍');
renderContentList('content-rp', await window.api.resourcepacks(currentDetailId), 'resourcepacks', '🎨');
renderContentList('content-dp', await window.api.datapacks(currentDetailId), 'datapacks', '📜');
}
// ---------- Screenshots ----------
let screenSelected = new Set();
function updateScreensSelectionUi() {
const n = screenSelected.size;
const btn = el('screens-delete-sel');
if (btn) {
btn.disabled = n === 0;
btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen';
}
const hint = el('screens-sel-hint');
if (hint) hint.textContent = n ? (n + ' ausgewählt Doppelklick öffnet das Bild') : 'Klick auf Vorschaubild zum Auswählen · Doppelklick öffnet';
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
const name = t.dataset.name;
const on = name && screenSelected.has(name);
t.classList.toggle('selected', !!on);
const cb = t.querySelector('.screen-check');
if (cb) cb.checked = !!on;
});
}
async function openScreens() {
const inst = await window.api.getInstance(currentDetailId);
el('screens-title').textContent = 'Screenshots ' + inst.name;
const grid = el('screens-grid');
grid.innerHTML = '<div class="screens-empty">Lade …</div>';
screenSelected = new Set();
updateScreensSelectionUi();
show('modal-screens');
const shots = await window.api.screenshots(currentDetailId);
grid.innerHTML = '';
if (!shots.length) { grid.innerHTML = '<div class="screens-empty">Keine Screenshots vorhanden.</div>'; return; }
if (!shots.length) {
grid.innerHTML = '<div class="screens-empty">Keine Screenshots vorhanden.</div>';
updateScreensSelectionUi();
return;
}
for (const s of shots) {
const thumb = document.createElement('div');
thumb.className = 'screen-thumb';
thumb.dataset.name = s.name;
thumb.innerHTML =
`<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div>
`<input type="checkbox" class="screen-check" title="Auswählen" />
<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div>
<button class="screen-del" title="Löschen">🗑</button>`;
const img = thumb.querySelector('img');
window.api.screenshotData(currentDetailId, s.name).then((d) => { if (d) img.src = d; });
img.addEventListener('click', () => window.api.openScreenshot(currentDetailId, s.name));
const toggle = () => {
if (screenSelected.has(s.name)) screenSelected.delete(s.name);
else screenSelected.add(s.name);
updateScreensSelectionUi();
};
thumb.querySelector('.screen-check').addEventListener('click', (e) => {
e.stopPropagation();
toggle();
});
thumb.addEventListener('click', (e) => {
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
toggle();
});
img.addEventListener('dblclick', (e) => {
e.stopPropagation();
window.api.openScreenshot(currentDetailId, s.name);
});
thumb.querySelector('.screen-del').addEventListener('click', (e) => {
e.stopPropagation();
askConfirm('Screenshot löschen', `${s.name}" löschen?`, '🗑 Löschen', async () => {
@@ -2140,6 +2391,22 @@ async function openScreens() {
});
grid.appendChild(thumb);
}
updateScreensSelectionUi();
}
async function deleteSelectedScreenshots() {
const names = [...screenSelected];
if (!names.length || !currentDetailId) return;
const label = names.length === 1
? `${names[0]}" löschen?`
: `${names.length} Screenshots unwiderruflich löschen?`;
askConfirm('Screenshots löschen', label, '🗑 Löschen', async () => {
for (const name of names) {
await window.api.deleteContent(currentDetailId, 'screenshots', name);
}
toast(names.length === 1 ? 'Screenshot gelöscht.' : names.length + ' Screenshots gelöscht.');
openScreens();
});
}
function renderContentList(boxId, items, sub, emoji) {
@@ -2166,27 +2433,57 @@ function renderContentList(boxId, items, sub, emoji) {
}
// ---------- Mod-Browser ----------
// Welche Inhaltsart wird gerade gezeigt: mod | resourcepack | shader
let modArt = 'mod';
const ART_LABEL = { mod: 'Mods', resourcepack: 'Ressourcenpakete', shader: 'Shader' };
const ART_SUCHE = {
mod: 'Mods durchsuchen … (z. B. Sodium, JEI, Create)',
resourcepack: 'Ressourcenpakete durchsuchen … (z. B. Faithful, Bare Bones)',
shader: 'Shader durchsuchen … (z. B. Complementary, BSL)',
};
// Nur Mods brauchen einen Loader Ressourcenpakete und Shader laufen auch in Vanilla
const ART_BRAUCHT_LOADER = { mod: true, resourcepack: false, shader: false };
async function openMods() {
const inst = await window.api.getInstance(currentDetailId);
if (!inst) return;
el('m-title').textContent = 'Mods ' + inst.name;
modArt = 'mod';
el('m-art').value = 'mod';
await zeigeModAnsicht(inst);
show('modal-mods');
}
// Wechsel der Inhaltsart: alles neu aufbauen
async function modArtGewechselt() {
modArt = el('m-art').value || 'mod';
const inst = await window.api.getInstance(currentDetailId);
if (inst) await zeigeModAnsicht(inst);
}
async function zeigeModAnsicht(inst) {
el('m-title').textContent = ART_LABEL[modArt] + ' ' + inst.name;
const loaderTxt = LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader;
el('m-sub').textContent = (inst.minecraft.version || 'keine Version') + ' · ' + loaderTxt;
el('m-query').placeholder = ART_SUCHE[modArt];
const warn = el('m-warn');
const blocked = !inst.minecraft.version || inst.minecraft.loader === 'vanilla';
const brauchtLoader = ART_BRAUCHT_LOADER[modArt];
const blocked = !inst.minecraft.version || (brauchtLoader && inst.minecraft.loader === 'vanilla');
if (blocked) {
warn.classList.remove('hidden');
warn.textContent = !inst.minecraft.version
? '⚠ Diese Instanz hat keine Minecraft-Version. Bitte zuerst im Bearbeiten-Dialog eine Version wählen.'
: '⚠ Vanilla-Instanzen unterstützen keine Mods. Wähle beim Bearbeiten einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).';
: '⚠ Vanilla-Instanzen unterstützen keine Mods. Wähle beim Bearbeiten einen Mod-Loader (Fabric/Forge/NeoForge/Quilt). Ressourcenpakete und Shader gehen auch ohne.';
} else {
warn.classList.add('hidden');
}
['m-query', 'm-search-btn', 'm-sort', 'm-category'].forEach((id) => { el(id).disabled = blocked; });
// Kategorien gelten nur für Mods
el('m-category').disabled = blocked || modArt !== 'mod';
el('m-query').value = '';
el('m-more').classList.add('hidden');
// Ergebnisse der vorigen Instanz nicht mitschleppen
// Ergebnisse der vorigen Ansicht nicht mitschleppen
modUpdates = new Map();
setUpdateStatus('');
el('m-apply-updates').classList.add('hidden');
@@ -2194,15 +2491,16 @@ async function openMods() {
switchModTab('catalog');
await refreshInstalled();
show('modal-mods');
if (blocked) {
const box = el('m-results');
box.innerHTML = '<div class="mods-empty">Für Mods braucht diese Instanz einen Mod-Loader.</div>';
box.innerHTML = `<div class="mods-empty">${!inst.minecraft.version
? 'Ohne Minecraft-Version geht hier nichts.'
: 'Für Mods braucht diese Instanz einen Mod-Loader.'}</div>`;
const b = document.createElement('button');
b.className = 'btn btn-primary';
b.style.margin = '0 auto';
b.style.display = 'block';
b.textContent = 'Mod-Loader hinzufügen';
b.textContent = inst.minecraft.version ? 'Mod-Loader hinzufügen' : 'Version wählen';
b.addEventListener('click', async () => {
hide('modal-mods');
const i = await window.api.getInstance(currentDetailId);
@@ -2210,7 +2508,7 @@ async function openMods() {
});
box.appendChild(b);
} else {
await loadCatalog(false); // beliebte Mods sofort anzeigen
await loadCatalog(false); // beliebte Einträge sofort anzeigen
}
}
@@ -2226,7 +2524,7 @@ function switchModTab(which) {
let modUpdates = new Map();
async function refreshInstalled() {
const list = await window.api.modList(currentDetailId);
const list = await window.api.modList(currentDetailId, modArt);
currentModsInstalled = new Set(list.map((m) => m.projectId));
el('m-installed-count').textContent = String(list.length);
const box = el('m-installed');
@@ -2265,7 +2563,7 @@ async function refreshInstalled() {
: 'Datei auf .jar.disabled umbenennen Minecraft lädt sie dann nicht';
schalter.addEventListener('click', async () => {
schalter.disabled = true;
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus);
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt);
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title);
else toast(res.message || 'Umschalten fehlgeschlagen.', true);
await refreshInstalled();
@@ -2277,7 +2575,7 @@ async function refreshInstalled() {
btn.className = 'btn btn-danger btn-mini';
btn.textContent = 'Entfernen';
btn.addEventListener('click', async () => {
await window.api.modRemove(currentDetailId, m.filename);
await window.api.modRemove(currentDetailId, m.filename, modArt);
toast('Entfernt: ' + m.title);
await refreshInstalled();
});
@@ -2296,7 +2594,7 @@ async function checkModUpdates() {
el('m-apply-updates').classList.add('hidden');
setUpdateStatus('Suche …');
try {
const res = await window.api.modCheckUpdates(currentDetailId);
const res = await window.api.modCheckUpdates(currentDetailId, modArt);
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Suche fehlgeschlagen.')); return; }
modUpdates = new Map((res.treffer || []).map((t) => [t.projectId, t]));
@@ -2321,14 +2619,15 @@ async function applyModUpdates() {
btn.disabled = true;
setUpdateStatus('Wird aktualisiert …');
try {
const res = await window.api.modApplyUpdates(currentDetailId, [...modUpdates.keys()]);
const res = await window.api.modApplyUpdates(currentDetailId, [...modUpdates.keys()], modArt);
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.')); return; }
modUpdates = new Map();
await refreshInstalled();
const n = res.erneuert.length;
setUpdateStatus(n + (n === 1 ? ' Mod aktualisiert' : ' Mods aktualisiert')
const wort = n === 1 ? 'Eintrag' : 'Einträge';
setUpdateStatus(`${n} ${wort} aktualisiert`
+ (res.fehler.length ? ' · ' + res.fehler.length + ' fehlgeschlagen' : ''));
if (n) toast(n + (n === 1 ? ' Mod aktualisiert.' : ' Mods aktualisiert.'));
if (n) toast(`${n} ${wort} aktualisiert.`);
for (const f of res.fehler) toast(f.title + ': ' + f.message, true);
btn.classList.add('hidden');
} finally {
@@ -2339,30 +2638,57 @@ async function applyModUpdates() {
let modOffset = 0;
async function loadCatalog(append) {
const inst = await window.api.getInstance(currentDetailId);
if (!inst || !inst.minecraft.version || inst.minecraft.loader === 'vanilla') return;
if (!inst || !inst.minecraft.version) return;
// Loader nur für Mods verlangen
if (ART_BRAUCHT_LOADER[modArt] && inst.minecraft.loader === 'vanilla') return;
const box = el('m-results');
if (!append) { modOffset = 0; box.innerHTML = '<div class="mods-empty">Lade …</div>'; el('m-more').classList.add('hidden'); }
const source = (el('m-source') && el('m-source').value) || 'modrinth';
if (el('m-category')) el('m-category').disabled = source === 'curseforge' || modArt !== 'mod';
try {
const res = await window.api.modSearch({
query: el('m-query').value.trim(),
gameVersion: inst.minecraft.version,
loader: inst.minecraft.loader,
category: el('m-category').value,
category: (source === 'modrinth' && modArt === 'mod') ? el('m-category').value : '',
sort: el('m-sort').value,
limit: 30,
offset: modOffset,
art: modArt,
source,
});
if (!append) box.innerHTML = '';
if (!res.hits.length && !append) {
if (res && (res.error || res.needKey)) {
if (!append) {
box.innerHTML = `<div class="mods-empty">${escapeHtml(res.error || 'Suche nicht möglich.')}</div>`;
if (res.needKey || /API-Key|Weiterleitung reicht/i.test(res.error || '')) {
const wrap = document.createElement('div');
wrap.className = 'mods-empty';
const btn = document.createElement('button');
btn.className = 'btn btn-mini';
btn.textContent = 'Zu den Netzwerk-Einstellungen';
btn.addEventListener('click', () => { hide('modal-mods'); openSettings('pane-network'); });
wrap.appendChild(btn);
box.appendChild(wrap);
}
}
el('m-more').classList.add('hidden');
return;
}
const hits = (res && res.hits) || [];
if (!hits.length && !append) {
box.innerHTML = '<div class="mods-empty">Nichts gefunden.</div>';
el('m-more').classList.add('hidden');
return;
}
for (const mod of res.hits) box.appendChild(buildResultItem(mod));
modOffset += res.hits.length;
el('m-more').classList.toggle('hidden', res.hits.length < 30 || modOffset >= res.total);
for (const mod of hits) box.appendChild(buildResultItem(mod));
modOffset += hits.length;
el('m-more').classList.toggle('hidden', hits.length < 30 || modOffset >= (res.total || 0));
} catch (e) {
if (!append) box.innerHTML = `<div class="mods-empty">Fehler: ${escapeHtml(e.message)}</div>`;
if (!append) {
const msg = (e && e.message) ? e.message : String(e);
box.innerHTML = `<div class="mods-empty">Fehler: ${escapeHtml(msg)}</div>`;
}
}
}
@@ -2386,7 +2712,7 @@ function buildResultItem(mod) {
info.innerHTML =
`<div class="mod-name">${escapeHtml(mod.title)}</div>
<div class="mod-desc">${escapeHtml(mod.description || '')}</div>
<div class="mod-meta">von ${escapeHtml(mod.author)} · ${Number(mod.downloads).toLocaleString('de-DE')} Downloads</div>`;
<div class="mod-meta">von ${escapeHtml(mod.author || 'unbekannt')} · ${Number(mod.downloads).toLocaleString('de-DE')} Downloads${mod.source === 'curseforge' ? ' · CurseForge' : ''}</div>`;
const action = document.createElement('div');
action.className = 'mod-action';
@@ -2412,7 +2738,7 @@ function buildResultItem(mod) {
async function installMod(mod, btn, item) {
btn.disabled = true; btn.textContent = 'Lädt …';
if (item) { item.classList.add('installing'); item.classList.remove('clickable'); }
const res = await window.api.modInstall(currentDetailId, { projectId: mod.projectId, title: mod.title });
const res = await window.api.modInstall(currentDetailId, { projectId: mod.projectId, title: mod.title }, modArt);
if (item) item.classList.remove('installing');
if (res.ok) {
const dep = res.depCount ? ` (+${res.depCount} Abhängigkeit${res.depCount > 1 ? 'en' : ''})` : '';
@@ -3003,6 +3329,31 @@ function onAuthCode(code) {
}
// ---------- Bestätigung ----------
let promptResolver = null;
function askPrompt({ title, text, label, value, okLabel } = {}) {
return new Promise((resolve) => {
// falls schon ein Prompt offen ist
if (promptResolver) { promptResolver(null); promptResolver = null; }
promptResolver = resolve;
el('prompt-title').textContent = title || 'Eingabe';
el('prompt-text').textContent = text || '';
el('prompt-text').classList.toggle('hidden', !text);
el('prompt-label').textContent = label || 'Wert';
el('prompt-ok').textContent = okLabel || 'OK';
const input = el('prompt-input');
input.value = value != null ? String(value) : '';
show('modal-prompt');
setTimeout(() => { input.focus(); input.select(); }, 30);
});
}
function closePrompt(wert) {
hide('modal-prompt');
const r = promptResolver;
promptResolver = null;
if (r) r(wert);
}
function askConfirm(title, text, okLabel, action) {
el('confirm-title').textContent = title;
el('confirm-text').textContent = text;
@@ -3073,10 +3424,20 @@ function wire() {
tab.addEventListener('click', async () => {
setImportKind(tab.dataset.kind);
if (tab.dataset.kind === 'mc') await ensureDefaultMcScan();
if (tab.dataset.kind === 'modpack') {
const box = el('mp-results');
if (box && !box.dataset.loaded) {
box.dataset.loaded = '1';
searchModpacks();
}
}
});
});
el('import-zip-do').addEventListener('click', runZipImport);
el('import-modpack-do').addEventListener('click', runModpackImport);
el('mp-search-btn').addEventListener('click', searchModpacks);
el('mp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
el('mp-mc').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
el('mc-pick').addEventListener('click', async () => {
const p = await window.api.mcPickFolder();
if (p) { el('mc-path').value = p; await scanMc(); }
@@ -3187,6 +3548,8 @@ function wire() {
el('p-screens').addEventListener('click', openScreens);
el('p-logs').addEventListener('click', openLogs);
el('p-servers').addEventListener('click', openServers);
el('p-modpack').addEventListener('click', openModpackUpdate);
el('mp-do').addEventListener('click', doModpackUpdate);
el('srv-add').addEventListener('click', addServer);
el('srv-ip').addEventListener('keydown', (e) => { if (e.key === 'Enter') addServer(); });
el('srv-copy-do').addEventListener('click', doServerCopy);
@@ -3257,15 +3620,29 @@ function wire() {
el('content-open-worlds').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'saves'));
el('content-open-rp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'resourcepacks'));
el('screens-open').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'screenshots'));
el('screens-select-all').addEventListener('click', () => {
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
if (t.dataset.name) screenSelected.add(t.dataset.name);
});
updateScreensSelectionUi();
});
el('screens-select-none').addEventListener('click', () => {
screenSelected.clear();
updateScreensSelectionUi();
});
el('screens-delete-sel').addEventListener('click', () => deleteSelectedScreenshots());
el('content-open-dp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'datapacks'));
// Mod-Dialog
el('m-search-btn').addEventListener('click', () => loadCatalog(false));
el('m-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadCatalog(false); });
el('m-sort').addEventListener('change', () => loadCatalog(false));
el('m-category').addEventListener('change', () => loadCatalog(false));
if (el('m-source')) el('m-source').addEventListener('change', () => loadCatalog(false));
el('m-more').addEventListener('click', () => loadCatalog(true));
el('m-tab-catalog').addEventListener('click', () => switchModTab('catalog'));
el('m-tab-installed').addEventListener('click', () => switchModTab('installed'));
el('m-art').addEventListener('change', modArtGewechselt);
el('m-open-folder').addEventListener('click', () => window.api.openGameDir(currentDetailId));
// Automatische Sicherung: unaufdringlich melden, Fehler deutlich
window.api.onAutoBackup((d) => {
@@ -3314,6 +3691,13 @@ function wire() {
el('s-bgskin').addEventListener('change', previewSkin);
el('s-bgskin-pose').addEventListener('change', previewSkin);
el('s-scan-java').addEventListener('click', scanJava);
el('f-java-detected').addEventListener('change', (e) => {
if (e.target.value) el('f-java').value = e.target.value;
});
el('f-java-pick').addEventListener('click', async () => {
const p = await window.api.pickJava();
if (p) { el('f-java').value = p; el('f-java-detected').value = ''; }
});
el('s-java-detected').addEventListener('change', async (e) => {
if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); }
});
@@ -3321,6 +3705,25 @@ function wire() {
el('s-backup-global').addEventListener('click', runGlobalBackup);
el('s-restore-global').addEventListener('click', promptGlobalRestore);
el('btn-save-settings').addEventListener('click', saveSettings);
if (el('s-cf-test')) {
el('s-cf-test').addEventListener('click', async () => {
const st = el('s-cf-test-status');
const key = el('s-cf-key').value.trim();
st.textContent = 'Prüfe bei CurseForge …';
try {
const res = await window.api.curseforgeTestKey(key);
st.textContent = (res.ok ? '✓ ' : '✖ ') + (res.message || '');
if (res.proben) {
st.textContent += ' [' + res.proben.map((p) => p.name + ':' + p.status).join(', ') + ']';
}
if (res.siehtAusWieCurseForge === false && key) {
st.textContent += ' Hinweis: Erwartet wird ein Studios-Key (cfc_pat_…) oder ein klassischer Key ($2a$10$…).';
}
} catch (err) {
st.textContent = '✖ ' + (err.message || err);
}
});
}
document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane)));
el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); });
el('s-add-account').addEventListener('click', () => { hide('modal-settings'); openLogin().then(addAccount); });
@@ -3358,6 +3761,27 @@ function wire() {
});
// Bestätigung
el('prompt-ok').addEventListener('click', () => {
const v = el('prompt-input').value;
closePrompt(v);
});
el('prompt-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
closePrompt(el('prompt-input').value);
}
});
// Abbrechen / Schließen des Prompt-Dialogs liefert null
el('modal-prompt').querySelectorAll('[data-close]').forEach((b) => {
b.addEventListener('click', (e) => {
e.stopPropagation();
closePrompt(null);
});
});
el('modal-prompt').addEventListener('mousedown', (e) => {
if (e.target === el('modal-prompt')) closePrompt(null);
});
el('confirm-ok').addEventListener('click', async () => {
hide('modal-confirm');
if (confirmAction) { const a = confirmAction; confirmAction = null; await a(); }
@@ -3365,12 +3789,22 @@ function wire() {
// generisches Schließen
document.querySelectorAll('[data-close]').forEach((b) =>
b.addEventListener('click', () => b.closest('.modal').classList.add('hidden')));
b.addEventListener('click', () => {
const m = b.closest('.modal');
if (!m) return;
if (m.id === 'modal-prompt') { closePrompt(null); return; }
m.classList.add('hidden');
}));
document.querySelectorAll('.modal').forEach((m) =>
m.addEventListener('mousedown', (e) => { if (e.target === m) m.classList.add('hidden'); }));
m.addEventListener('mousedown', (e) => {
if (e.target !== m) return;
if (m.id === 'modal-prompt') { closePrompt(null); return; }
m.classList.add('hidden');
}));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open) open.classList.add('hidden');
}

View File

@@ -135,6 +135,23 @@ function entferne(gameDir, ip) {
return schreibe(gameDir, liste);
}
/*
* Nur den Anzeigenamen ändern die Adresse bleibt gleich.
* ip identifiziert den Eintrag (wie beim Entfernen).
*/
function benenneUm(gameDir, ip, neuerName) {
const name = String(neuerName || '').trim();
if (!name) return { ok: false, message: 'Bitte einen Namen angeben.' };
const gelesen = lies(gameDir);
if (!gelesen.ok) return gelesen;
const liste = gelesen.server;
const treffer = liste.find((s) => s.ip.toLowerCase() === String(ip || '').toLowerCase());
if (!treffer) return { ok: false, message: 'Server nicht gefunden.' };
if (treffer.name === name) return { ok: true, unveraendert: true };
treffer.name = name;
return schreibe(gameDir, liste);
}
/*
* Einen Server in eine andere Instanz übertragen der eigentliche Zweck:
* denselben Server nicht in jeder Instanz neu eintippen zu müssen.
@@ -143,4 +160,4 @@ function uebertrage(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 };

View File

@@ -227,10 +227,30 @@ const MODRINTH = 'https://api.modrinth.com/v2';
const MODRINTH_SORTS = ['relevance', 'downloads', 'follows', 'newest', 'updated'];
async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0 } = {}) {
const facets = [['project_type:mod']];
/*
* Inhaltsarten, die der Launcher aus dem Modrinth-Katalog holen kann.
* ordner = Zielverzeichnis im Spielordner, loaderNoetig = braucht einen
* Mod-Loader (Ressourcenpakete laufen auch in Vanilla).
*/
const INHALTSARTEN = {
mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true },
resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false },
shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false },
modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false },
};
function inhaltsArt(art) {
return INHALTSARTEN[art] || INHALTSARTEN.mod;
}
async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0, art = 'mod' } = {}) {
const typ = inhaltsArt(art);
const facets = [['project_type:' + typ.projectType]];
if (gameVersion) facets.push(['versions:' + gameVersion]);
if (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]);
const url = new URL(MODRINTH + '/search');
url.searchParams.set('query', query);
@@ -251,17 +271,24 @@ async function modrinthSearch({ query = '', gameVersion = '', loader = '', categ
description: h.description,
author: h.author,
downloads: h.downloads,
follows: h.follows || 0,
iconUrl: h.icon_url || '',
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
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');
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 } });
if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status);
const versions = await res.json();
@@ -280,6 +307,38 @@ async function modrinthResolveFile({ projectId, gameVersion, loader }) {
};
}
/*
* Neueste passende Modpack-Version von Modrinth.
* gameVersion optional ohne Filter kommt die neueste Version.
*/
async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Modpack-Versionen fehlgeschlagen: HTTP ' + res.status);
const versions = await res.json();
if (!Array.isArray(versions) || !versions.length) return null;
const v = versions[0];
const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0];
if (!file || !file.url) return null;
return {
versionId: v.id,
versionNumber: v.version_number,
name: v.name || v.version_number,
filename: file.filename,
url: file.url,
size: file.size || 0,
gameVersions: v.game_versions || [],
loaders: v.loaders || [],
};
}
async function downloadBuffer(url, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
return Buffer.from(await res.arrayBuffer());
}
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
async function downloadFile(url, destPath, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
@@ -291,11 +350,14 @@ async function downloadFile(url, destPath, extraHeaders) {
}
// lädt eine Mod in <modsDir>; gibt Metadaten für die Instanz zurück
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir }) {
const file = await modrinthResolveFile({ projectId, gameVersion, loader });
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
const typ = inhaltsArt(art);
const file = await modrinthResolveFile({ projectId, gameVersion, loader, art });
if (!file) {
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 bytes = await downloadFile(file.url, dest);
@@ -322,7 +384,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir
* für die weiß niemand, woher sie stammen.
* -> [{ 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 treffer = [];
let geprueft = 0;
@@ -333,8 +395,8 @@ async function modrinthFindUpdates(mods, gameVersion, loader, onProgress) {
await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => {
let file = null;
try {
file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader });
} catch { /* Netzfehler -> diese Mod überspringen */ }
file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader, art });
} catch { /* Netzfehler -> diesen Eintrag überspringen */ }
geprueft++;
if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId });
if (!file || !file.versionId) return;
@@ -383,16 +445,42 @@ const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || '');
* kein fremdes Kontingent.
* -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null
*/
/*
* CurseForge kennt zwei Schlüsselarten:
* - Klassisch (console.curseforge.com): Header x-api-key, oft $2a$10$…
* - Studios / Profil-PAT: Header "Authorization: Key cfc_pat_…"
*/
function cfAuthHeaders(apiKey) {
let key = String(apiKey || '').trim();
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
key = key.replace(/[\r\n\t]/g, '').trim();
// Komplette Kopfzeile eingefügt?
const m = key.match(/^authorization\s*:\s*key\s+(.+)$/i);
if (m) key = m[1].trim();
if (key.toLowerCase().startsWith('key ')) key = key.slice(4).trim();
if (!key) return null;
// Neues Studios-Format
if (/^cfc_pat_/i.test(key) || /^cf[a-z]_/i.test(key)) {
return { key, headers: { Authorization: 'Key ' + key } };
}
// Klassisch
return { key, headers: { 'x-api-key': key } };
}
function curseforgeZugang(settings) {
const s = settings || {};
const key = String(s.curseforgeApiKey || '').trim();
if (key) {
const auth = cfAuthHeaders(s.curseforgeApiKey);
if (auth) {
return {
modus: 'direkt',
basis: CURSEFORGE,
kopfzeilen: { 'x-api-key': key },
kopfzeilen: auth.headers,
downloadAdresse: (url) => url,
downloadKopfzeilen: { 'x-api-key': key },
downloadKopfzeilen: auth.headers,
};
}
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
@@ -426,6 +514,250 @@ function forgeCdnUrl(fileId, fileName) {
return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`;
}
const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 };
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552 };
function cfLoaderType(loader) {
return CF_LOADER[String(loader || '').toLowerCase()] || 0;
}
/*
* CurseForge-Katalogsuche. Braucht direkten API-Key (modus === 'direkt').
* Die Weiterleitung deckt absichtlich nur Dateiauflösung und Download ab.
*/
/*
* Prüft einen API-Key direkt gegen CurseForge (ohne den gespeicherten Stand).
* Drei Proben: games, Minecraft, Modsuche damit man sieht, ob der Key
* grundsätzlich gilt oder nur für bestimmte Bereiche fehlt.
*/
async function curseforgeTestKey(apiKey) {
const auth = cfAuthHeaders(apiKey);
if (!auth) {
return { ok: false, message: 'Kein API-Key angegeben.' };
}
const key = auth.key;
const headers = Object.assign({ Accept: 'application/json', 'User-Agent': UA }, auth.headers);
const proben = [
{ name: 'games', url: CURSEFORGE + '/games' },
{ name: 'minecraft', url: CURSEFORGE + '/games/432' },
{ name: 'suche', url: CURSEFORGE + '/mods/search?gameId=432&classId=6&pageSize=1&index=0' },
];
const ergebnisse = [];
for (const p of proben) {
try {
const res = await fetch(p.url, { headers });
let text = '';
try { text = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 120); } catch { /* egal */ }
ergebnisse.push({ name: p.name, status: res.status, ok: res.ok, text: res.ok ? 'ok' : text });
} catch (err) {
ergebnisse.push({ name: p.name, status: 0, ok: false, text: err.message });
}
}
const alleOk = ergebnisse.every((e) => e.ok);
const alle403 = ergebnisse.every((e) => e.status === 401 || e.status === 403);
let message;
if (alleOk) {
message = 'Schlüssel wird akzeptiert Katalog und Downloads sollten funktionieren.';
} else if (alle403) {
message = 'CurseForge lehnt diesen Schlüssel ab (HTTP 403/401). Entweder ist er falsch kopiert, '
+ 'wurde neu erzeugt („Regenerate“ macht den alten ungültig), oder das Organisationskonto '
+ 'ist bei CurseForge noch nicht für die API freigeschaltet.';
} else {
const suche = ergebnisse.find((e) => e.name === 'suche');
if (suche && !suche.ok && ergebnisse.filter((e) => e.name !== 'suche').every((e) => e.ok)) {
message = 'Der Schlüssel gilt grundsätzlich, aber die Modsuche wird abgelehnt. '
+ 'Für den Katalog wird der Organisationsschlüssel benötigt (nicht nur ein per-game Key).';
} else {
message = 'Gemischtes Ergebnis: ' + ergebnisse.map((e) => e.name + '=' + e.status).join(', ');
}
}
return {
ok: alleOk,
message,
laenge: key.length,
beginntMit: key.slice(0, 7),
siehtAusWieCurseForge: /^\$2[aby]\$\d{2}\$/.test(key) || /^cfc_pat_/i.test(key),
format: /^cfc_pat_/i.test(key) ? 'studios-pat' : (/^\$2[aby]\$/.test(key) ? 'klassisch' : 'unbekannt'),
proben: ergebnisse,
};
}
async function curseforgeSearch(zugang, {
query = '', gameVersion = '', loader = '', sort = 'popularity',
limit = 30, offset = 0, art = 'mod',
} = {}) {
if (!zugang || zugang.modus !== 'direkt') {
return {
total: 0,
hits: [],
needKey: true,
error: 'Für die CurseForge-Suche brauchst du einen eigenen API-Key in den Netzwerk-Einstellungen. '
+ 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.',
};
}
const classId = CF_CLASS[art] || CF_CLASS.mod;
const url = new URL(CURSEFORGE + '/mods/search');
url.searchParams.set('gameId', '432');
url.searchParams.set('classId', String(classId));
if (query) url.searchParams.set('searchFilter', query);
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
const lt = cfLoaderType(loader);
// Loader-Filter nur bei Mods sinnvoll
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
// sortField: 1=Featured 2=Popularity 3=LastUpdated 4=Name 5=Author 6=TotalDownloads
const sortMap = { popularity: 2, relevance: 2, downloads: 6, updated: 3, newest: 3, follows: 2 };
url.searchParams.set('sortField', String(sortMap[sort] || 2));
url.searchParams.set('sortOrder', 'desc');
url.searchParams.set('pageSize', String(Math.min(50, limit || 30)));
url.searchParams.set('index', String(offset || 0));
const res = await fetch(url, {
headers: Object.assign({
Accept: 'application/json',
'User-Agent': UA,
}, zugang.kopfzeilen),
});
if (!res.ok) {
let detail = '';
try { detail = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
if (res.status === 401 || res.status === 403) {
return {
total: 0,
hits: [],
needKey: true,
error: 'CurseForge lehnt den API-Key ab (HTTP ' + res.status + '). '
+ 'Bitte unter Optionen → Netzwerk einen gültigen Schlüssel von console.curseforge.com eintragen. '
+ 'Das Proxy-Token ist kein API-Key.'
+ (detail ? ' (' + detail + ')' : ''),
};
}
throw new Error('CurseForge-Suche fehlgeschlagen: HTTP ' + res.status + (detail ? ' ' + detail : ''));
}
const data = await res.json();
const hits = (data.data || []).map((m) => {
const author = (m.authors && m.authors[0] && m.authors[0].name) || '';
const logo = (m.logo && (m.logo.thumbnailUrl || m.logo.url)) || '';
return {
projectId: 'cf-' + m.id,
cfId: m.id,
slug: m.slug,
title: m.name,
description: m.summary || '',
author,
downloads: m.downloadCount || 0,
iconUrl: logo,
categories: (m.categories || []).map((c) => c.name).filter(Boolean),
source: 'curseforge',
};
});
const total = (data.pagination && data.pagination.totalCount) || hits.length;
return { total, hits };
}
function cfFileMatches(file, gameVersion, loader, art) {
if (!file || file.isAvailable === false) return false;
const gvs = file.gameVersions || [];
if (gameVersion && !gvs.some((v) => String(v).toLowerCase() === String(gameVersion).toLowerCase())) {
// manche Einträge trennen Version und Loader in gameVersions
const hasVer = gvs.some((v) => String(v) === gameVersion);
if (!hasVer) return false;
}
if (art === 'mod' && loader && loader !== 'vanilla') {
const want = String(loader).toLowerCase();
const ok = gvs.some((v) => String(v).toLowerCase() === want)
|| (file.sortableGameVersions || []).some((s) => {
const name = String(s.gameVersionName || s.gameVersion || '').toLowerCase();
return name === want;
});
// Wenn gar kein Loader in den Tags steht, trotzdem erlauben (manche Dateien)
const anyLoader = gvs.some((v) => /fabric|forge|quilt|neoforge/i.test(String(v)))
|| (file.sortableGameVersions || []).some((s) => /fabric|forge|quilt|neoforge/i.test(String(s.gameVersionName || '')));
if (anyLoader && !ok) return false;
}
return true;
}
async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
if (!zugang || zugang.modus !== 'direkt') {
const err = new Error('CurseForge-Dateiauswahl braucht einen eigenen API-Key.');
err.code = 'need-key';
throw err;
}
const url = new URL(CURSEFORGE + '/mods/' + modId + '/files');
url.searchParams.set('pageSize', '50');
url.searchParams.set('index', '0');
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
const lt = cfLoaderType(loader);
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
const res = await fetch(url, {
headers: Object.assign({ Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen),
});
if (!res.ok) throw new Error('CurseForge-Dateien fehlgeschlagen: HTTP ' + res.status);
const data = await res.json();
const files = data.data || [];
const match = files.find((f) => cfFileMatches(f, gameVersion, loader, art)) || files[0];
if (!match) return null;
const name = match.fileName || ('file-' + match.id + '.jar');
let dl = match.downloadUrl || null;
if (!dl) dl = forgeCdnUrl(match.id, name);
return {
fileId: match.id,
filename: name,
url: dl,
versionNumber: match.displayName || match.fileName || String(match.id),
dependencies: match.dependencies || [],
};
}
async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' };
const rawId = String(projectId || '').replace(/^cf-/, '');
const modId = Number(rawId);
if (!modId) return { ok: false, message: 'Ungültige CurseForge-Projekt-ID.' };
const file = await curseforgePickFile(zugang, modId, gameVersion, loader, art);
if (!file || !file.url) {
return {
ok: false,
reason: 'no-match',
message: `Keine passende Datei für ${gameVersion || '?'} / ${loader || 'any'} gefunden.`,
};
}
const dest = path.join(modsDir, file.filename);
// Download: direkt mit Key oder über Weiterleitung
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
const bytes = await downloadFile(url, dest, headers);
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
const depIds = (file.dependencies || [])
.filter((d) => d.relationType === 3 && d.modId)
.map((d) => d.modId);
return {
ok: true,
dependencies: depIds.map((id) => ({ projectId: 'cf-' + id, required: true })),
entry: {
projectId: 'cf-' + modId,
title: title || file.filename,
filename: file.filename,
versionId: String(file.fileId),
versionNumber: file.versionNumber,
gameVersion,
loader,
source: 'curseforge',
bytes,
},
};
}
/*
* Löst die Dateien eines CurseForge-Modpacks auf.
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
@@ -479,7 +811,7 @@ async function modrinthProjectTitle(projectId) {
module.exports = {
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
getMcVersions,
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle,
modrinthFindUpdates, downloadFile,
curseforgeResolveFiles, forgeCdnUrl, curseforgeZugang,
modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
};

View File

@@ -124,12 +124,25 @@ function gameDir(id) {
return path.join(instancePath(id), 'minecraft');
}
function modsDir(id) {
return path.join(gameDir(id), 'mods');
/*
* Inhalte je Art: Mods, Ressourcenpakete und Shader liegen in verschiedenen
* Ordnern des Spielverzeichnisses und werden getrennt verzeichnet.
* "mod" behält die bisherigen Namen, damit vorhandene Instanzen unverändert
* weiterlaufen.
*/
const INHALT_ORDNER = { mod: 'mods', resourcepack: 'resourcepacks', shader: 'shaderpacks' };
const INHALT_INDEX = { mod: 'mods.json', resourcepack: 'resourcepacks.json', shader: 'shaders.json' };
function inhaltsDir(id, art = 'mod') {
return path.join(gameDir(id), INHALT_ORDNER[art] || INHALT_ORDNER.mod);
}
function modsIndexFile(id) {
return path.join(instancePath(id), 'mods.json');
function modsDir(id, art = 'mod') {
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) ----------
@@ -142,8 +155,8 @@ function modsIndexFile(id) {
const MOD_ENDUNG = /\.(jar|zip)$/i;
const AUS_ENDUNG = /\.(jar|zip)\.disabled$/i;
function scanModsDir(id) {
const dir = modsDir(id);
function scanModsDir(id, art = 'mod') {
const dir = modsDir(id, art);
if (!fs.existsSync(dir)) return [];
const gefunden = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -163,9 +176,9 @@ function scanModsDir(id) {
return gefunden;
}
function listMods(id) {
const aufDerPlatte = new Map(scanModsDir(id).map((m) => [m.filename, m]));
const indexed = readJson(modsIndexFile(id), []);
function listMods(id, art = 'mod') {
const aufDerPlatte = new Map(scanModsDir(id, art).map((m) => [m.filename, m]));
const indexed = readJson(modsIndexFile(id, art), []);
const merged = new Map();
for (const entry of indexed) {
@@ -186,8 +199,8 @@ function listMods(id) {
* Schaltet eine Mod an oder aus, indem die Datei umbenannt wird.
* -> { ok, enabled } oder { ok: false, reason }
*/
function setModEnabled(id, filename, enabled) {
const dir = modsDir(id);
function setModEnabled(id, filename, enabled, art = 'mod') {
const dir = modsDir(id, art);
const anPfad = path.join(dir, filename);
const ausPfad = anPfad + '.disabled';
// Pfad darf den mods-Ordner nicht verlassen
@@ -212,18 +225,18 @@ function setModEnabled(id, filename, enabled) {
}
}
function addMod(id, entry) {
const list = listMods(id).filter((m) => m.filename !== entry.filename);
function addMod(id, entry, art = 'mod') {
const list = listMods(id, art).filter((m) => m.filename !== entry.filename);
list.push(entry);
writeJson(modsIndexFile(id), list);
writeJson(modsIndexFile(id, art), list);
return list;
}
function removeMod(id, filename) {
const list = listMods(id).filter((m) => m.filename !== filename);
writeJson(modsIndexFile(id), list);
function removeMod(id, filename, art = 'mod') {
const list = listMods(id, art).filter((m) => m.filename !== filename);
writeJson(modsIndexFile(id, art), list);
// zugehörige Datei löschen auch, wenn sie gerade abgeschaltet ist
const jar = path.join(modsDir(id), filename);
const jar = path.join(modsDir(id, art), filename);
for (const p of [jar, jar + '.disabled']) {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* egal */ }
}
@@ -423,6 +436,7 @@ module.exports = {
instancePath,
gameDir,
modsDir,
inhaltsDir,
iconDataUri,
setCustomImage,
clearCustomImage,

View File

@@ -24,6 +24,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
--text:#e4e4e6; --dim:#9a9aa2;
--sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
--danger:#e06c6c; --play:#5cb85c;
--input:#1e1e1e;
}
:root[data-theme="dark"] body{background:var(--win)}
: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"] #btn-console 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"] .modal-close{color:#aaa}
:root[data-theme="dark"] .modal-close:hover{color:#fff}
@@ -160,6 +162,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* ---------- Modals ---------- */
.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-sm{width:420px}.modal-lg{width:820px}
#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)}
@@ -224,9 +228,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* Mods */
.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-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 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}
.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)}
@@ -311,6 +323,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-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.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}
/* Konsole (dunkel wie ein Log) */