Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
129ab2d939 | ||
| b250ef16fd | |||
|
|
9e601b1962 | ||
|
|
176d20ae7b |
177
README.md
177
README.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"curseforgeProxyUrl": "",
|
||||
"curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J"
|
||||
"curseforgeProxyToken": ""
|
||||
}
|
||||
|
||||
110
src/index.html
110
src/index.html
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
415
src/main.js
415
src/main.js
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
500
src/renderer.js
500
src/renderer.js
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
368
src/services.js
368
src/services.js
@@ -227,10 +227,30 @@ const MODRINTH = 'https://api.modrinth.com/v2';
|
||||
|
||||
const MODRINTH_SORTS = ['relevance', 'downloads', 'follows', 'newest', 'updated'];
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
50
src/store.js
50
src/store.js
@@ -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,
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user