9 Commits
Author SHA1 Message Date
M_Viper de81575bcf Upload via GUI (143 Dateien) 2026-10-04 18:46:18 +00:00
M_Viper 76762ab567 Upload via GUI (143 Dateien) 2026-10-04 18:46:08 +00:00
M_Viper 6a0e92eb0f Upload via GUI (143 Dateien) 2026-10-04 18:46:00 +00:00
M_Viper 7e1fb616aa Upload via GUI (138 Dateien) 2026-10-04 14:49:45 +00:00
M_Viper 642d229d7e Upload via GUI (138 Dateien) 2026-10-04 14:49:44 +00:00
M_Viper e782cc3058 Upload via GUI (138 Dateien) 2026-10-04 14:49:43 +00:00
M_Viper 972a3e73da Upload via GUI (138 Dateien) 2026-10-03 14:20:38 +00:00
M_Viper e83c951256 Upload via GUI (138 Dateien) 2026-10-03 14:20:37 +00:00
M_Viper 67fe4cba48 Upload via GUI (138 Dateien) 2026-10-03 14:20:36 +00:00
49 changed files with 12157 additions and 8887 deletions
+52 -2
View File
@@ -2,7 +2,54 @@
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
## [1.1.6] - 2026-10-02 ## [1.2.0] - 2026-10-04
### Neu
- Untergruppen (optional): im Feld „Gruppe“ mit „/“ trennen, z. B. „Modpacks/Survival“ – auch mehrere Ebenen tief. Untergruppen erscheinen eingerückt unter ihrer Gruppe, lassen sich einzeln einklappen und per Drag & Drop befüllen; die Anzahl an einer Gruppe zählt ihre Untergruppen mit
- Importe aus anderen Launchern zeigen jetzt genauen Fortschritt: Instanz x von y, übertragene GB, Prozent, Tempo, Restzeit und die gerade kopierte Datei
- Import abbrechen: fertig übernommene Instanzen bleiben erhalten, die gerade halb kopierte wird wieder entfernt
- Vor dem Kopieren prüft AeroMC, ob genug Speicherplatz frei ist, und nennt sonst benötigte und freie GB
- Konto-Menü oben rechts (wie in MultiMC): Ein Klick auf den Kontonamen klappt alle Konten mit Spielerkopf auf – das aktive ist markiert, ein Klick wechselt sofort. Dazu „Konto hinzufügen…“, „Skin ändern…“ und „Konten verwalten…“
### Geändert
- Plugin „AeroMC Freunde“ 1.5.1: merkt sich den von Mojang bestätigten Namen schon bei der Anmeldung und beim Senden einer Anfrage – Spieler sind damit per Name auffindbar, bevor sie zum ersten Mal über AeroMC gespielt haben (Server-Update empfohlen, der Launcher funktioniert auch mit 1.5.0)
### Behoben
- Freundschaftsanfragen zeigten statt des Namens nur eine lange ID (UUID), wenn der Absender noch nie über AeroMC gespielt hatte – fehlende Namen holt der Launcher jetzt direkt bei Mojang (auch in der Freundesliste und bei gesendeten Anfragen)
- Große Importe (viele GB, z. B. 60 GB aus MultiMC/Prism, CurseForge, Modrinth App, Minecraft Launcher oder Technic) froren den Launcher komplett ein – kopiert wird jetzt im Hintergrund, der Launcher bleibt bedienbar
- Einzelne gesperrte Dateien (z. B. eine gerade geöffnete Welt) brechen den Import nicht mehr ab, sie werden übersprungen und gemeldet
- „Instanz kopieren“, „Löschen“ und das Ersetzen einer vorhandenen Instanz beim erneuten Import blockierten bei großen Instanzen den Launcher – das läuft jetzt im Hintergrund
- Der Spielerkopf oben neben dem Kontonamen und in der Kontoliste zeigte die 2. Skin-Ebene (Mütze, Haare, Kragen, Ärmel) nicht sichtbar – er wird jetzt selbst in 3D gezeichnet, die 2. Ebene steht wie im Spiel plastisch ab
## [1.1.9] - 2026-10-04
### Neu
- Startanimation: Beim Öffnen dreht sich das AeroMC-Logo mit Schriftzug, danach baut sich der Launcher von der Mitte nach außen auf – Schriftzug passend zum Design (Minecraft mit Pixel-Schrift), nicht beim Start im Infobereich
- Lebendige Skin-Figur im Hintergrund: atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem Mauszeiger – pausiert, solange der Launcher minimiert oder im Hintergrund ist
- Fenster wie Optionen, Mods, Skin und Freunde wachsen beim Öffnen aus dem angeklickten Knopf heraus
- Instanzen blenden beim Öffnen des Launchers nacheinander ein; beim Darüberfahren hebt sich das Block-Symbol und dreht sich ein Stück
- Designwechsel blenden weich über statt hart umzuschalten
- Optionen → Darstellung → Animationen: „Startanimation“ und „Bewegte Oberfläche“ einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig
## [1.1.8] - 2026-10-04
### Behoben
- Das Skin-Fenster und „Aktuellen Skin laden“ im Skin-Editor zeigten bei manchen Konten einen Standard-Skin (z. B. Efe) statt des eigenen Skins – angezeigt wird jetzt der Skin, den andere Spieler im Spiel sehen; direkt nach einer eigenen Skin-Änderung noch kurz der neue Stand aus dem Konto, bis Mojang ihn überall verteilt hat
## [1.1.7] - 2026-10-03
### Neu
- Umhänge im Skin-Fenster: offiziellen Mojang-Umhang auswählen oder ablegen (Liste zeigt alle Umhänge, die das Konto besitzt) und den eigenen AeroMC-Umhang verwalten – Figur und Skin-Hintergrund zeigen den Umhang mit
- Skin-Editor, neuer Modus „Umhang“: eigene Umhänge gestalten, auch in HD bis 1024×512 – mit allen Werkzeugen, Pinselgröße, Spiegeln (linke ↔ rechte Kante), Zoom und Verschieben, Rückgängig auch über Größenwechsel
- Umhang auf der 3D-Figur bemalen, wahlweise als Elytra ansehen, beliebiges Bild (PNG, JPG …) mit „Bild einsetzen…“ auf die Außenseite setzen, aktuellen Umhang laden, als PNG speichern oder mit „Als Umhang übernehmen“ hochladen
- AeroMC-Umhänge im Spiel: in jeder Minecraft-Version und mit jedem Loader (Vanilla, Fabric, Quilt, Forge, NeoForge) ohne zusätzliche Mod sichtbar – für einen selbst und für andere AeroMC-Spieler; abschaltbar unter Optionen → Spiel → Umhänge
- Mod „AeroMC Umhänge“ für Fabric und Quilt: damit sehen auch Spieler mit anderen Launchern (offizieller Launcher, MultiMC, Prism, Modrinth App …) die AeroMC-Umhänge – ein Jar für Minecraft 1.14 bis 26.x, nur auf dem Client nötig
### Geändert
- Suche in der Werkzeugleiste: statt des dauerhaft sichtbaren Suchfelds gibt es eine Lupe – ein Klick oder Strg+F klappt das Feld auf, Esc leert und schließt es, mit Suchbegriff bleibt es offen
- Benötigt für eigene Umhänge das Plugin „AeroMC Freunde“ 1.5.0 auf dem Server
## [1.1.6] - 2026-10-03
### Neu ### Neu
- Skin-Editor: Skins direkt im Launcher pixeln – erreichbar im Skin-Fenster über „Im Editor bearbeiten…“ - Skin-Editor: Skins direkt im Launcher pixeln – erreichbar im Skin-Fenster über „Im Editor bearbeiten…“
@@ -15,11 +62,14 @@ Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
- Aktuellen Skin laden, PNG öffnen oder speichern und das Ergebnis mit einem Klick als Skin übernehmen – klassische und schmale Arme, alte 64×32-Skins werden automatisch umgewandelt - Aktuellen Skin laden, PNG öffnen oder speichern und das Ergebnis mit einem Klick als Skin übernehmen – klassische und schmale Arme, alte 64×32-Skins werden automatisch umgewandelt
- Skin-Hintergrund: Armform wählbar (Optionen → Darstellung) – „Automatisch“ übernimmt die Armform des Kontos, alternativ fest „Normal“ oder „Schmal“ - Skin-Hintergrund: Armform wählbar (Optionen → Darstellung) – „Automatisch“ übernimmt die Armform des Kontos, alternativ fest „Normal“ oder „Schmal“
- Designs zur Auswahl (Optionen → Darstellung → Design, mit Vorschau-Kacheln): „Klassisch“ hell oder dunkel wie bisher, „AeroMC“ im Look der Webseite (Nachtblau, Cyan-Verläufe, runde Knöpfe), „Nachtlila“ (wie AeroMC, nur in dunklem Lila) und „Minecraft“ mit Block-Optik, Stein-Knöpfen, grünem Starten-Knopf und eigener Pixel-Schrift - Designs zur Auswahl (Optionen → Darstellung → Design, mit Vorschau-Kacheln): „Klassisch“ hell oder dunkel wie bisher, „AeroMC“ im Look der Webseite (Nachtblau, Cyan-Verläufe, runde Knöpfe), „Nachtlila“ (wie AeroMC, nur in dunklem Lila) und „Minecraft“ mit Block-Optik, Stein-Knöpfen, grünem Starten-Knopf und eigener Pixel-Schrift
- Ein gewähltes Design gilt sofort in allen offenen Fenstern - Ein Klick auf eine Kachel zeigt das Design sofort zur Probe, nach dem Speichern gilt es in allen offenen Fenstern
- Eigene Titelleiste im Design statt der weißen Windows-Leiste – Minimieren, Maximieren und Schließen bleiben echte Windows-Knöpfe (Andocken, Fensterlayouts) und werden passend eingefärbt - Eigene Titelleiste im Design statt der weißen Windows-Leiste – Minimieren, Maximieren und Schließen bleiben echte Windows-Knöpfe (Andocken, Fensterlayouts) und werden passend eingefärbt
- Import aus der Modrinth App (Importieren → „Modrinth App“, auch im Installer): übernimmt Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen – auch bei verlegtem Datenordner und während die Modrinth App läuft - Import aus der Modrinth App (Importieren → „Modrinth App“, auch im Installer): übernimmt Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen – auch bei verlegtem Datenordner und während die Modrinth App läuft
- Aus der Modrinth App übernommene Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC aktualisieren - Aus der Modrinth App übernommene Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC aktualisieren
### Geändert
- Die Design-Auswahl (bisher „Design: Dunkel/Hell“ unter Optionen → Allgemein) steht jetzt unter Optionen → Darstellung
### Behoben ### Behoben
- Das Symbol für „Optionen“ sah aus wie eine Sonne – jetzt ein Zahnrad - Das Symbol für „Optionen“ sah aus wie eine Sonne – jetzt ein Zahnrad
- Der Skin-Hintergrund zeigte schmale (Alex-)Skins mit breiten Armen – dadurch erschienen falsche Streifen an den Armen - Der Skin-Hintergrund zeigte schmale (Alex-)Skins mit breiten Armen – dadurch erschienen falsche Streifen an den Armen
+40 -4
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung. Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
Aktueller Stand: Version 1.1.6 Aktueller Stand: Version 1.2.0
<img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" /> <img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" />
@@ -62,9 +62,11 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar - Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar
**Konten, Welten und Backups** **Konten, Welten und Backups**
- Microsoft-Login mit mehreren Konten - Microsoft-Login mit mehreren Konten – Wechsel mit einem Klick über das Konto-Menü oben rechts
- Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen - Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen
- **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig - **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
- **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“
- Offiziellen Mojang-Umhang direkt im Launcher wechseln oder ablegen
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd) - Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten - Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt) - Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
@@ -130,7 +132,7 @@ Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable
Jede Instanz hat: Jede Instanz hat:
- Namen und optionale Gruppe - Namen und optionale Gruppe – Untergruppen mit „/“ trennen, z. B. `Modpacks/Survival` (eingerückt, einzeln einklappbar, per Drag & Drop befüllbar)
- Minecraft-Version und optionalen Mod-Loader - Minecraft-Version und optionalen Mod-Loader
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen - optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
- eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste - eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste
@@ -162,7 +164,7 @@ Im Launcher:
| Taste | Wirkung | | Taste | Wirkung |
|---|---| |---|---|
| Strg+F | Suche fokussieren | | Strg+F | Suche öffnen (Lupe in der Werkzeugleiste), Esc leert und schließt sie |
| Strg+N | Neue Instanz anlegen | | Strg+N | Neue Instanz anlegen |
| Strg+1 … Strg+5 | Schnellstart-Instanz starten | | Strg+1 … Strg+5 | Schnellstart-Instanz starten |
| Entf | Markierte Instanz(en) löschen | | Entf | Markierte Instanz(en) löschen |
@@ -195,6 +197,8 @@ Im Spiel (solange ein über AeroMC gestartetes Minecraft läuft):
Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt. Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
**Große Importe:** Auch viele Gigabyte werden im Hintergrund kopiert – der Launcher bleibt bedienbar. Vorher prüft AeroMC, ob genug Speicherplatz frei ist. Oben im Import-Fenster stehen Instanz, übertragene GB, Prozent, Tempo, Restzeit und die aktuelle Datei; **Abbrechen** behält fertig übernommene Instanzen und entfernt die halb kopierte. Einzelne gesperrte Dateien werden übersprungen und gemeldet.
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. 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.
**Modrinth App:** Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich weiter aktualisieren. Ein in der App verlegter Datenordner wird automatisch gefunden, und die Modrinth App darf dabei geöffnet bleiben – AeroMC liest auch ihr Schreibprotokoll mit. An den Daten der Modrinth App wird nichts verändert. **Modrinth App:** Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich weiter aktualisieren. Ein in der App verlegter Datenordner wird automatisch gefunden, und die Modrinth App darf dabei geöffnet bleiben – AeroMC liest auch ihr Schreibprotokoll mit. An den Daten der Modrinth App wird nichts verändert.
@@ -279,6 +283,33 @@ Die Änderung wird direkt bei Mojang für das angemeldete Konto hinterlegt und g
| M | Spiegeln ein/aus | | M | Spiegeln ein/aus |
| L | Schicht wechseln | | L | Schicht wechseln |
| Strg+Z / Strg+Y | Rückgängig / Wiederholen | | Strg+Z / Strg+Y | Rückgängig / Wiederholen |
| 0 | Umhang: Ansicht einpassen |
### Umhänge
Im Skin-Fenster (**Kontoname oben rechts → Skin ändern…**) gibt es den Bereich **Umhang**:
- **Offizieller Umhang:** zeigt alle Umhänge, die dein Konto bei Mojang besitzt (z. B. Migrator, Pan …) – auswählen oder „Kein Umhang“. Das gilt wie der Skin überall, auch außerhalb von AeroMC.
- **AeroMC-Umhang:** dein eigener Umhang, auch in HD. **Gestalten…** öffnet den Skin-Editor im Modus **Umhang**, **Entfernen** löscht ihn wieder.
Im Editor-Modus **Umhang**:
- Größe wählen: Normal (64×32) oder HD 128×64, 256×128, 512×256, 1024×512
- Alle Werkzeuge wie beim Skin, dazu Pinselgröße (1–16 Pixel); Spiegeln malt linke und rechte Kante gleichzeitig
- Mausrad zoomt, rechte oder mittlere Maustaste verschiebt die Ansicht, **0** passt sie wieder ein
- In der 3D-Ansicht direkt auf dem Umhang an der Figur malen; **Als Elytra zeigen** zeigt dieselbe Datei als Flügel
- **Bild einsetzen…** setzt ein beliebiges Bild (PNG, JPG …) auf die Außenseite
- **Aktuellen Umhang laden**, PNG öffnen oder speichern, **Als Umhang übernehmen** lädt ihn hoch
**Wer sieht den AeroMC-Umhang?**
| Spieler | sieht den AeroMC-Umhang? |
|---|---|
| Spielt über AeroMC (Optionen → Spiel → Umhänge eingeschaltet) | Ja – in jeder Minecraft-Version und mit jedem Loader, ohne Mod |
| Anderer Launcher mit Fabric oder Quilt und dem Mod **„AeroMC Umhänge“** | Ja – ein Jar für Minecraft 1.14 bis 26.x, nur auf dem Client nötig |
| Anderer Launcher ohne den Mod (auch mit OptiFine) | Nein – sieht weiter den offiziellen Mojang-Umhang |
Der AeroMC-Umhang hat Vorrang vor dem offiziellen Umhang. Benötigt das Plugin „AeroMC Freunde“ 1.5.0 auf dem Server.
## Backup und Wiederherstellung ## Backup und Wiederherstellung
@@ -308,10 +339,15 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
### Darstellung ### Darstellung
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern - Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
- Block-Symbole räumlich (3D) - Block-Symbole räumlich (3D)
- Animationen:
- **Startanimation**: Beim Öffnen dreht sich das AeroMC-Logo, danach baut sich das Fenster von der Mitte aus auf (nicht beim Start im Infobereich)
- **Bewegte Oberfläche**: Die Skin-Figur im Hintergrund atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden beim Öffnen ein und heben sich beim Darüberfahren, Designs blenden weich über
- Beides einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig
- Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten - Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten
### Spiel ### Spiel
- Standard-RAM (min./max.) - Standard-RAM (min./max.)
- Umhänge: AeroMC-Umhänge im Spiel anzeigen (gilt ab dem nächsten Start)
- Fenstergröße bzw. maximiert starten - Fenstergröße bzw. maximiert starten
- Mods: vor jedem Start automatisch nach Updates suchen und installieren - Mods: vor jedem Start automatisch nach Updates suchen und installieren
+42
View File
@@ -0,0 +1,42 @@
package de.aeromc.capes;
import java.lang.instrument.Instrumentation;
/**
* AeroMC-Umhang-Agent.
*
* Wird vom Launcher per -javaagent an Minecraft angehängt (jede Version, jeder
* Loader). Er klinkt sich in Mojangs authlib ein – über sie lädt Minecraft für
* jeden Spieler Skin und Umhang – und setzt dort den AeroMC-Umhang des Spielers
* ein, falls er einen hat. Alles Weitere (Herunterladen, Zwischenspeichern,
* Anzeigen, auch HD) erledigt Minecraft selbst wie bei einem offiziellen Umhang.
*
* Argument: Basisadresse des Umhang-Servers (…/wp-json/aeromc/v1).
*/
public final class Agent {
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
public static final String SCHLUESSEL = "aeromc.capes.hook";
private Agent() {}
public static void premain(String argument, Instrumentation inst) {
starte(argument, inst);
}
public static void agentmain(String argument, Instrumentation inst) {
starte(argument, inst);
}
private static void starte(String argument, Instrumentation inst) {
try {
String basis = argument != null && argument.startsWith("http") ? argument : Nachschlagen.STANDARD_BASIS;
// Die eingefügten Befehle in authlib holen sich den Dienst von hier – nur
// über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt.
System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis));
inst.addTransformer(new Umbau());
System.out.println("[AeroMC] Umhang-Agent aktiv");
} catch (Throwable t) {
System.out.println("[AeroMC] Umhang-Agent konnte nicht starten: " + t);
}
}
}
+183
View File
@@ -0,0 +1,183 @@
package de.aeromc.capes;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Wird aus authlib heraus mit (ergebnis, argument) aufgerufen und gibt das
* Ergebnis zurück – mit eingesetztem AeroMC-Umhang, falls der Spieler einen hat.
* Arbeitet nur über Reflexion, damit es zu jeder authlib-Fassung passt, und
* fängt jeden Fehler ab: im schlimmsten Fall bleibt alles wie ohne Agent.
*/
public final class Nachschlagen implements BiFunction<Object, Object, Object> {
public static final String STANDARD_BASIS = "https://viper-network.de/wp-json/aeromc/v1";
private static final long GEFUNDEN_MS = 5 * 60 * 1000L;
private static final long KEINER_MS = 2 * 60 * 1000L;
private static final Pattern URL_MUSTER = Pattern.compile("\"url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
private static final Pattern ID_MUSTER = Pattern.compile("\"profileId\"\\s*:\\s*\"([0-9a-fA-F-]{32,36})\"");
private static final class Eintrag {
final String url; // null = kein Umhang
final long zeit;
Eintrag(String url) { this.url = url; this.zeit = System.currentTimeMillis(); }
boolean frisch() { return System.currentTimeMillis() - zeit < (url != null ? GEFUNDEN_MS : KEINER_MS); }
}
private final String basis;
private final Map<String, Eintrag> speicher = new ConcurrentHashMap<>();
private final Map<String, Boolean> unterwegs = new ConcurrentHashMap<>();
private final Map<String, Boolean> gemeldet = new ConcurrentHashMap<>();
public Nachschlagen(String basis) {
this.basis = basis.endsWith("/") ? basis.substring(0, basis.length() - 1) : basis;
}
@Override
public Object apply(Object ergebnis, Object argument) {
try {
String id = spielerId(argument);
if (id == null) return ergebnis;
String url = umhangUrl(id);
if (url == null) return ergebnis;
Object neu = mitUmhang(ergebnis, url, argument);
if (neu != ergebnis && gemeldet.putIfAbsent(id, Boolean.TRUE) == null) {
System.out.println("[AeroMC] Umhang fuer " + id + " eingesetzt");
}
return neu;
} catch (Throwable t) {
return ergebnis;
}
}
// ---------- Spieler erkennen ----------
private static String spielerId(Object argument) throws Exception {
if (argument == null) return null;
String klasse = argument.getClass().getName();
if (klasse.endsWith("GameProfile")) {
Object id = rufe(argument, "getId", "id");
return id instanceof UUID ? hex((UUID) id) : null;
}
if (klasse.endsWith("Property")) {
Object wert = rufe(argument, "getValue", "value");
if (!(wert instanceof String)) return null;
String json = new String(Base64.getDecoder().decode(((String) wert).trim()), StandardCharsets.UTF_8);
Matcher m = ID_MUSTER.matcher(json);
return m.find() ? m.group(1).replace("-", "").toLowerCase() : null;
}
return null;
}
private static String hex(UUID u) {
return u.toString().replace("-", "").toLowerCase();
}
private static Object rufe(Object ziel, String... namen) throws Exception {
for (String n : namen) {
try {
Method m = ziel.getClass().getMethod(n);
return m.invoke(ziel);
} catch (NoSuchMethodException ignoriert) {
// nächster Name
}
}
return null;
}
// ---------- Umhang-Server ----------
private String umhangUrl(String id) {
Eintrag e = speicher.get(id);
if (e != null && e.frisch()) return e.url;
// Auf dem Haupt-/Render-Thread nie warten: dort nur im Hintergrund holen;
// Minecraft fragt beim nächsten Laden des Spielers erneut
String thread = Thread.currentThread().getName();
boolean hauptThread = thread.equals("Render thread") || thread.equals("Client thread") || thread.equals("main");
if (hauptThread) {
if (unterwegs.putIfAbsent(id, Boolean.TRUE) == null) {
Thread t = new Thread(() -> { try { hole(id); } finally { unterwegs.remove(id); } }, "AeroMC-Umhang");
t.setDaemon(true);
t.start();
}
return e != null ? e.url : null;
}
return hole(id);
}
private String hole(String id) {
try {
HttpURLConnection c = (HttpURLConnection) new URL(basis + "/capes?uuids=" + id).openConnection();
c.setConnectTimeout(2000);
c.setReadTimeout(3000);
c.setRequestProperty("User-Agent", "AeroMC-Umhang/1.0");
c.setRequestProperty("Accept", "application/json");
if (c.getResponseCode() != 200) {
speicher.put(id, new Eintrag(null));
return null;
}
String json;
try (InputStream in = c.getInputStream()) {
ByteArrayOutputStream puffer = new ByteArrayOutputStream();
byte[] b = new byte[4096];
int n;
while ((n = in.read(b)) > 0 && puffer.size() < 65536) puffer.write(b, 0, n);
json = new String(puffer.toByteArray(), StandardCharsets.UTF_8);
}
String url = null;
Matcher m = URL_MUSTER.matcher(json);
if (json.contains("\"" + id + "\"") && m.find()) {
url = m.group(1).replace("\\/", "/");
if (!url.startsWith("https://")) url = null;
}
speicher.put(id, new Eintrag(url));
return url;
} catch (Throwable t) {
// Server nicht erreichbar: kurz merken, damit nicht jeder Spieler hängt
speicher.put(id, new Eintrag(null));
return null;
}
}
// ---------- Umhang einsetzen ----------
private static Object textur(ClassLoader cl, String url) throws Exception {
Class<?> k = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture", false, cl);
Constructor<?> ctor = k.getConstructor(String.class, Map.class);
return ctor.newInstance(url, null);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static Object mitUmhang(Object ergebnis, String url, Object argument) throws Exception {
ClassLoader cl = argument.getClass().getClassLoader();
if (ergebnis instanceof Map) {
// authlib 1–4: Map<MinecraftProfileTexture.Type, MinecraftProfileTexture>
Class<?> typ = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture$Type", false, cl);
Object cape = Enum.valueOf((Class<Enum>) typ, "CAPE");
Map neu = new HashMap((Map) ergebnis);
neu.put(cape, textur(cl, url));
return neu;
}
if (ergebnis == null) return null;
// authlib 5+: record MinecraftProfileTextures(skin, cape, elytra, signatureState)
Class<?> k = ergebnis.getClass();
Object skin = k.getMethod("skin").invoke(ergebnis);
Object elytra = k.getMethod("elytra").invoke(ergebnis);
Object signatur = k.getMethod("signatureState").invoke(ergebnis);
for (Constructor<?> ctor : k.getConstructors()) {
if (ctor.getParameterCount() == 4) {
return ctor.newInstance(skin, textur(cl, url), elytra, signatur);
}
}
return ergebnis;
}
}
+78
View File
@@ -0,0 +1,78 @@
package de.aeromc.capes;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
/**
* Baut YggdrasilMinecraftSessionService beim Laden um: vor jedem "return" der
* Textur-Methode wird das Ergebnis an den Nachschlage-Dienst gereicht, der den
* AeroMC-Umhang einsetzt.
*
* authlib 5+ (Minecraft 1.20.2 … 26.x):
* MinecraftProfileTextures unpackTextures(Property)
* authlib 1–4 (Minecraft 1.8 … 1.20.1):
* Map&lt;Type, MinecraftProfileTexture&gt; getTextures(GameProfile, boolean)
*
* Eingefügt wird nur geradliniger Code ohne Sprünge – die vorhandenen
* Stack-Map-Frames bleiben gültig, es muss nichts neu berechnet werden.
*/
final class Umbau implements ClassFileTransformer {
static final String ZIEL = "com/mojang/authlib/yggdrasil/YggdrasilMinecraftSessionService";
@Override
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
if (!ZIEL.equals(name) || umdefiniert != null) return null;
try {
ClassReader leser = new ClassReader(klasse);
ClassWriter schreiber = new ClassWriter(leser, ClassWriter.COMPUTE_MAXS);
final int[] umgebaut = {0};
leser.accept(new ClassVisitor(Opcodes.ASM9, schreiber) {
@Override
public MethodVisitor visitMethod(int zugriff, String mname, String desc, String sig, String[] ausn) {
MethodVisitor mv = super.visitMethod(zugriff, mname, desc, sig, ausn);
if ((zugriff & Opcodes.ACC_STATIC) != 0) return mv;
String rueckgabe = null;
if ("unpackTextures".equals(mname) && desc.startsWith("(Lcom/mojang/authlib/properties/Property;)L")) {
rueckgabe = desc.substring(desc.indexOf(")L") + 2, desc.length() - 1);
} else if ("getTextures".equals(mname) && desc.equals("(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;")) {
rueckgabe = "java/util/Map";
}
if (rueckgabe == null) return mv;
umgebaut[0]++;
final String typ = rueckgabe;
return new MethodVisitor(Opcodes.ASM9, mv) {
@Override
public void visitInsn(int op) {
if (op == Opcodes.ARETURN) {
// Stapel: [ergebnis] -> [dienst, ergebnis, argument] -> apply -> [neu]
super.visitVarInsn(Opcodes.ALOAD, 1);
super.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false);
super.visitLdcInsn(Agent.SCHLUESSEL);
super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false);
super.visitTypeInsn(Opcodes.CHECKCAST, "java/util/function/BiFunction");
super.visitInsn(Opcodes.DUP_X2);
super.visitInsn(Opcodes.POP);
super.visitMethodInsn(Opcodes.INVOKEINTERFACE, "java/util/function/BiFunction", "apply",
"(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", true);
super.visitTypeInsn(Opcodes.CHECKCAST, typ);
}
super.visitInsn(op);
}
};
}
}, 0);
if (umgebaut[0] == 0) return null;
System.out.println("[AeroMC] authlib fuer Umhaenge vorbereitet (" + umgebaut[0] + " Methode)");
return schreiber.toByteArray();
} catch (Throwable t) {
System.out.println("[AeroMC] authlib konnte nicht angepasst werden: " + t);
return null;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.Enumeration;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.commons.ClassRemapper;
import org.objectweb.asm.commons.Remapper;
/**
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
*
* Aufruf: java Shade &lt;ziel.jar&gt; &lt;klassenordner&gt; &lt;asm.jar&gt;
*/
public class Shade {
static final Remapper UMBENENNEN = new Remapper() {
@Override
public String map(String name) {
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
}
};
static byte[] umbenennen(byte[] klasse) {
ClassReader r = new ClassReader(klasse);
ClassWriter w = new ClassWriter(0);
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
return w.toByteArray();
}
static byte[] lies(InputStream in) throws Exception {
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] p = new byte[8192];
int n;
while ((n = in.read(p)) > 0) b.write(p, 0, n);
return b.toByteArray();
}
public static void main(String[] a) throws Exception {
Manifest mf = new Manifest();
Attributes at = mf.getMainAttributes();
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
at.putValue("Can-Retransform-Classes", "false");
at.putValue("Implementation-Title", "AeroMC Umhang-Agent");
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
// eigene Klassen
File basis = new File(a[1]);
for (File f : Files.walk(basis.toPath()).map(p -> p.toFile()).filter(f -> f.getName().endsWith(".class")).toArray(File[]::new)) {
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
out.putNextEntry(new JarEntry(name));
out.write(umbenennen(Files.readAllBytes(f.toPath())));
out.closeEntry();
}
// ASM (umbenannt), ohne Modul-Infos und Signaturen
try (JarFile asm = new JarFile(a[2])) {
Enumeration<JarEntry> e = asm.entries();
while (e.hasMoreElements()) {
JarEntry je = e.nextElement();
String n = je.getName();
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
out.write(neu);
out.closeEntry();
}
}
}
}
}
@@ -5,7 +5,7 @@
}, },
"projects": { "projects": {
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": { "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
"version": "1.1.6", "version": "1.2.0",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
Binary file not shown.
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")] [assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")] [assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.6.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.6")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.0")]
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.1.6.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.2.0.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")] [assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert. // Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -1 +1 @@
5b6f28fb41c3f0e5fe29f9489cfc618a7d6593efb71d420030e4a1d4fdb4066e e1b134ff0a2fb2387ee195d0721c9af9546a7821126b2fd5bae3f90099e4d686
+1 -1
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {} "C:\\Users\\Viper\\.nuget\\packages\\": {}
}, },
"project": { "project": {
"version": "1.1.6", "version": "1.2.0",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "lJiGEWCFg6M=", "dgSpecHash": "IwFtzT9tvgw=",
"success": true, "success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
+42
View File
@@ -0,0 +1,42 @@
# AeroMC Capes
Shows the **custom capes (including HD up to 1024×512)** of players who designed their cape in the
[AeroMC Launcher](https://aeromc.viper.ipv64.net) – right in the game, even if you use a different
launcher yourself.
## How it works
- Drop the mod into your `mods` folder – done. No configuration, no Fabric API required.
- Whenever Minecraft loads a player's skin, the mod asks the AeroMC server whether that player has
an AeroMC cape and shows it instead of the regular cape.
- If a player has no AeroMC cape, nothing changes (official Mojang cape or none).
- **Client-side only** – servers don't need anything.
## Compatibility
- **Fabric** and **Quilt**
- **One jar for all versions**: Minecraft 1.14 to 26.x
- Works with old Fabric Loader versions too (0.10 and newer)
- Players using the AeroMC Launcher don't need this mod (the launcher already does this on its
own) – but having it installed doesn't hurt either.
## Privacy
The mod only sends the (public) UUIDs of players whose skins are currently being loaded to
`viper-network.de` to look up their capes. Results are cached for a few minutes.
No account or login data is ever transmitted.
## Create your own cape
In the AeroMC Launcher: click your account name (top right) → **Skin ändern…** (Change skin) →
in the **Umhang** (Cape) section click **Gestalten…** (Design). You can paint your cape pixel by
pixel or insert any image, in sizes from 64×32 up to HD 1024×512.
---
### Hinweise zum Hochladen (nicht Teil der Beschreibung)
- Name: AeroMC Capes (URL bleibt `aeromc-umhaenge`)
- Kurzbeschreibung: Shows custom capes (HD up to 1024×512) made in the AeroMC Launcher – even if you use another launcher. One jar for 1.14–26.x, client-side.
- Datei: `dist/aeromc-umhaenge-fabric-1.0.0.jar`
- Loader: Fabric, Quilt · Umgebung: Client · Server: nicht nötig
- Spielversionen: alle Releases von 1.14 bis zur aktuellen
- Kategorien: Sozial (hervorgehoben), Werkzeug („Cosmetic“ gibt es für Mods nicht)
- Abhängigkeiten: keine
- Lizenz: aktuell „All Rights Reserved“ (in `fabric.mod.json`) – Modrinth akzeptiert das, eine
offene Lizenz (z. B. MIT) wirkt aber vertrauenswürdiger.
+10
View File
@@ -0,0 +1,10 @@
{
"required": false,
"minVersion": "0.8",
"package": "de.aeromc.capes.mixin",
"compatibilityLevel": "JAVA_8",
"client": ["SessionServiceMixin", "SkinLaderMixin"],
"injectors": {
"defaultRequire": 0
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"id": "aeromc-capes",
"version": "1.0.0",
"name": "AeroMC Umhänge",
"description": "Zeigt die eigenen Umhänge (auch HD) von AeroMC-Spielern im Spiel – gestaltet im AeroMC Launcher. Nur auf dem Client nötig. Funktioniert mit jeder Minecraft-Version, die Fabric unterstützt.",
"authors": ["M_Viper"],
"contact": {
"homepage": "https://aeromc.viper.ipv64.net"
},
"license": "LicenseRef-All-Rights-Reserved",
"icon": "assets/aeromc-capes/icon.png",
"environment": "client",
"mixins": ["aeromc-capes.mixins.json"],
"depends": {
"fabricloader": ">=0.10.0"
}
}
@@ -0,0 +1,48 @@
package de.aeromc.capes.mixin;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.Property;
import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService;
import de.aeromc.capes.mod.Umhaenge;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Hängt sich an die Textur-Methoden von authlib (beide Fassungen, siehe
* agent/…/Umbau.java). require = 0: Es greift immer nur die Methode, die es in
* der jeweiligen authlib-Fassung gibt, die andere wird still übersprungen.
* remap = false: authlib ist nicht verschleiert, Namen bleiben wie sie sind.
*/
@Mixin(value = YggdrasilMinecraftSessionService.class, remap = false)
public abstract class SessionServiceMixin {
// wird in den statischen Initialisierer von authlib übernommen: zeigt im Spielprotokoll, dass der Mod greift
static {
Umhaenge.authlibGreift = true;
Umhaenge.meldeAktiv("authlib");
}
// authlib 5+ (Minecraft 1.20.2 … 26.x)
@Inject(method = "unpackTextures", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
private void aeromc$neu(Property eigenschaft, CallbackInfoReturnable<Object> cir) {
aeromc$ersetze(cir, eigenschaft);
}
// authlib 1–4 (Minecraft 1.14 … 1.20.1)
@Inject(method = "getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
private void aeromc$alt(GameProfile profil, boolean sicher, CallbackInfoReturnable<Object> cir) {
aeromc$ersetze(cir, profil);
}
private static void aeromc$ersetze(CallbackInfoReturnable<Object> cir, Object argument) {
try {
Object alt = cir.getReturnValue();
Object neu = Umhaenge.mitUmhang(alt, argument);
if (neu != alt) cir.setReturnValue(neu);
} catch (Throwable ignoriert) {
// im Zweifel bleibt alles wie ohne Mod
}
}
}
@@ -0,0 +1,38 @@
package de.aeromc.capes.mixin;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.minecraft.MinecraftSessionService;
import de.aeromc.capes.mod.Umhaenge;
import net.minecraft.class_1071;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.util.Map;
/**
* Zweiter Weg für alte Fabric Loader (vor 0.12): Die lassen Mixins nur in
* Minecraft selbst zu, nicht in authlib – SessionServiceMixin greift dort also
* nie. Deshalb wird hier der Aufruf von getTextures im Skin-Lader von Minecraft
* (PlayerSkinProvider = class_1071, Intermediary-Name, gleich in 1.14 … 1.20.1)
* umgeleitet. Bei neuen Loadern erledigt das bereits SessionServiceMixin, dann
* reicht diese Umleitung das Ergebnis nur unverändert durch.
*/
@Mixin(value = class_1071.class, targets = "net.minecraft.class_1071$1", remap = false)
public abstract class SkinLaderMixin {
@Redirect(method = "*", at = @At(value = "INVOKE", remap = false,
target = "Lcom/mojang/authlib/minecraft/MinecraftSessionService;getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;"),
remap = false, require = 0)
private Map aeromc$texturen(MinecraftSessionService dienst, GameProfile profil, boolean sicher) {
Map texturen = dienst.getTextures(profil, sicher);
if (Umhaenge.authlibGreift) return texturen;
Umhaenge.meldeAktiv("Skin-Lader");
try {
Object neu = Umhaenge.mitUmhang(texturen, profil);
return neu instanceof Map ? (Map) neu : texturen;
} catch (Throwable ignoriert) {
return texturen;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package de.aeromc.capes.mod;
import de.aeromc.capes.Nachschlagen;
/**
* AeroMC Umhänge – Fabric/Quilt-Mod für Spieler, die NICHT den AeroMC Launcher
* nutzen. Er macht dasselbe wie der Umhang-Agent des Launchers: In Mojangs
* authlib wird der AeroMC-Umhang eines Spielers eingesetzt, sobald Minecraft
* dessen Skin lädt. Da nur authlib angepasst wird (nicht Minecraft selbst),
* passt ein und derselbe Mod zu jeder Minecraft-Version, die Fabric kennt.
* Alte Fabric Loader (vor 0.12) erlauben keine Mixins in authlib, dort greift
* stattdessen SkinLaderMixin im Skin-Lader von Minecraft.
*/
public final class Umhaenge {
private static final Nachschlagen DIENST = new Nachschlagen(Nachschlagen.STANDARD_BASIS);
/** true, sobald SessionServiceMixin in authlib eingebaut wurde (dann ist SkinLaderMixin überflüssig). */
public static volatile boolean authlibGreift;
private static volatile boolean gemeldet;
private Umhaenge() {}
/** Schreibt einmalig ins Spielprotokoll, dass (und über welchen Weg) der Mod greift. */
public static void meldeAktiv(String weg) {
if (gemeldet || agentAktiv()) return;
gemeldet = true;
System.out.println("[AeroMC] Umhang-Mod aktiv (" + weg + ")");
}
/** Läuft das Spiel über den AeroMC Launcher, setzt schon dessen Agent die Umhänge ein. */
public static boolean agentAktiv() {
return System.getProperties().containsKey("aeromc.capes.hook");
}
public static Object mitUmhang(Object ergebnis, Object argument) {
if (agentAktiv()) return ergebnis;
return DIENST.apply(ergebnis, argument);
}
}
+4
View File
@@ -0,0 +1,4 @@
package net.minecraft;
/** Nur zum Kompilieren: Platzhalter für Minecrafts PlayerSkinProvider (Intermediary-Name). Kommt nicht ins Mod-Jar. */
public class class_1071 {}
+4914 -4914
View File
File diff suppressed because it is too large Load Diff
+54 -54
View File
@@ -1,54 +1,54 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.1.6", "version": "1.2.0",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de", "homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net", "launcherWebsite": "https://aeromc.viper.ipv64.net",
"main": "src/main.js", "main": "src/main.js",
"author": "M_Viper", "author": "M_Viper",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"prebuild": "node scripts/write-build-config.js", "prebuild": "node scripts/write-build-config.js",
"prebuild:portable": "node scripts/write-build-config.js", "prebuild:portable": "node scripts/write-build-config.js",
"build": "electron-builder --win dir && node scripts/build-setup.js", "build": "electron-builder --win dir && node scripts/build-setup.js",
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"" "build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
}, },
"build": { "build": {
"appId": "com.aeromc.launcher", "appId": "com.aeromc.launcher",
"productName": "AeroMC", "productName": "AeroMC",
"artifactName": "AeroMC.${ext}", "artifactName": "AeroMC.${ext}",
"compression": "store", "compression": "store",
"files": [ "files": [
"src/**/*", "src/**/*",
"package.json", "package.json",
"LICENSE" "LICENSE"
], ],
"asarUnpack": [ "asarUnpack": [
"node_modules/loopback-capture/**/*", "node_modules/loopback-capture/**/*",
"node_modules/node-window-manager/**/*", "node_modules/node-window-manager/**/*",
"node_modules/ffmpeg-static/**/*" "node_modules/ffmpeg-static/**/*"
], ],
"win": { "win": {
"target": "dir", "target": "dir",
"icon": "src/assets/icon.ico" "icon": "src/assets/icon.ico"
} }
}, },
"devDependencies": { "devDependencies": {
"electron": "^28.0.0", "electron": "^28.0.0",
"electron-builder": "^24.0.0" "electron-builder": "^24.0.0"
}, },
"dependencies": { "dependencies": {
"@xhayper/discord-rpc": "^1.3.4", "@xhayper/discord-rpc": "^1.3.4",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"loopback-capture": "^3.0.2", "loopback-capture": "^3.0.2",
"node-window-manager": "^2.2.4", "node-window-manager": "^2.2.4",
"skinview3d": "^3.4.2", "skinview3d": "^3.4.2",
"sql.js": "^1.14.2", "sql.js": "^1.14.2",
"undici": "^6.28.0", "undici": "^6.28.0",
"unzipper": "^0.12.5" "unzipper": "^0.12.5"
} }
} }
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/*
* build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent)
* ------------------------------------------------------------------------------
* Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
* Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab).
* Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version.
*
* Aufruf: node scripts/build-agent.js
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const WURZEL = path.join(__dirname, '..');
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
function findeJdk() {
const kandidaten = [];
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
if (fs.existsSync(adoptium)) {
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
}
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
return jdk;
}
function findeAsm(name) {
const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name);
if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`);
const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v))
.sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0));
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
return datei;
}
function quellen(ordner) {
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
const p = path.join(ordner, e.name);
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
});
}
const jdk = findeJdk();
const javac = path.join(jdk, 'bin', 'javac');
const java = path.join(jdk, 'bin', 'java');
const asm = findeAsm('asm');
const asmCommons = findeAsm('asm-commons');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
const klassen = path.join(tmp, 'klassen');
const werkzeug = path.join(tmp, 'werkzeug');
console.log('JDK:', jdk);
console.log('ASM:', asm);
try {
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen,
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' });
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
+83
View File
@@ -0,0 +1,83 @@
'use strict';
/*
* build-mod.js – baut den Fabric/Quilt-Mod "AeroMC Umhänge" (dist/aeromc-umhaenge-fabric-<version>.jar)
* -------------------------------------------------------------------------------------------------
* Für Spieler, die NICHT den AeroMC Launcher nutzen. Der Mod greift wie der
* Umhang-Agent nur in Mojangs authlib ein (Mixin, ohne Namensumsetzung) – darum
* braucht es weder Gradle noch Fabric Loom, und eine Datei passt zu allen
* Minecraft-Versionen, die Fabric unterstützt. Übersetzt wird für Java 8.
*
* Gebraucht werden (nur beim Bauen) ein JDK sowie sponge-mixin und authlib aus
* dem Bibliotheksordner von AeroMC. Der Nachschlage-Code ist derselbe wie im
* Agenten (agent/src/de/aeromc/capes/Nachschlagen.java).
*
* Aufruf: node scripts/build-mod.js
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const WURZEL = path.join(__dirname, '..');
const LIB = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries');
const modInfo = JSON.parse(fs.readFileSync(path.join(WURZEL, 'mod', 'resources', 'fabric.mod.json'), 'utf8'));
const ZIEL = path.join(WURZEL, 'dist', `aeromc-umhaenge-fabric-${modInfo.version}.jar`);
function findeJdk() {
const kandidaten = [];
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
if (fs.existsSync(adoptium)) {
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
}
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
return jdk;
}
// neueste (oder mit aelteste=true älteste) Fassung einer Bibliothek im AeroMC-Bibliotheksordner
function bibliothek(gruppe, name, aelteste = false) {
const basis = path.join(LIB, ...gruppe.split('.'), name);
if (!fs.existsSync(basis)) throw new Error(`${name} nicht gefunden unter ${basis}`);
const fassungen = fs.readdirSync(basis).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const v = aelteste ? fassungen[0] : fassungen.pop();
const datei = path.join(basis, v, `${name}-${v}.jar`);
if (!fs.existsSync(datei)) throw new Error('Datei fehlt: ' + datei);
return datei;
}
function quellen(ordner) {
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
const p = path.join(ordner, e.name);
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
});
}
// authlib in der ältesten Fassung: SkinLaderMixin ruft getTextures(GameProfile, boolean) auf, das es nur bis authlib 4 gibt
const jdk = findeJdk();
const klassenpfad = [bibliothek('net.fabricmc', 'sponge-mixin'), bibliothek('com.mojang', 'authlib', true)].join(path.delimiter);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-mod-'));
const inhalt = path.join(tmp, 'inhalt');
const stubs = path.join(tmp, 'stubs');
console.log('JDK:', jdk);
try {
// Platzhalter für Minecraft-Klassen (nur zum Kompilieren, landen nicht im Jar)
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-d', stubs,
...quellen(path.join(WURZEL, 'mod', 'stubs'))], { stdio: 'inherit' });
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-proc:none', '-cp', klassenpfad + path.delimiter + stubs, '-d', inhalt,
...quellen(path.join(WURZEL, 'mod', 'src')),
path.join(WURZEL, 'agent', 'src', 'de', 'aeromc', 'capes', 'Nachschlagen.java')], { stdio: 'inherit' });
for (const datei of fs.readdirSync(path.join(WURZEL, 'mod', 'resources'))) {
fs.copyFileSync(path.join(WURZEL, 'mod', 'resources', datei), path.join(inhalt, datei));
}
fs.mkdirSync(path.join(inhalt, 'assets', 'aeromc-capes'), { recursive: true });
fs.copyFileSync(path.join(WURZEL, 'src', 'assets', 'logo.png'), path.join(inhalt, 'assets', 'aeromc-capes', 'icon.png'));
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
if (fs.existsSync(ZIEL)) fs.rmSync(ZIEL);
execFileSync(path.join(jdk, 'bin', 'jar'), ['--create', '--file', ZIEL, '-C', inhalt, '.'], { stdio: 'inherit' });
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" />
<title>AeroMC</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
#bild {
position: absolute; display: none; transform-origin: 0 0; will-change: transform, opacity;
border-radius: 6px; box-shadow: 0 18px 48px rgba(0, 0, 0, .45);
}
</style>
</head>
<body>
<img id="bild" alt="" />
<script>
// Hilfsfenster für "Fenster wachsen aus dem Knopf" (siehe fensteraufbau.js) – wird nach einmaligem Gebrauch geschlossen
const bild = document.getElementById('bild');
let start = 1;
// Bild an die Zielstelle legen und auf Knopfgröße zusammenschrumpfen (noch unsichtbar)
window.vorbereiten = (adresse, nach, von) => new Promise((fertig) => {
bild.onerror = () => fertig(false);
bild.onload = () => {
Object.assign(bild.style, { left: nach.x + 'px', top: nach.y + 'px', width: nach.width + 'px', height: nach.height + 'px' });
// wächst aus der Mitte des angeklickten Knopfs heraus (darf außerhalb liegen)
bild.style.transformOrigin = `${von.x + von.width / 2 - nach.x}px ${von.y + von.height / 2 - nach.y}px`;
start = Math.max(0.06, Math.min(0.5, Math.max(von.width / nach.width, von.height / nach.height)));
bild.style.transform = `scale(${start})`;
bild.style.opacity = '0';
bild.style.display = 'block';
// zwei Bildtakte abwarten, damit der Anfangszustand gezeichnet ist (höchstens 120 ms)
let erledigt = false;
const fertigEinmal = () => { if (!erledigt) { erledigt = true; fertig(true); } };
requestAnimationFrame(() => requestAnimationFrame(fertigEinmal));
setTimeout(fertigEinmal, 120);
};
bild.src = adresse;
});
window.abspielen = (ms) => bild.animate([
{ transform: `scale(${start})`, opacity: 0 },
{ opacity: 1, offset: 0.3 },
{ transform: 'scale(1)', opacity: 1 },
], { duration: ms, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' }).finished.then(() => true, () => false);
</script>
</body>
</html>
+16 -3
View File
@@ -154,14 +154,27 @@ function scan(inputPath) {
return { ok: true, instancesDir, count: list.length, instances: list }; return { ok: true, instancesDir, count: list.length, instances: list };
} }
function kopierNamen(sourceDir) {
return fs.readdirSync(sourceDir).filter((name) => !META_FILES.includes(name));
}
function copyInto(sourceDir, targetDir) { function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of kopierNamen(sourceDir)) {
if (META_FILES.includes(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
@@ -193,4 +206,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, resolveInstancesDir }; module.exports = { scan, importOne, kopierQuellen, resolveInstancesDir };
+4 -2
View File
@@ -73,7 +73,7 @@
:root:is([data-design="aero"],[data-design="lila"]) .tbtn:active{background:var(--surface2)} :root:is([data-design="aero"],[data-design="lila"]) .tbtn:active{background:var(--surface2)}
:root:is([data-design="aero"],[data-design="lila"]) .tbtn svg, :root:is([data-design="aero"],[data-design="lila"]) .tbtn svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-new svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-import svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-open-dir svg, :root:is([data-design="aero"],[data-design="lila"]) #btn-new svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-import svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-open-dir svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-console svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-account svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-settings svg, :root:is([data-design="aero"],[data-design="lila"]) #btn-search svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-console svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-account svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-settings svg,
:root:is([data-design="aero"],[data-design="lila"]) .acct-av svg{color:var(--cyan-soft)} :root:is([data-design="aero"],[data-design="lila"]) .acct-av svg{color:var(--cyan-soft)}
:root:is([data-design="aero"],[data-design="lila"]) .tbsep{background:var(--border)} :root:is([data-design="aero"],[data-design="lila"]) .tbsep{background:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px;padding:5px 12px;width:150px} :root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px;padding:5px 12px;width:150px}
@@ -270,7 +270,7 @@
:root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto} :root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto}
:root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff} :root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff}
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)} :root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
:root[data-design="minecraft"] .tbtn svg,:root[data-design="minecraft"] #btn-console svg,:root[data-design="minecraft"] .acct-av svg{color:#e6e6e6} :root[data-design="minecraft"] #btn-search svg,:root[data-design="minecraft"] .tbtn svg,:root[data-design="minecraft"] #btn-console svg,:root[data-design="minecraft"] .acct-av svg{color:#e6e6e6}
:root[data-design="minecraft"] #btn-new svg,:root[data-design="minecraft"] #btn-account svg{color:#7fd858} :root[data-design="minecraft"] #btn-new svg,:root[data-design="minecraft"] #btn-account svg{color:#7fd858}
:root[data-design="minecraft"] #btn-import svg{color:#6db8ff} :root[data-design="minecraft"] #btn-import svg{color:#6db8ff}
:root[data-design="minecraft"] #btn-open-dir svg{color:#f2c94c} :root[data-design="minecraft"] #btn-open-dir svg{color:#f2c94c}
@@ -308,6 +308,8 @@
:root[data-design="minecraft"] .list{image-rendering:auto} :root[data-design="minecraft"] .list{image-rendering:auto}
:root[data-design="minecraft"] .group-header{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten);padding:8px 4px 4px} :root[data-design="minecraft"] .group-header{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten);padding:8px 4px 4px}
:root[data-design="minecraft"] .group-count{color:#b8b9bc} :root[data-design="minecraft"] .group-count{color:#b8b9bc}
:root[data-design="minecraft"] .group-sub > .group-header{font-size:14px}
:root[data-design="minecraft"] .group-sub{border-left:2px solid rgba(0,0,0,.45)}
:root[data-design="minecraft"] .group-header .chev{color:#fff;font-family:"Segoe UI",sans-serif;font-size:10px} :root[data-design="minecraft"] .group-header .chev{color:#fff;font-family:"Segoe UI",sans-serif;font-size:10px}
:root[data-design="minecraft"] .group-header::after{height:2px;background:rgba(0,0,0,.55);box-shadow:0 1px 0 rgba(255,255,255,.08)} :root[data-design="minecraft"] .group-header::after{height:2px;background:rgba(0,0,0,.55);box-shadow:0 1px 0 rgba(255,255,255,.08)}
:root[data-design="minecraft"] .card{border-radius:0} :root[data-design="minecraft"] .card{border-radius:0}
+85
View File
@@ -0,0 +1,85 @@
/*
* "Fenster wachsen aus dem Knopf": Ein Popup-Fenster (Optionen, Mods, Skin,
* Freunde …) erscheint nicht schlagartig, sondern wächst aus dem angeklickten
* Knopf heraus. Dafür wird das noch unsichtbare Fenster abfotografiert und das
* Bild in einem durchsichtigen Hilfsfenster vom Knopf aus groß gezogen; am Ende
* übernimmt das echte Fenster an exakt derselben Stelle.
*
* Das Hilfsfenster entsteht jedes Mal neu und gleich in der richtigen Größe:
* Unter Windows färbt sich ein durchsichtiges Fenster schwarz, sobald es
* nachträglich vergrößert wird, und ein verstecktes Fenster zeichnet nicht mehr.
* Abschaltbar unter Optionen → Darstellung → Animationen („Bewegte Oberfläche“).
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const DAUER_MS = 280;
const HOECHSTENS_MS = 2000; // spätestens dann ist das Fenster in jedem Fall zu sehen
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
function vereinigung(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return {
x, y,
width: Math.max(a.x + a.width, b.x + b.width) - x,
height: Math.max(a.y + a.height, b.y + b.height) - y,
};
}
// Rechteck in Koordinaten des Hilfsfensters umrechnen
const relativ = (r, box) => ({ x: r.x - box.x, y: r.y - box.y, width: r.width, height: r.height });
async function abspielen(ziel, von, merke) {
// unsichtbar zeigen, damit Chromium den Inhalt zeichnet, dann abfotografieren
ziel.setOpacity(0);
ziel.setIgnoreMouseEvents(true);
ziel.show();
await warte(90);
if (ziel.isDestroyed()) return;
const bild = await ziel.webContents.capturePage();
if (bild.isEmpty() || ziel.isDestroyed()) return;
const nach = ziel.getBounds();
const box = vereinigung(von, nach);
const hilfe = new BrowserWindow({
x: box.x, y: box.y, width: box.width, height: box.height,
show: false, frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false, focusable: false,
skipTaskbar: true, hasShadow: false, alwaysOnTop: true, backgroundColor: '#00000000',
title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
merke(hilfe);
hilfe.setIgnoreMouseEvents(true);
await hilfe.loadFile(path.join(__dirname, 'aufbau.html'));
const ok = await hilfe.webContents.executeJavaScript(
`vorbereiten(${JSON.stringify(bild.toDataURL())}, ${JSON.stringify(relativ(nach, box))}, ${JSON.stringify(relativ(von, box))})`);
if (!ok || hilfe.isDestroyed()) return;
hilfe.showInactive();
await hilfe.webContents.executeJavaScript(`abspielen(${DAUER_MS})`);
}
/**
* Zeigt `ziel` (geladen, aber noch nicht sichtbar) mit Wachs-Animation an.
* `von` ist das Rechteck des angeklickten Knopfs in Bildschirmkoordinaten.
*/
async function wachsen(ziel, von) {
let hilfe = null;
try {
await Promise.race([abspielen(ziel, von, (h) => { hilfe = h; }), warte(HOECHSTENS_MS)]);
} catch {
// Animation ist nur Zierde – im Fehlerfall das Fenster einfach zeigen
} finally {
if (!ziel.isDestroyed()) {
ziel.setOpacity(1);
ziel.setIgnoreMouseEvents(false);
if (!ziel.isVisible()) ziel.show();
ziel.focus();
}
// Hilfsfenster erst nach dem Umschalten schließen, sonst blitzt der Hintergrund durch
setTimeout(() => { if (hilfe && !hilfe.isDestroyed()) hilfe.destroy(); }, 60);
}
}
module.exports = { wachsen };
+25
View File
@@ -200,7 +200,32 @@ async function sendReport(daten) {
} }
} }
// ---------- AeroMC-Umhänge ----------
// Hochladen/Entfernen nur angemeldet; Abrufen ist öffentlich (auch für den Mod)
function uploadCape(uuid, image) {
return anfrage('/cape', { method: 'POST', body: { uuid, image } });
}
function removeCape(uuid) {
return anfrage('/cape/remove', { method: 'POST', body: { uuid } });
}
async function getCapes(uuids) {
try {
const liste = [...new Set((uuids || []).map((u) => String(u).replace(/-/g, '').toLowerCase()))].filter(Boolean);
if (!liste.length) return { ok: true, capes: {} };
const { res, daten } = await roheAnfrage('/capes', { method: 'GET', body: { uuids: liste.join(',') } });
// ältere Plugin-Fassung ohne Umhänge
if (res.status === 404) return { ok: false, fehlt: true, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
return { ok: true, capes: (daten && daten.capes) || {} };
} catch (err) {
return { ok: false, message: 'Umhang-Server nicht erreichbar: ' + err.message };
}
}
module.exports = { module.exports = {
BASIS_URL, uploadCape, removeCape, getCapes,
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend, heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer, sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer,
sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport, sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport,
+89 -5
View File
@@ -27,16 +27,18 @@
<button id="btn-stats" class="tbtn" data-ic="chart" title="Spielzeit-Statistik">Statistik</button> <button id="btn-stats" class="tbtn" data-ic="chart" title="Spielzeit-Statistik">Statistik</button>
<button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button> <button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button>
<span class="tbgrow"></span> <span class="tbgrow"></span>
<div class="search-wrap"> <div id="search-wrap" class="search-wrap">
<input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" /> <button id="btn-search" class="tbtn tb-lupe" data-ic="search" title="Instanzen suchen (Strg+F)"></button>
<input id="search" type="text" placeholder="Suchen… (Esc schließt)" autocomplete="off" title="Instanzen nach Name, Version oder Gruppe filtern" />
</div> </div>
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button> <button id="btn-account" class="tbtn acct-btn" title="Konto wechseln, hinzufügen oder verwalten"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span><span class="acct-pfeil">▾</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button> <button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
</header> </header>
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste --> <!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
<main class="workspace"> <main class="workspace">
<div class="list-area"> <div class="list-area">
<canvas id="skin-live" class="skin-live hidden"></canvas>
<div id="grid" class="list"></div> <div id="grid" class="list"></div>
<section id="empty" class="empty hidden"> <section id="empty" class="empty hidden">
<h2>Noch keine Instanzen</h2> <h2>Noch keine Instanzen</h2>
@@ -105,6 +107,7 @@
<span>Gruppe <small class="opt">(optional)</small></span> <span>Gruppe <small class="opt">(optional)</small></span>
<input id="f-group" type="text" list="group-list" placeholder="z. B. Modpacks, Server, Tests" autocomplete="off" /> <input id="f-group" type="text" list="group-list" placeholder="z. B. Modpacks, Server, Tests" autocomplete="off" />
<datalist id="group-list"></datalist> <datalist id="group-list"></datalist>
<small class="hint">Untergruppe: mit „/“ trennen, z. B. „Modpacks/Survival“.</small>
</label> </label>
<div class="field-row"> <div class="field-row">
@@ -361,6 +364,17 @@
</label> </label>
</section> </section>
<section class="settings-card">
<h5>Animationen</h5>
<label class="checkbox-field">
<input id="s-start-animation" type="checkbox" /> <span>Startanimation beim Öffnen des Launchers</span>
</label>
<label class="checkbox-field">
<input id="s-ui-animationen" type="checkbox" /> <span>Bewegte Oberfläche</span>
</label>
<small class="hint">Startanimation: Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (ab dem nächsten Start, nicht im Infobereich). Bewegte Oberfläche: Die Skin-Figur atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden ein, Designs blenden weich über.</small>
</section>
<section class="settings-card settings-card-wide"> <section class="settings-card settings-card-wide">
<h5>Skin-Hintergrund</h5> <h5>Skin-Hintergrund</h5>
<label class="field"> <label class="field">
@@ -445,6 +459,14 @@
</label> </label>
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small> <small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
</section> </section>
<section class="settings-card">
<h5>Umhänge</h5>
<label class="checkbox-field">
<input id="s-aeromc-capes" type="checkbox" /> <span>AeroMC-Umhänge im Spiel anzeigen</span>
</label>
<small class="hint">Zeigt deinen eigenen Umhang und die anderer AeroMC-Spieler – in jeder Minecraft-Version und mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small>
</section>
</div> </div>
</div> </div>
@@ -1249,6 +1271,26 @@
</select> </select>
</label> </label>
<p id="skin-status" class="hint"></p> <p id="skin-status" class="hint"></p>
<div class="skin-umhang">
<div class="skin-label">Umhang</div>
<div class="field-row">
<label class="field">
<span>Offizieller Umhang <small class="opt">(im Spiel für alle sichtbar)</small></span>
<select id="skin-cape-offiziell" title="Nur Umhänge, die dein Konto bei Mojang besitzt">
<option value="">Kein Umhang</option>
</select>
</label>
<div class="field">
<span>AeroMC-Umhang <small class="opt">(eigener, auch HD)</small></span>
<div class="skin-umhang-aero">
<span id="skin-cape-aero" class="hint">–</span>
<button id="skin-cape-edit" class="btn btn-mini" title="Im Skin-Editor einen eigenen Umhang gestalten">Gestalten…</button>
<button id="skin-cape-remove" class="btn btn-mini btn-danger hidden">Entfernen</button>
</div>
</div>
</div>
<small id="skin-cape-status" class="hint">Den AeroMC-Umhang sehen im Spiel alle, die AeroMC nutzen oder den Mod „AeroMC Umhänge“ (Fabric/Quilt) installiert haben – dort hat er Vorrang vor dem offiziellen.</small>
</div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button> <button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
@@ -1268,6 +1310,7 @@
<div class="se-modus"> <div class="se-modus">
<button class="se-modus-btn on" data-se-modus="2d" title="Auf der aufgeklappten Vorlage malen">2D-Vorlage</button> <button class="se-modus-btn on" data-se-modus="2d" title="Auf der aufgeklappten Vorlage malen">2D-Vorlage</button>
<button class="se-modus-btn" data-se-modus="3d" title="Direkt auf der drehbaren Figur malen">3D-Figur</button> <button class="se-modus-btn" data-se-modus="3d" title="Direkt auf der drehbaren Figur malen">3D-Figur</button>
<button class="se-modus-btn" data-se-modus="umhang" title="Eigenen Umhang (auch HD) gestalten">Umhang</button>
</div> </div>
<button class="modal-close" data-close>✕</button> <button class="modal-close" data-close>✕</button>
</div> </div>
@@ -1290,7 +1333,7 @@
</div> </div>
<div id="se-zuletzt" class="se-zuletzt"></div> <div id="se-zuletzt" class="se-zuletzt"></div>
</div> </div>
<div class="se-gruppe"> <div id="se-gruppe-ebene" class="se-gruppe">
<div class="se-titel">Ebene</div> <div class="se-titel">Ebene</div>
<div class="se-ebenen"> <div class="se-ebenen">
<button class="se-ebene on" data-se-schicht="0" title="Die eigentliche Figur (L wechselt)">Grundschicht</button> <button class="se-ebene on" data-se-schicht="0" title="Die eigentliche Figur (L wechselt)">Grundschicht</button>
@@ -1300,6 +1343,30 @@
<label class="se-check"><input id="se-durch" type="checkbox" checked /> Grundschicht durchscheinen</label> <label class="se-check"><input id="se-durch" type="checkbox" checked /> Grundschicht durchscheinen</label>
<button id="se-leeren" class="btn btn-mini">2. Schicht leeren</button> <button id="se-leeren" class="btn btn-mini">2. Schicht leeren</button>
</div> </div>
<div id="se-gruppe-umhang" class="se-gruppe hidden">
<div class="se-titel">Umhang</div>
<label class="se-feld"><span>Größe</span>
<select id="se-u-groesse" title="Größere Umhänge (HD) erlauben feinere Motive">
<option value="64">Normal (64×32)</option>
<option value="128">HD 128×64</option>
<option value="256">HD 256×128</option>
<option value="512">HD 512×256</option>
<option value="1024">HD 1024×512</option>
</select>
</label>
<label class="se-feld"><span>Pinselgröße</span>
<select id="se-u-pinsel">
<option value="1">1 Pixel</option>
<option value="2">2 Pixel</option>
<option value="4">4 Pixel</option>
<option value="8">8 Pixel</option>
<option value="16">16 Pixel</option>
</select>
</label>
<button id="se-u-bild" class="btn btn-mini" title="Ein beliebiges Bild (PNG, JPG …) auf die Außenseite setzen">Bild einsetzen…</button>
<label class="se-check" title="Dieselbe Datei trägt auch die Elytra"><input id="se-u-elytra" type="checkbox" /> Als Elytra zeigen</label>
<button id="se-u-einpassen" class="btn btn-mini" title="Zoom zurücksetzen (Taste 0)">Ansicht einpassen</button>
</div>
<div class="se-gruppe"> <div class="se-gruppe">
<div class="se-titel">Optionen</div> <div class="se-titel">Optionen</div>
<label class="se-check" title="Malt gleichzeitig auf der anderen Körperseite (M)"><input id="se-spiegeln" type="checkbox" /> Spiegeln (links ↔ rechts)</label> <label class="se-check" title="Malt gleichzeitig auf der anderen Körperseite (M)"><input id="se-spiegeln" type="checkbox" /> Spiegeln (links ↔ rechts)</label>
@@ -1319,6 +1386,10 @@
<div id="se-buehne" class="se-buehne"> <div id="se-buehne" class="se-buehne">
<div id="se-2d" class="se-ansicht se-2d"><canvas id="se-2d-canvas"></canvas></div> <div id="se-2d" class="se-ansicht se-2d"><canvas id="se-2d-canvas"></canvas></div>
</div> </div>
<!-- gerade nicht gezeigte Ansichten (je nach Modus) -->
<div id="se-ablage" class="se-ablage">
<div id="se-u2d" class="se-ansicht se-u2d"><canvas id="se-u2d-canvas"></canvas></div>
</div>
<div id="se-info" class="se-info hint"></div> <div id="se-info" class="se-info hint"></div>
</section> </section>
<aside class="se-seite"> <aside class="se-seite">
@@ -1326,7 +1397,7 @@
<div id="se-mini" class="se-mini"> <div id="se-mini" class="se-mini">
<div id="se-3d" class="se-ansicht se-3d"><canvas id="se-3d-canvas"></canvas><span id="se-3d-leer" class="hint se-3d-leer">wird geladen …</span></div> <div id="se-3d" class="se-ansicht se-3d"><canvas id="se-3d-canvas"></canvas><span id="se-3d-leer" class="hint se-3d-leer">wird geladen …</span></div>
</div> </div>
<p class="hint se-tipp">Beide Ansichten sind bemalbar. Die <b>2. Schicht</b> liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.</p> <p id="se-tipp" class="hint se-tipp">Beide Ansichten sind bemalbar. Die <b>2. Schicht</b> liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.</p>
</aside> </aside>
</div> </div>
<div class="modal-foot se-fuss"> <div class="modal-foot se-fuss">
@@ -1490,6 +1561,16 @@
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button> <button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
</div> </div>
<div id="import-lauf" class="import-lauf hidden">
<div class="import-lauf-kopf">
<b id="import-lauf-titel">Wird vorbereitet …</b>
<button id="import-lauf-abbrechen" class="btn btn-mini btn-danger" title="Bricht den Import ab – die gerade halb kopierte Instanz wird wieder entfernt, fertig übernommene bleiben">Abbrechen</button>
</div>
<div class="import-balken"><i id="import-lauf-balken"></i></div>
<div id="import-lauf-zeile" class="import-lauf-zeile"></div>
<div id="import-lauf-datei" class="hint import-lauf-datei"></div>
</div>
<div id="import-pane-zip" class="import-pane"> <div id="import-pane-zip" class="import-pane">
<p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p> <p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p>
<p class="hint">Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.</p> <p class="hint">Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.</p>
@@ -1770,6 +1851,9 @@
</div> </div>
</div> </div>
<!-- Konto-Menü (Klick auf den Kontoknopf oben rechts) -->
<div id="konto-menu" class="ctx konto-menu hidden" role="menu"></div>
<div id="ctx-menu" class="ctx hidden"> <div id="ctx-menu" class="ctx hidden">
<button class="ctx-item" data-act="p-edit">Symbol ändern</button> <button class="ctx-item" data-act="p-edit">Symbol ändern</button>
<button class="ctx-item" data-act="p-edit">Umbenennen</button> <button class="ctx-item" data-act="p-edit">Umbenennen</button>
+182
View File
@@ -0,0 +1,182 @@
/*
* Große Ordner kopieren, ohne den Launcher einzufrieren.
*
* Früher liefen Importe mit fs.cpSync – synchron im Hauptprozess. Bei
* Spielständen mit vielen Gigabyte stand dann der komplette Launcher
* minutenlang still (keine Anzeige, kein Abbrechen, „Keine Rückmeldung“).
*
* Hier wird asynchron gearbeitet: erst Dateien und Größe zählen, dann mit
* mehreren Dateien gleichzeitig kopieren. Große Dateien gehen stückweise, damit
* der Fortschritt auch bei einer einzelnen 4-GB-Datei weiterläuft. Gesperrte
* Einzeldateien werden übersprungen und gemeldet statt alles abzubrechen.
*/
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { pipeline } = require('stream/promises');
const GLEICHZEITIG = 8; // Dateien parallel
const STUECKWEISE_AB = 64 * 1024 * 1024; // ab hier mit Zwischenständen kopieren
const MAX_TIEFE = 64; // Schutz vor Verknüpfungs-Schleifen
const MELDE_ALLE_MS = 200;
class Abgebrochen extends Error {
constructor() { super('Vom Nutzer abgebrochen.'); this.abgebrochen = true; }
}
// Ordner-/Dateieintrag auflösen (Verknüpfungen werden wie ihr Ziel behandelt –
// die Modrinth App verlinkt z. B. Dateien aus ihrem gemeinsamen Speicher)
async function art(voll, eintrag) {
if (!eintrag.isSymbolicLink()) return { ordner: eintrag.isDirectory(), datei: eintrag.isFile() };
const st = await fsp.stat(voll).catch(() => null);
return st ? { ordner: st.isDirectory(), datei: st.isFile() } : { ordner: false, datei: false };
}
/** Zählt Dateien und Bytes unter allen Quellen (Dateien oder Ordner). */
async function zaehle(quellen, job, melde) {
const summe = { dateien: 0, bytes: 0 };
let zuletzt = 0;
async function lauf(voll, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(voll).catch(() => null);
if (!st) return;
if (st.isFile()) { summe.dateien++; summe.bytes += st.size; }
else if (st.isDirectory() && tiefe < MAX_TIEFE) {
const eintraege = await fsp.readdir(voll, { withFileTypes: true }).catch(() => []);
for (const e of eintraege) {
const p = path.join(voll, e.name);
const a = await art(p, e);
if (a.ordner) await lauf(p, tiefe + 1);
else if (a.datei) {
const s = await fsp.stat(p).catch(() => null);
if (s) { summe.dateien++; summe.bytes += s.size; }
}
}
}
if (melde && Date.now() - zuletzt > MELDE_ALLE_MS) { zuletzt = Date.now(); melde({ ...summe }); }
}
for (const q of quellen) await lauf(q, 0);
if (melde) melde({ ...summe });
return summe;
}
/** Freier Platz (Bytes) auf dem Laufwerk von `ordner`, null wenn unbekannt. */
async function freierPlatz(ordner) {
try {
let pruefe = ordner;
while (!fs.existsSync(pruefe) && path.dirname(pruefe) !== pruefe) pruefe = path.dirname(pruefe);
const s = await fsp.statfs(pruefe);
return s.bavail * s.bsize;
} catch { return null; }
}
/**
* Fortschritt über mehrere Kopiervorgänge hinweg (z. B. alle Instanzen eines
* Imports). `melde` bekommt höchstens alle 200 ms einen Zwischenstand.
*/
function neuerFortschritt(gesamt, melde) {
const f = {
gesamtBytes: gesamt.bytes || 0, gesamtDateien: gesamt.dateien || 0,
bytes: 0, dateien: 0, datei: '', uebersprungen: [],
beginn: Date.now(), zuletzt: 0, verlauf: [],
};
f.melden = (sofort) => {
const jetzt = Date.now();
if (!melde || (!sofort && jetzt - f.zuletzt < MELDE_ALLE_MS)) return;
f.zuletzt = jetzt;
// Tempo über die letzten ~5 Sekunden glätten
f.verlauf.push([jetzt, f.bytes]);
while (f.verlauf.length > 2 && jetzt - f.verlauf[0][0] > 5000) f.verlauf.shift();
const [t0, b0] = f.verlauf[0];
const tempo = jetzt > t0 ? ((f.bytes - b0) * 1000) / (jetzt - t0) : 0;
// erst nach 1,5 s schätzen – davor gibt es noch kein brauchbares Tempo
const eingeschwungen = jetzt - f.beginn > 1500;
const rest = eingeschwungen && tempo > 0 && f.gesamtBytes > f.bytes ? Math.round((f.gesamtBytes - f.bytes) / tempo) : null;
melde({
bytes: f.bytes, gesamtBytes: f.gesamtBytes, dateien: f.dateien, gesamtDateien: f.gesamtDateien,
datei: f.datei, tempo: eingeschwungen ? Math.round(tempo) : 0, restSekunden: rest, uebersprungen: f.uebersprungen.length,
});
};
return f;
}
async function kopiereDatei(von, nach, groesse, fortschritt, job) {
if (groesse >= STUECKWEISE_AB) {
// große Datei: stückweise, damit der Balken weiterläuft
const lesen = fs.createReadStream(von, { highWaterMark: 4 * 1024 * 1024 });
lesen.on('data', (stueck) => {
if (job && job.abgebrochen) { lesen.destroy(new Abgebrochen()); return; }
if (fortschritt) { fortschritt.bytes += stueck.length; fortschritt.melden(); }
});
await pipeline(lesen, fs.createWriteStream(nach));
} else {
await fsp.copyFile(von, nach);
if (fortschritt) fortschritt.bytes += groesse;
}
}
/**
* Kopiert `von` (Datei oder Ordner) nach `nach`. Läuft asynchron, meldet über
* `fortschritt` (siehe neuerFortschritt) und bricht ab, sobald job.abgebrochen.
* Gesperrte Einzeldateien landen in fortschritt.uebersprungen; volle Platte
* (ENOSPC) und Abbruch beenden den ganzen Vorgang mit Fehler.
*/
async function kopiere(von, nach, fortschritt, job) {
const laufend = new Set();
let schwererFehler = null;
async function platz(aufgabe) {
while (laufend.size >= GLEICHZEITIG) await Promise.race(laufend);
if (schwererFehler) throw schwererFehler;
const p = aufgabe().catch((err) => {
if (err && (err.abgebrochen || err.code === 'ENOSPC')) schwererFehler = schwererFehler || err;
else if (fortschritt) fortschritt.uebersprungen.push({ datei: err && err.path ? err.path : '?', grund: err && err.code ? err.code : String(err && err.message) });
}).finally(() => laufend.delete(p));
laufend.add(p);
}
const basis = von;
async function datei(q, z) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(q);
if (fortschritt) fortschritt.datei = path.relative(path.dirname(basis), q);
await kopiereDatei(q, z, st.size, fortschritt, job);
if (fortschritt) { fortschritt.dateien++; fortschritt.melden(); }
}
async function ordner(q, z, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
if (schwererFehler) throw schwererFehler;
await fsp.mkdir(z, { recursive: true });
if (tiefe >= MAX_TIEFE) return;
const eintraege = await fsp.readdir(q, { withFileTypes: true });
for (const e of eintraege) {
const qq = path.join(q, e.name);
const zz = path.join(z, e.name);
const a = await art(qq, e);
if (a.ordner) await ordner(qq, zz, tiefe + 1);
else if (a.datei) await platz(() => datei(qq, zz));
}
}
let fehler = null;
try {
const st = await fsp.stat(von);
if (st.isDirectory()) await ordner(von, nach, 0);
else {
await fsp.mkdir(path.dirname(nach), { recursive: true });
await platz(() => datei(von, nach));
}
} catch (err) {
fehler = err;
}
// erst zurückkehren, wenn keine Datei mehr geschrieben wird – sonst würde
// das Aufräumen nach einem Abbruch mit noch laufenden Kopien kollidieren
while (laufend.size) await Promise.allSettled([...laufend]);
if (fehler) throw fehler;
if (schwererFehler) throw schwererFehler;
if (job && job.abgebrochen) throw new Abgebrochen();
if (fortschritt) fortschritt.melden(true);
}
module.exports = { zaehle, kopiere, freierPlatz, neuerFortschritt, Abgebrochen };
+630 -627
View File
File diff suppressed because it is too large Load Diff
+350 -88
View File
@@ -18,6 +18,8 @@ let ffmpegPfad = null;
try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ } try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
const pkg = require('../package.json'); const pkg = require('../package.json');
const store = require('./store'); const store = require('./store');
const startanimation = require('./startanimation');
const fensteraufbau = require('./fensteraufbau');
const services = require('./services'); const services = require('./services');
const launcher = require('./launcher'); const launcher = require('./launcher');
const loaders = require('./loaders'); const loaders = require('./loaders');
@@ -28,6 +30,7 @@ const icons = require('./icons');
const bedrock = require('./bedrock'); const bedrock = require('./bedrock');
const backup = require('./backup'); const backup = require('./backup');
const mmcimport = require('./mmcimport'); const mmcimport = require('./mmcimport');
const kopieren = require('./kopieren');
const cfimport = require('./cfimport'); const cfimport = require('./cfimport');
const mrimport = require('./mrimport'); const mrimport = require('./mrimport');
const mcimport = require('./mcimport'); const mcimport = require('./mcimport');
@@ -154,14 +157,18 @@ function broadcast(channel, payload) {
} }
} }
function openPopout(modalId, params) { // von: Rechteck des angeklickten Knopfs (Bildschirmkoordinaten) – dann wächst
// das Fenster daraus hervor (Optionen → Darstellung → Animationen)
function openPopout(modalId, params, von) {
const wachsen = !!von && store.getSettings().uiAnimationen !== false;
let win = popoutWindows.get(modalId); let win = popoutWindows.get(modalId);
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.show();
win.focus();
// erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu // erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
// laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren // laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
win.webContents.send('popout:reopen', params || {}); win.webContents.send('popout:reopen', params || {});
if (wachsen && !win.isVisible()) { fensteraufbau.wachsen(win, von); return; }
win.show();
win.focus();
return; return;
} }
const query = { popout: modalId }; const query = { popout: modalId };
@@ -189,7 +196,7 @@ function openPopout(modalId, params) {
}); });
popoutWindows.set(modalId, win); popoutWindows.set(modalId, win);
win.loadFile(path.join(__dirname, 'index.html'), { query }); win.loadFile(path.join(__dirname, 'index.html'), { query });
win.once('ready-to-show', () => win.show()); win.once('ready-to-show', () => { if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
win.webContents.on('did-fail-load', (_e, code, desc) => { win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc); console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
}); });
@@ -212,7 +219,18 @@ let recordingGuardActive = false;
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; }); ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg)); ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params)); ipcMain.on('popout:open', (e, modalId, params, knopf) => {
// Herkunft fürs Aufwachsen: angeklickter Knopf, sonst die Mitte des Fensters
let von = null;
const quelle = BrowserWindow.fromWebContents(e.sender);
if (quelle && !quelle.isDestroyed() && quelle.isVisible()) {
const cb = quelle.getContentBounds();
von = knopf && Number.isFinite(knopf.x) && Number.isFinite(knopf.y)
? { x: Math.round(cb.x + knopf.x), y: Math.round(cb.y + knopf.y), width: Math.max(8, Math.round(knopf.width)), height: Math.max(8, Math.round(knopf.height)) }
: { x: Math.round(cb.x + cb.width / 2 - 20), y: Math.round(cb.y + cb.height / 2 - 20), width: 40, height: 40 };
}
openPopout(modalId, params, von);
});
// Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster, // Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster,
// die Popup-Fenster haben ihren eigenen Kopf) // die Popup-Fenster haben ihren eigenen Kopf)
ipcMain.on('window:titleBar', (e, design) => { ipcMain.on('window:titleBar', (e, design) => {
@@ -752,7 +770,7 @@ function windowIcon() {
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
} }
function createWindow(startHidden) { function createWindow(startHidden, mitStartanimation) {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 820, height: 820,
@@ -773,7 +791,7 @@ function createWindow(startHidden) {
}, },
}); });
mainWindow.loadFile(path.join(__dirname, 'index.html')); mainWindow.loadFile(path.join(__dirname, 'index.html'), mitStartanimation ? { query: { startanimation: '1' } } : undefined);
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im // Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der // Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
@@ -939,8 +957,13 @@ app.whenReady().then(() => {
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B. // Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update) // von Hand an der Registry gedreht wurde oder nach einem Update)
applyAutostart(autostartAn); applyAutostart(autostartAn);
// Reste von im Hintergrund gelöschten Instanzen wegräumen (siehe store.entsorgeOrdner)
store.raeumePapierkorbAuf();
const startHidden = autostartAn && process.argv.includes('--hidden'); const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden); // Startanimation: Fenster erst verdeckt anlegen, die Animation zeigt es dann
const mitAnimation = !startHidden && store.getSettings().startAnimation !== false;
createWindow(startHidden || mitAnimation, mitAnimation);
if (mitAnimation) startanimation.spielen(mainWindow, hauptfensterBereit, store.getSettings().theme);
// Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog: // Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog:
// das vom Renderer per recording:setCaptureTarget gewählte Fenster wird // das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
@@ -1788,10 +1811,25 @@ ipcMain.handle('skin:modelFor', async (_e, uuid) => {
} }
}); });
// Zeitpunkt der letzten eigenen Skin-Änderung: danach hinkt der Sitzungsserver
// (= was im Spiel zu sehen ist) noch kurz hinterher
let letzterSkinWechsel = 0;
const SKIN_NACHLAUF_MS = 3 * 60 * 1000;
// Kontoprofil + der Skin, den andere im Spiel sehen (siehe skins.waehleAnzeigeSkin)
async function profilMitSpielSkin(token) {
const p = await skins.holeProfil(token);
if (!p.ok) return p;
const kuerzlich = Date.now() - letzterSkinWechsel < SKIN_NACHLAUF_MS;
let spiel = null;
if (!kuerzlich) { try { spiel = await skins.holeSpielTexturen(p.id); } catch { /* dann eben das Kontoprofil */ } }
return skins.waehleAnzeigeSkin(p, spiel, kuerzlich);
}
ipcMain.handle('skin:profile', async () => { ipcMain.handle('skin:profile', async () => {
const token = await aktivesToken(); const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.holeProfil(token); } try { return await profilMitSpielSkin(token); }
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; } catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
}); });
@@ -1819,7 +1857,7 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const res = await skins.setzeSkin(token, dateiPfad, variante); const res = await skins.setzeSkin(token, dateiPfad, variante);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); } if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
return res; return res;
} }
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; } catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
@@ -1830,7 +1868,7 @@ ipcMain.handle('skin:reset', async () => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const res = await skins.setzeSkinZurueck(token); const res = await skins.setzeSkinZurueck(token);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); } if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
return res; return res;
} }
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
@@ -1852,7 +1890,7 @@ ipcMain.handle('skin:currentData', async () => {
const token = await aktivesToken(); const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const p = await skins.holeProfil(token); const p = await profilMitSpielSkin(token);
if (!p.ok) return p; if (!p.ok) return p;
if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' }; if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' };
const res = await fetch(p.skinUrl); const res = await fetch(p.skinUrl);
@@ -1864,6 +1902,176 @@ ipcMain.handle('skin:currentData', async () => {
} }
}); });
// ---------- Umhänge (offiziell von Mojang + eigene AeroMC-Umhänge) ----------
// Bilder immer als data:-Adresse in die Oberfläche (fremde Server schicken
// keine CORS-Kopfzeilen – die 3D-Ansicht dürfte sie sonst nicht laden)
async function bildAlsDataUri(url) {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const daten = Buffer.from(await res.arrayBuffer());
if (daten.length > 6 * 1024 * 1024) throw new Error('Bild zu groß');
return 'data:image/png;base64,' + daten.toString('base64');
}
// PNG mit Seitenverhältnis 2:1 (64×32 … 1024×512) – wie der Server es verlangt
function umhangPngAusBase64(base64) {
const daten = Buffer.from(String(base64 || ''), 'base64');
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
const breite = daten.readUInt32BE(16);
const hoehe = daten.readUInt32BE(20);
if (![64, 128, 256, 512, 1024].includes(breite) || breite !== 2 * hoehe) return null;
return daten;
}
// Umhang eines beliebigen Spielers fürs Anzeigen: eigener AeroMC-Umhang hat
// Vorrang, sonst der offizielle aus Mojangs Profil. 5 Minuten gemerkt.
const umhangCache = new Map(); // uuid -> { dataUri, quelle, zeit }
async function umhangFuer(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const gemerkt = umhangCache.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 5 * 60 * 1000) return gemerkt;
let ergebnis = { dataUri: null, quelle: null, zeit: Date.now() };
try {
const aero = await friends.getCapes([id]);
const eintrag = aero.ok && aero.capes && aero.capes[id];
if (eintrag && eintrag.url) ergebnis = { dataUri: await bildAlsDataUri(eintrag.url), quelle: 'aeromc', zeit: Date.now() };
} catch { /* weiter mit Mojang */ }
if (!ergebnis.dataUri) {
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (res.ok) {
const profil = await res.json();
const eig = (profil.properties || []).find((p) => p.name === 'textures');
const tex = eig ? JSON.parse(Buffer.from(eig.value, 'base64').toString('utf8')) : {};
const cape = tex.textures && tex.textures.CAPE;
if (cape && cape.url) ergebnis = { dataUri: await bildAlsDataUri(skins.aufHttps(cape.url)), quelle: 'mojang', zeit: Date.now() };
}
} catch { /* kein Umhang */ }
}
umhangCache.set(id, ergebnis);
return ergebnis;
}
ipcMain.handle('cape:forUuid', async (_e, uuid) => {
const r = await umhangFuer(uuid);
return r && r.dataUri ? { dataUri: r.dataUri, quelle: r.quelle } : null;
});
// Übersicht fürs Skin-Fenster: offizielle Umhänge + eigener AeroMC-Umhang
ipcMain.handle('cape:info', async () => {
const konto = aktivesFreundeKonto();
const token = await aktivesToken();
if (!konto || !token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const ergebnis = { ok: true, name: konto.username, offiziell: [], aeromc: null, aeromcFehler: '' };
try {
const p = await skins.holeProfil(token);
if (p.ok) ergebnis.offiziell = p.capes || [];
} catch { /* ohne offizielle Liste weiter */ }
const aero = await friends.getCapes([konto.uuid]);
if (aero.ok) ergebnis.aeromc = aero.capes[String(konto.uuid).replace(/-/g, '').toLowerCase()] || null;
else { ergebnis.aeromcFehler = aero.message; ergebnis.aeromcFehlt = !!aero.fehlt; }
return ergebnis;
});
ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
const res = await skins.setzeUmhang(token, capeId || null);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res;
} catch (err) { return { ok: false, message: 'Umhang konnte nicht gesetzt werden: ' + err.message }; }
});
// Der Umhang-Agent muss als echte Datei vorliegen (Java liest nicht aus app.asar):
// einmal nach userData kopieren, Dateiname mit Größe, damit ein Update ihn ersetzt.
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
function umhangAgent() {
try {
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
const groesse = fs.statSync(quelle).size;
const ordner = path.join(app.getPath('userData'), 'agent');
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(ziel, fs.readFileSync(quelle));
for (const alt of fs.readdirSync(ordner)) {
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
}
}
return `${ziel}=${friends.BASIS_URL}`;
} catch (err) {
console.error('Umhang-Agent nicht verfügbar:', err.message);
return '';
}
}
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
function umhangFehler(res) {
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
return res;
}
ipcMain.handle('cape:upload', async (_e, base64) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' };
const res = await friends.uploadCape(konto.uuid, base64);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res);
});
ipcMain.handle('cape:remove', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
const res = await friends.removeCape(konto.uuid);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? res : umhangFehler(res);
});
// für den Editor: eigener Umhang, sonst der getragene offizielle
ipcMain.handle('cape:currentData', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
umhangCache.delete(String(konto.uuid).replace(/-/g, '').toLowerCase());
const r = await umhangFuer(konto.uuid);
if (!r || !r.dataUri) return { ok: false, keiner: true, message: 'Dein Konto hat noch keinen Umhang.' };
return { ok: true, dataUri: r.dataUri, quelle: r.quelle };
});
// Bild wählen: art 'umhang' = fertige Umhang-Datei (PNG), 'bild' = beliebiges Motiv zum Einsetzen
ipcMain.handle('cape:chooseFile', async (e, art) => {
const motiv = art === 'bild';
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: motiv ? 'Bild für den Umhang wählen' : 'Umhang-Datei öffnen',
properties: ['openFile'],
filters: motiv
? [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] }]
: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const pfad = res.filePaths[0];
try {
const st = fs.statSync(pfad);
if (st.size > 15 * 1024 * 1024) return { ok: false, message: 'Die Datei ist zu groß (über 15 MB).' };
const endung = path.extname(pfad).slice(1).toLowerCase().replace('jpg', 'jpeg');
return { ok: true, pfad, dataUri: `data:image/${endung || 'png'};base64,` + fs.readFileSync(pfad).toString('base64') };
} catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
});
ipcMain.handle('cape:saveData', async (e, base64) => {
const daten = umhangPngAusBase64(base64);
if (!daten) return { ok: false, message: 'Ungültige Umhang-Daten.' };
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Umhang speichern',
defaultPath: path.join(app.getPath('pictures'), 'umhang.png'),
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('skin:readData', (_e, dateiPfad) => { ipcMain.handle('skin:readData', (_e, dateiPfad) => {
const geprueft = skins.pruefeDatei(dateiPfad); const geprueft = skins.pruefeDatei(dateiPfad);
if (!geprueft.ok) return geprueft; if (!geprueft.ok) return geprueft;
@@ -1893,7 +2101,7 @@ ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
try { try {
fs.writeFileSync(tmp, daten); fs.writeFileSync(tmp, daten);
const res = await skins.setzeSkin(token, tmp, variante); const res = await skins.setzeSkin(token, tmp, variante);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); } if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
return res; return res;
} catch (err) { } catch (err) {
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
@@ -2475,18 +2683,48 @@ function aktivesFreundeKonto() {
ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false); ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false);
/*
* Der Server kennt den Namen eines Spielers nur, wenn dieser schon einmal über
* AeroMC gespielt hat (Herzschlag). Wer vorher eine Anfrage schickt, käme beim
* Empfänger nur als UUID an – deshalb fehlende Namen bei Mojang nachschlagen
* (öffentliches Profil, 1 Stunde gemerkt).
*/
const spielerNamen = new Map(); // uuid -> { name, zeit }
async function spielerName(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const gemerkt = spielerNamen.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 60 * 60 * 1000) return gemerkt.name;
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) return gemerkt ? gemerkt.name : null;
const name = (await res.json()).name || null;
spielerNamen.set(id, { name, zeit: Date.now() });
return name;
} catch {
return gemerkt ? gemerkt.name : null;
}
}
async function mitNamen(liste) {
const eintraege = Array.isArray(liste) ? liste : [];
await Promise.all(eintraege.map(async (e) => {
if (e && !e.username && e.uuid) e.username = await spielerName(e.uuid);
}));
return eintraege;
}
ipcMain.handle('friends:list', async () => { ipcMain.handle('friends:list', async () => {
const konto = aktivesFreundeKonto(); const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getFriends(konto.uuid); const res = await friends.getFriends(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res; return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
}); });
ipcMain.handle('friends:requests', async () => { ipcMain.handle('friends:requests', async () => {
const konto = aktivesFreundeKonto(); const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getRequests(konto.uuid); const res = await friends.getRequests(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res; return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
}); });
ipcMain.handle('friends:add', async (_e, targetUsername) => { ipcMain.handle('friends:add', async (_e, targetUsername) => {
@@ -2500,7 +2738,7 @@ ipcMain.handle('friends:sentRequests', async () => {
const konto = aktivesFreundeKonto(); const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getSentRequests(konto.uuid); const res = await friends.getSentRequests(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res; return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
}); });
ipcMain.handle('friends:cancel', async (_e, otherUuid) => { ipcMain.handle('friends:cancel', async (_e, otherUuid) => {
@@ -2573,7 +2811,12 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id)); ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id)); ipcMain.handle('instances:duplicate', async (_e, id) => {
const kopie = store.duplicateInstance(id, true);
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
catch { /* Quelle evtl. leer */ }
return kopie;
});
ipcMain.handle('instances:openFolder', (_e, id) => { ipcMain.handle('instances:openFolder', (_e, id) => {
const p = store.instancePath(id); const p = store.instancePath(id);
@@ -3602,6 +3845,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null); if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
const opts = { const opts = {
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(),
versionId: inst.minecraft.version, versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla', loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '', loaderVersion: inst.minecraft.loaderVersion || '',
@@ -3811,20 +4055,91 @@ ipcMain.handle('mmc:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('mmc:import', (_e, entries, copyData) => { // ---------- Instanzen aus anderen Launchern übernehmen ----------
/*
* Gemeinsamer Ablauf für alle Launcher-Importe. Früher wurde synchron kopiert –
* bei vielen Gigabyte stand der Launcher dann komplett still. Jetzt:
* 1. Größe aller ausgewählten Instanzen ermitteln (asynchron)
* 2. prüfen, ob auf dem Laufwerk genug Platz frei ist
* 3. Instanz für Instanz anlegen und im Hintergrund kopieren
* Fortschritt geht als 'import:fortschritt' an alle Fenster; 'import:abbrechen'
* stoppt den Lauf, die gerade halb kopierte Instanz wird wieder entfernt.
*/
let laufenderImport = null;
const GB = 1024 * 1024 * 1024;
const zuGb = (b) => (b / GB).toLocaleString('de-DE', { maximumFractionDigits: 1 });
ipcMain.handle('import:abbrechen', () => {
if (laufenderImport) laufenderImport.abgebrochen = true;
return !!laufenderImport;
});
async function fuehreImportAus(modul, entries, copyData, nachher) {
if (laufenderImport) return { ok: false, message: 'Es läuft bereits ein Import – bitte warten oder ihn abbrechen.' };
const job = { abgebrochen: false };
laufenderImport = job;
const liste = entries || [];
const done = []; const done = [];
const failed = []; const failed = [];
for (const entry of entries || []) { const melde = (daten) => broadcast('import:fortschritt', daten);
try { let fortschritt = null;
const imported = mmcimport.importOne(store, entry, copyData !== false); try {
done.push(imported); // 1) Was wird kopiert und wie viel?
const plaene = liste.map((entry) => ({ entry, quellen: copyData ? modul.kopierQuellen(entry) : [] }));
let gesamt = { bytes: 0, dateien: 0 };
if (copyData) {
melde({ phase: 'zaehlen', dateien: 0, bytes: 0, instanzen: liste.length });
try {
gesamt = await kopieren.zaehle(plaene.flatMap((p) => p.quellen.map((q) => q.von)), job,
(s) => melde({ phase: 'zaehlen', dateien: s.dateien, bytes: s.bytes, instanzen: liste.length }));
} catch (err) {
if (err.abgebrochen) return { ok: true, imported: [], failed: [], abgebrochen: true, uebersprungen: [] };
throw err;
}
// 2) Reicht der Platz? (etwas Luft für Windows lassen)
const frei = await kopieren.freierPlatz(store.getInstancesDir());
if (frei !== null && gesamt.bytes > frei - 512 * 1024 * 1024) {
return { ok: false, message: `Nicht genug Speicherplatz: Die Instanzen brauchen ${zuGb(gesamt.bytes)} GB, auf dem Laufwerk sind nur ${zuGb(frei)} GB frei. Weniger Instanzen auswählen oder unter Optionen → Allgemein einen anderen Instanz-Ordner wählen.` };
}
} }
catch (err) { failed.push({ name: entry.name, message: err.message }); } // 3) Instanz für Instanz – ein gemeinsamer Zähler über alle Instanzen
broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length }); let kopf = { phase: 'kopieren' };
fortschritt = kopieren.neuerFortschritt(gesamt, (p) => melde(Object.assign({}, kopf, p)));
const f = fortschritt;
for (let i = 0; i < plaene.length; i++) {
if (job.abgebrochen) break;
const { entry, quellen } = plaene[i];
kopf = { phase: 'kopieren', instanz: entry.name, nr: i + 1, von: plaene.length };
f.melden(true);
let angelegt = null;
try {
angelegt = modul.importOne(store, entry, false); // nur Angaben – kopiert wird hier
for (const q of quellen) {
await kopieren.kopiere(q.von, path.join(store.gameDir(angelegt.id), q.name), f, job);
}
angelegt.copied = quellen.length > 0;
if (nachher) await nachher(entry, angelegt, kopf);
done.push(angelegt);
} catch (err) {
// halb kopierte Instanz nicht liegen lassen
if (angelegt) { try { store.deleteInstance(angelegt.id); } catch { /* Aufräumen ist kein Muss */ } }
if (!err.abgebrochen) {
failed.push({ name: entry.name, message: err.code === 'ENOSPC' ? 'Speicherplatz voll' : err.message });
if (err.code === 'ENOSPC') break;
}
}
}
} finally {
laufenderImport = null;
} }
if (done.length) broadcast('instances:changed'); if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed }; return {
}); ok: true, imported: done, failed, abgebrochen: job.abgebrochen,
uebersprungen: fortschritt ? fortschritt.uebersprungen.slice(0, 50) : [],
};
}
ipcMain.handle('mmc:import', (_e, entries, copyData) => fuehreImportAus(mmcimport, entries, copyData !== false));
ipcMain.handle('cf:pickFolder', async (e) => { ipcMain.handle('cf:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -3839,20 +4154,7 @@ ipcMain.handle('cf:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('cf:import', (_e, entries, copyData) => { ipcMain.handle('cf:import', (_e, entries, copyData) => fuehreImportAus(cfimport, entries, copyData !== false));
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = cfimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
// ---------- Modrinth App ---------- // ---------- Modrinth App ----------
ipcMain.handle('mr:pickFolder', async (e) => { ipcMain.handle('mr:pickFolder', async (e) => {
@@ -3914,26 +4216,12 @@ async function verknuepfeModrinthModpack(instId, link) {
return true; return true;
} }
ipcMain.handle('mr:import', async (_e, entries, copyData) => { ipcMain.handle('mr:import', (_e, entries, copyData) => fuehreImportAus(mrimport, entries, copyData !== false, async (entry, imported, kopf) => {
const done = []; if (!(entry.modpack && entry.modpack.projectId && entry.modpack.versionId)) return;
const failed = []; broadcast('import:fortschritt', Object.assign({}, kopf, { phase: 'modpack' }));
const liste = entries || []; try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
for (const entry of liste) { catch { imported.modpack = false; }
try { }));
const imported = mrimport.importOne(store, entry, copyData !== false);
if (entry.modpack && entry.modpack.projectId && entry.modpack.versionId) {
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length, detail: 'Modpack-Verknüpfung für ' + entry.name });
try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
catch { imported.modpack = false; }
}
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async (e) => { ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -3948,20 +4236,7 @@ ipcMain.handle('mc:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('mc:import', (_e, entries, copyData) => { ipcMain.handle('mc:import', (_e, entries, copyData) => fuehreImportAus(mcimport, entries, copyData !== false));
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = mcimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mc:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:pickFolder', async (e) => { ipcMain.handle('technic:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -3976,20 +4251,7 @@ ipcMain.handle('technic:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('technic:import', (_e, entries, copyData) => { ipcMain.handle('technic:import', (_e, entries, copyData) => fuehreImportAus(technicimport, entries, copyData !== false));
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = technicimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ---- // ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
+20 -5
View File
@@ -173,16 +173,31 @@ function scan(inputPath) {
return { ok: true, rootDir, count: list.length, instances: list }; return { ok: true, rootDir, count: list.length, instances: list };
} }
function kopierNamen(sourceDir, usesDefaultGameDir) {
return fs.readdirSync(sourceDir).filter((name) => {
if (/^launcher_profiles.*\.json$/i.test(name)) return false;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') return false;
return !(usesDefaultGameDir && !shouldCopyRootEntry(name));
});
}
function copyInto(sourceDir, targetDir, usesDefaultGameDir) { function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of kopierNamen(sourceDir, usesDefaultGameDir)) {
if (/^launcher_profiles.*\.json$/i.test(name)) continue;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir, !!entry.usesDefaultGameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
@@ -214,4 +229,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot }; module.exports = { scan, importOne, kopierQuellen, parseLastVersionId, resolveLauncherRoot };
+199 -188
View File
@@ -1,188 +1,199 @@
'use strict'; 'use strict';
/* /*
* mmcimport.js – Instanzen aus MultiMC/Prism übernehmen * mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
* ------------------------------------------------------ * ------------------------------------------------------
* Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner). * Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
* Ausgelesen werden: * Ausgelesen werden:
* instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen * instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
* mmc-pack.json – Minecraft-Version und Mod-Loader * mmc-pack.json – Minecraft-Version und Mod-Loader
* .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen) * .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// uid der Loader in mmc-pack.json -> unsere Bezeichnung // uid der Loader in mmc-pack.json -> unsere Bezeichnung
const LOADER_UIDS = { const LOADER_UIDS = {
'net.fabricmc.fabric-loader': 'fabric', 'net.fabricmc.fabric-loader': 'fabric',
'org.quiltmc.quilt-loader': 'quilt', 'org.quiltmc.quilt-loader': 'quilt',
'net.minecraftforge': 'forge', 'net.minecraftforge': 'forge',
'net.neoforged': 'neoforge', 'net.neoforged': 'neoforge',
}; };
function parseCfg(file) { function parseCfg(file) {
const out = {}; const out = {};
try { try {
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const i = line.indexOf('='); const i = line.indexOf('=');
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim(); if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
} }
} catch { /* Datei fehlt */ } } catch { /* Datei fehlt */ }
return out; return out;
} }
// Gruppen stehen zentral in instances/instgroups.json // Gruppen stehen zentral in instances/instgroups.json
function readGroups(instancesDir) { function readGroups(instancesDir) {
const map = {}; const map = {};
try { try {
const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8')); const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
for (const [group, data] of Object.entries(raw.groups || {})) { for (const [group, data] of Object.entries(raw.groups || {})) {
for (const inst of data.instances || []) map[inst] = group; for (const inst of data.instances || []) map[inst] = group;
} }
} catch { /* keine Gruppen hinterlegt */ } } catch { /* keine Gruppen hinterlegt */ }
return map; return map;
} }
function readPack(dir) { function readPack(dir) {
const res = { version: '', loader: 'vanilla', loaderVersion: '' }; const res = { version: '', loader: 'vanilla', loaderVersion: '' };
try { try {
const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8')); const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
for (const c of pack.components || []) { for (const c of pack.components || []) {
if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || ''; if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
const loader = LOADER_UIDS[c.uid]; const loader = LOADER_UIDS[c.uid];
if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; } if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
} }
} catch { /* alte Instanz ohne mmc-pack.json */ } } catch { /* alte Instanz ohne mmc-pack.json */ }
return res; return res;
} }
// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde // findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
function resolveInstancesDir(inputPath) { function resolveInstancesDir(inputPath) {
if (!inputPath) return null; if (!inputPath) return null;
const direct = path.join(inputPath, 'instances'); const direct = path.join(inputPath, 'instances');
if (fs.existsSync(direct)) return direct; if (fs.existsSync(direct)) return direct;
if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath; if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
return fs.existsSync(inputPath) ? inputPath : null; return fs.existsSync(inputPath) ? inputPath : null;
} }
/* /*
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt. * Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet. * Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
*/ */
function readInstance(dir, folderName, group) { function readInstance(dir, folderName, group) {
const cfgFile = path.join(dir, 'instance.cfg'); const cfgFile = path.join(dir, 'instance.cfg');
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
const cfg = parseCfg(cfgFile); const cfg = parseCfg(cfgFile);
const pack = readPack(dir); const pack = readPack(dir);
// MultiMC nutzt .minecraft, Prism minecraft // MultiMC nutzt .minecraft, Prism minecraft
const gameDir = ['.minecraft', 'minecraft'] const gameDir = ['.minecraft', 'minecraft']
.map((n) => path.join(dir, n)) .map((n) => path.join(dir, n))
.find((p) => fs.existsSync(p)) || null; .find((p) => fs.existsSync(p)) || null;
return { return {
folder: folderName, folder: folderName,
dir, dir,
name: cfg.name || folderName, name: cfg.name || folderName,
group: group || '', group: group || '',
notes: cfg.notes || '', notes: cfg.notes || '',
version: pack.version || cfg.IntendedVersion || '', version: pack.version || cfg.IntendedVersion || '',
loader: pack.loader, loader: pack.loader,
loaderVersion: pack.loaderVersion, loaderVersion: pack.loaderVersion,
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '', javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null, minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null, maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
gameDir, gameDir,
hasGameData: !!gameDir, hasGameData: !!gameDir,
}; };
} }
/* /*
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren). * Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
*/ */
function scan(inputPath) { function scan(inputPath) {
const instancesDir = resolveInstancesDir(inputPath); const instancesDir = resolveInstancesDir(inputPath);
if (!instancesDir) return { ok: false, reason: 'not-found' }; if (!instancesDir) return { ok: false, reason: 'not-found' };
const groups = readGroups(instancesDir); const groups = readGroups(instancesDir);
const list = []; const list = [];
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]); const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
if (inst) list.push(inst); if (inst) list.push(inst);
} }
list.sort((a, b) => a.name.localeCompare(b.name)); list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir, count: list.length, instances: list }; return { ok: true, instancesDir, count: list.length, instances: list };
} }
/* /*
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab: * Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg"). * mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird. * Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
*/ */
function findInstanceDir(root, tiefe = 2) { function findInstanceDir(root, tiefe = 2) {
if (!root || !fs.existsSync(root)) return null; if (!root || !fs.existsSync(root)) return null;
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root; if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
if (tiefe <= 0) return null; if (tiefe <= 0) return null;
let eintraege = []; let eintraege = [];
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; } try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
for (const e of eintraege) { for (const e of eintraege) {
if (!e.isDirectory()) continue; if (!e.isDirectory()) continue;
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner // .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
if (e.name === '.minecraft' || e.name === 'minecraft') continue; if (e.name === '.minecraft' || e.name === 'minecraft') continue;
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1); const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
if (treffer) return treffer; if (treffer) return treffer;
} }
return null; return null;
} }
/* /*
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz. * Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
* -> { ok, instance } oder { ok: false, reason: 'not-found' } * -> { ok, instance } oder { ok: false, reason: 'not-found' }
*/ */
function scanSingle(root) { function scanSingle(root) {
const dir = findInstanceDir(root); const dir = findInstanceDir(root);
if (!dir) return { ok: false, reason: 'not-found' }; if (!dir) return { ok: false, reason: 'not-found' };
const inst = readInstance(dir, path.basename(dir), ''); const inst = readInstance(dir, path.basename(dir), '');
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' }; return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
} }
/* /*
* Übernimmt eine Instanz in den Launcher. * Übernimmt eine Instanz in den Launcher.
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren * store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
*/ */
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
group: entry.group, group: entry.group,
notes: entry.notes, notes: entry.notes,
minecraft: { minecraft: {
version: entry.version, version: entry.version,
loader: entry.loader, loader: entry.loader,
loaderVersion: entry.loaderVersion, loaderVersion: entry.loaderVersion,
}, },
}); });
const patch = {}; const patch = {};
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) { if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
patch.java = { patch.java = {
path: entry.javaPath || '', path: entry.javaPath || '',
minMemMb: entry.minMemMb || null, minMemMb: entry.minMemMb || null,
maxMemMb: entry.maxMemMb || null, maxMemMb: entry.maxMemMb || null,
extraArgs: '', extraArgs: '',
}; };
} }
if (Object.keys(patch).length) store.updateInstance(created.id, patch); if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false; let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
try { try {
fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true }); fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
copied = true; copied = true;
} catch { /* Spieldaten konnten nicht kopiert werden */ } } catch { /* Spieldaten konnten nicht kopiert werden */ }
} }
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return fs.readdirSync(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
module.exports = { scan, scanSingle, importOne, kopierQuellen, resolveInstancesDir, readInstance, findInstanceDir };
+448 -437
View File
@@ -1,437 +1,448 @@
'use strict'; 'use strict';
/* /*
* mrimport.js – Instanzen aus der Modrinth App übernehmen * mrimport.js – Instanzen aus der Modrinth App übernehmen
* -------------------------------------------------------- * --------------------------------------------------------
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte * Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in * Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen, * "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in * Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
* der App verlegt, steht der neue Ort in settings.custom_dir. * der App verlegt, steht der neue Ort in settings.custom_dir.
* *
* Unterstützte Datenbank-Fassungen: * Unterstützte Datenbank-Fassungen:
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader), * - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
* Gruppen in "instance_group_memberships"/"instance_groups", * Gruppen in "instance_group_memberships"/"instance_groups",
* Starteinstellungen als JSONB in "instance_launch_overrides" * Starteinstellungen als JSONB in "instance_launch_overrides"
* - 2024 bis Mitte 2026: eine Tabelle "profiles" * - 2024 bis Mitte 2026: eine Tabelle "profiles"
* - vorher (ohne app.db): je Instanz eine profile.json * - vorher (ohne app.db): je Instanz eine profile.json
* *
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher – * Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
* die Datenbank der Modrinth App wird nie verändert. * die Datenbank der Modrinth App wird nie verändert.
*/ */
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']); const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
let sqlPromise = null; let sqlPromise = null;
function sqlJs() { function sqlJs() {
if (!sqlPromise) { if (!sqlPromise) {
const initSqlJs = require('sql.js'); const initSqlJs = require('sql.js');
const wasmDir = path.dirname(require.resolve('sql.js')); const wasmDir = path.dirname(require.resolve('sql.js'));
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) }); sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
} }
return sqlPromise; return sqlPromise;
} }
function appDatenOrdner() { function appDatenOrdner() {
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')]; return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
} }
function standardOrdner() { function standardOrdner() {
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0]; return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
} }
function readJson(file) { function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; } try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
} }
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln // Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
function ermittleOrte(eingabe) { function ermittleOrte(eingabe) {
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner(); let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
if (!fs.existsSync(basis)) return null; if (!fs.existsSync(basis)) return null;
// direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db // direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) { if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
const hoeher = path.dirname(basis); const hoeher = path.dirname(basis);
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher; if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
} }
const db = path.join(basis, 'app.db'); const db = path.join(basis, 'app.db');
return { basis, db: fs.existsSync(db) ? db : null }; return { basis, db: fs.existsSync(db) ? db : null };
} }
// ---------- Datenbank ---------- // ---------- Datenbank ----------
/* /*
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im * Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene * Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
* Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach * Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
* dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite), * dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
* mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst. * mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt * -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
*/ */
function spieleWalEin(daten, walPfad) { function spieleWalEin(daten, walPfad) {
let wal; let wal;
try { wal = fs.readFileSync(walPfad); } catch { return null; } try { wal = fs.readFileSync(walPfad); } catch { return null; }
if (wal.length < 32) return null; if (wal.length < 32) return null;
const magie = wal.readUInt32BE(0); const magie = wal.readUInt32BE(0);
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null; if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
const bigEndian = magie === 0x377f0683; const bigEndian = magie === 0x377f0683;
const seite = wal.readUInt32BE(8); const seite = wal.readUInt32BE(8);
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16); const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
if (!seite || seite !== dbSeite) return null; if (!seite || seite !== dbSeite) return null;
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20); const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i)); const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
let s0 = 0, s1 = 0; let s0 = 0, s1 = 0;
const pruefe = (buf, start, ende) => { const pruefe = (buf, start, ende) => {
for (let i = start; i < ende; i += 8) { for (let i = start; i < ende; i += 8) {
s0 = (s0 + wort(buf, i) + s1) >>> 0; s0 = (s0 + wort(buf, i) + s1) >>> 0;
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0; s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
} }
}; };
pruefe(wal, 0, 24); pruefe(wal, 0, 24);
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null; if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen) const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
let offen = new Map(); let offen = new Map();
let seiten = Math.floor(daten.length / seite); let seiten = Math.floor(daten.length / seite);
let rahmen = 0; let rahmen = 0;
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) { for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break; if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
pruefe(wal, pos, pos + 8); pruefe(wal, pos, pos + 8);
pruefe(wal, pos + 24, pos + 24 + seite); pruefe(wal, pos + 24, pos + 24 + seite);
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break; if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite)); offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
const nachCommit = wal.readUInt32BE(pos + 4); const nachCommit = wal.readUInt32BE(pos + 4);
if (nachCommit) { // Transaktion abgeschlossen if (nachCommit) { // Transaktion abgeschlossen
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt); for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
offen = new Map(); offen = new Map();
seiten = nachCommit; seiten = nachCommit;
rahmen++; rahmen++;
} }
} }
if (!rahmen) return null; if (!rahmen) return null;
const neu = Buffer.alloc(seiten * seite); const neu = Buffer.alloc(seiten * seite);
daten.copy(neu, 0, 0, Math.min(daten.length, neu.length)); daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite); for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
return { daten: neu, rahmen }; return { daten: neu, rahmen };
} }
async function oeffneDb(dbPfad) { async function oeffneDb(dbPfad) {
const SQL = await sqlJs(); const SQL = await sqlJs();
let daten = fs.readFileSync(dbPfad); let daten = fs.readFileSync(dbPfad);
let walEingespielt = false; let walEingespielt = false;
if (daten.length > 100 && daten[18] === 2) { if (daten.length > 100 && daten[18] === 2) {
const mitWal = spieleWalEin(daten, dbPfad + '-wal'); const mitWal = spieleWalEin(daten, dbPfad + '-wal');
if (mitWal) { daten = mitWal.daten; walEingespielt = true; } if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die // WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19) // Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
daten[18] = 1; daten[19] = 1; daten[18] = 1; daten[19] = 1;
} }
const db = new SQL.Database(daten); const db = new SQL.Database(daten);
db.walEingespielt = walEingespielt; db.walEingespielt = walEingespielt;
return db; return db;
} }
function zeilen(db, sql, params) { function zeilen(db, sql, params) {
const st = db.prepare(sql); const st = db.prepare(sql);
try { try {
if (params) st.bind(params); if (params) st.bind(params);
const out = []; const out = [];
while (st.step()) out.push(st.getAsObject()); while (st.step()) out.push(st.getAsObject());
return out; return out;
} finally { } finally {
st.free(); st.free();
} }
} }
function tabellen(db) { function tabellen(db) {
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name)); return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
} }
function spalten(db, tabelle) { function spalten(db, tabelle) {
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name)); return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
} }
function jsonFeld(wert) { function jsonFeld(wert) {
if (wert === null || wert === undefined || wert === '') return null; if (wert === null || wert === undefined || wert === '') return null;
try { return JSON.parse(wert); } catch { return null; } try { return JSON.parse(wert); } catch { return null; }
} }
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle) // Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') { function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`; return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
} }
function eigenerOrdner(db, t) { function eigenerOrdner(db, t) {
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null; if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0]; const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
return r && r.custom_dir ? String(r.custom_dir) : null; return r && r.custom_dir ? String(r.custom_dir) : null;
} }
function leseNeu(db, t) { function leseNeu(db, t) {
const si = spalten(db, 'instances'); const si = spalten(db, 'instances');
const liste = zeilen(db, ` const liste = zeilen(db, `
SELECT i.id AS id, i.path AS path, i.name AS name, SELECT i.id AS id, i.path AS path, i.name AS name,
${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')}, ${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
${sp(si, 'i.last_played', 'last_played')}, ${sp(si, 'i.last_played', 'last_played')},
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')}, ${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
${sp(si, 'i.recent_time_played', 'recent_time_played', '0')}, ${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
FROM instances i FROM instances i
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`); LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
const overrides = new Map(); const overrides = new Map();
if (t.has('instance_launch_overrides')) { if (t.has('instance_launch_overrides')) {
for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) { for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
overrides.set(r.instance_id, jsonFeld(r.o) || {}); overrides.set(r.instance_id, jsonFeld(r.o) || {});
} }
} }
const gruppen = new Map(); const gruppen = new Map();
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); }; const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
if (t.has('instance_group_memberships') && t.has('instance_groups')) { if (t.has('instance_group_memberships') && t.has('instance_groups')) {
for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name); JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
} else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) { } else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name); for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
} }
const links = new Map(); const links = new Map();
if (t.has('instance_links')) { if (t.has('instance_links')) {
for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) { for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id }); if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
} }
} }
return liste.map((r) => { return liste.map((r) => {
const o = overrides.get(r.id) || {}; const o = overrides.get(r.id) || {};
const hooks = o.hooks || {}; const hooks = o.hooks || {};
return { return {
path: r.path, path: r.path,
name: r.name, name: r.name,
iconPath: r.icon_path, iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed', installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played, lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0), sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version, gameVersion: r.game_version,
loader: r.loader, loader: r.loader,
loaderVersion: r.loader_version, loaderVersion: r.loader_version,
gruppe: gruppen.get(r.id) || '', gruppe: gruppen.get(r.id) || '',
javaPath: o.java_path || '', javaPath: o.java_path || '',
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null, maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '', extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
vorStart: hooks.pre_launch || '', vorStart: hooks.pre_launch || '',
nachEnde: hooks.post_exit || '', nachEnde: hooks.post_exit || '',
modpack: links.get(r.id) || null, modpack: links.get(r.id) || null,
}; };
}); });
} }
function leseProfile(db) { function leseProfile(db) {
const s = spalten(db, 'profiles'); const s = spalten(db, 'profiles');
const liste = zeilen(db, `SELECT path, name, const liste = zeilen(db, `SELECT path, name,
${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')}, ${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')}, ${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')}, ${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
${sp(s, 'last_played', 'last_played')}, ${sp(s, 'last_played', 'last_played')},
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')}, ${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
${sp(s, 'recent_time_played', 'recent_time_played', '0')}, ${sp(s, 'recent_time_played', 'recent_time_played', '0')},
${sp(s, 'override_java_path', 'override_java_path')}, ${sp(s, 'override_java_path', 'override_java_path')},
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')}, ${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')}, ${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')}, ${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')}, ${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')} ${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
FROM profiles`); FROM profiles`);
return liste.map((r) => { return liste.map((r) => {
const gruppen = jsonFeld(r.groups); const gruppen = jsonFeld(r.groups);
const args = jsonFeld(r.override_extra_launch_args); const args = jsonFeld(r.override_extra_launch_args);
return { return {
path: r.path, path: r.path,
name: r.name, name: r.name,
iconPath: r.icon_path, iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed', installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played, lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0), sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version, gameVersion: r.game_version,
loader: r.mod_loader, loader: r.mod_loader,
loaderVersion: r.mod_loader_version, loaderVersion: r.mod_loader_version,
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '', gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
javaPath: r.override_java_path || '', javaPath: r.override_java_path || '',
maxMemMb: Number(r.override_mc_memory_max) || null, maxMemMb: Number(r.override_mc_memory_max) || null,
extraArgs: Array.isArray(args) ? args.join(' ') : '', extraArgs: Array.isArray(args) ? args.join(' ') : '',
vorStart: r.override_hook_pre_launch || '', vorStart: r.override_hook_pre_launch || '',
nachEnde: r.override_hook_post_exit || '', nachEnde: r.override_hook_post_exit || '',
modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null, modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
}; };
}); });
} }
// sehr alte Fassungen: profiles\<Ordner>\profile.json // sehr alte Fassungen: profiles\<Ordner>\profile.json
function leseProfileJson(profilesDir) { function leseProfileJson(profilesDir) {
const liste = []; const liste = [];
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) { for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue; if (!e.isDirectory()) continue;
const p = readJson(path.join(profilesDir, e.name, 'profile.json')); const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
if (!p) continue; if (!p) continue;
const m = p.metadata || p; const m = p.metadata || p;
const lv = m.loader_version; const lv = m.loader_version;
liste.push({ liste.push({
path: e.name, path: e.name,
name: m.name || e.name, name: m.name || e.name,
iconPath: m.icon || null, iconPath: m.icon || null,
installiert: !p.install_stage || p.install_stage === 'installed', installiert: !p.install_stage || p.install_stage === 'installed',
lastPlayed: m.last_played || null, lastPlayed: m.last_played || null,
sekunden: 0, sekunden: 0,
gameVersion: m.game_version, gameVersion: m.game_version,
loader: m.loader, loader: m.loader,
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv, loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '', gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '', javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null, maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '', extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
vorStart: (p.hooks && p.hooks.pre_launch) || '', vorStart: (p.hooks && p.hooks.pre_launch) || '',
nachEnde: (p.hooks && p.hooks.post_exit) || '', nachEnde: (p.hooks && p.hooks.post_exit) || '',
modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null, ? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
}); });
} }
return liste; return liste;
} }
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version // "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
function loaderVersion(loader, wert, mc) { function loaderVersion(loader, wert, mc) {
let v = String(wert || '').trim(); let v = String(wert || '').trim();
if (!v || v === 'stable' || v === 'latest') return ''; if (!v || v === 'stable' || v === 'latest') return '';
if (loader === 'forge') { if (loader === 'forge') {
v = v.replace(/^forge-/i, ''); v = v.replace(/^forge-/i, '');
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1); if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
} else if (loader === 'neoforge') { } else if (loader === 'neoforge') {
v = v.replace(/^neoforge-/i, ''); v = v.replace(/^neoforge-/i, '');
} else if (loader === 'fabric' || loader === 'quilt') { } else if (loader === 'fabric' || loader === 'quilt') {
v = v.replace(/^(fabric|quilt)(-loader)?-/i, ''); v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1)); if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
} }
return v; return v;
} }
function zeitstempel(wert) { function zeitstempel(wert) {
if (!wert) return null; if (!wert) return null;
const n = Number(wert); const n = Number(wert);
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString(); if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
const d = new Date(wert); const d = new Date(wert);
return Number.isNaN(d.getTime()) ? null : d.toISOString(); return Number.isNaN(d.getTime()) ? null : d.toISOString();
} }
/* /*
* Durchsucht die Modrinth App. Leere Eingabe = Standardort. * Durchsucht die Modrinth App. Leere Eingabe = Standardort.
* -> { ok, basis, count, instances, hinweis } * -> { ok, basis, count, instances, hinweis }
*/ */
async function scan(eingabe) { async function scan(eingabe) {
const orte = ermittleOrte(eingabe); const orte = ermittleOrte(eingabe);
if (!orte) return { ok: false, reason: 'not-found' }; if (!orte) return { ok: false, reason: 'not-found' };
let roh = []; let roh = [];
let profilesDir = path.join(orte.basis, 'profiles'); let profilesDir = path.join(orte.basis, 'profiles');
let hinweis = ''; let hinweis = '';
if (orte.db) { if (orte.db) {
const db = await oeffneDb(orte.db); const db = await oeffneDb(orte.db);
try { try {
const t = tabellen(db); const t = tabellen(db);
const eigener = eigenerOrdner(db, t); const eigener = eigenerOrdner(db, t);
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles'); if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
if (t.has('instances')) roh = leseNeu(db, t); if (t.has('instances')) roh = leseNeu(db, t);
else if (t.has('profiles')) roh = leseProfile(db); else if (t.has('profiles')) roh = leseProfile(db);
} finally { } finally {
db.close(); db.close();
} }
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie // Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
// ist evtl. nicht ganz aktuell // ist evtl. nicht ganz aktuell
try { try {
const wal = orte.db + '-wal'; const wal = orte.db + '-wal';
if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) { if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.'; hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
} }
} catch { /* egal */ } } catch { /* egal */ }
} else if (fs.existsSync(profilesDir)) { } else if (fs.existsSync(profilesDir)) {
roh = leseProfileJson(profilesDir); roh = leseProfileJson(profilesDir);
} else { } else {
return { ok: false, reason: 'not-found' }; return { ok: false, reason: 'not-found' };
} }
const instances = []; const instances = [];
for (const r of roh) { for (const r of roh) {
const dir = path.join(profilesDir, String(r.path || '')); const dir = path.join(profilesDir, String(r.path || ''));
if (!r.path || !fs.existsSync(dir)) continue; if (!r.path || !fs.existsSync(dir)) continue;
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla'; const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
const version = String(r.gameVersion || '').trim(); const version = String(r.gameVersion || '').trim();
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : ''; const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
instances.push({ instances.push({
folder: r.path, folder: r.path,
dir, dir,
gameDir: dir, gameDir: dir,
name: String(r.name || r.path), name: String(r.name || r.path),
group: r.gruppe || '', group: r.gruppe || '',
version, version,
loader, loader,
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version), loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
installiert: r.installiert, installiert: r.installiert,
iconPath: icon, iconPath: icon,
lastPlayed: zeitstempel(r.lastPlayed), lastPlayed: zeitstempel(r.lastPlayed),
playSeconds: Math.max(0, Math.round(r.sekunden || 0)), playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
javaPath: String(r.javaPath || '').trim(), javaPath: String(r.javaPath || '').trim(),
maxMemMb: r.maxMemMb || null, maxMemMb: r.maxMemMb || null,
extraArgs: String(r.extraArgs || '').trim(), extraArgs: String(r.extraArgs || '').trim(),
preLaunchCommand: String(r.vorStart || '').trim(), preLaunchCommand: String(r.vorStart || '').trim(),
postExitCommand: String(r.nachEnde || '').trim(), postExitCommand: String(r.nachEnde || '').trim(),
modpack: r.modpack, modpack: r.modpack,
}); });
} }
instances.sort((a, b) => a.name.localeCompare(b.name, 'de')); instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis }; return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
} }
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt // gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']); const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
function copyInto(quelle, ziel) { /*
fs.mkdirSync(ziel, { recursive: true }); * Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
for (const name of fs.readdirSync(quelle)) { * im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue; * asynchron mit Fortschritt (main.js → fuehreImportAus).
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher */
fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true }); function kopierQuellen(entry) {
} if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
} return fs.readdirSync(entry.gameDir).filter((name) => !NICHT_KOPIEREN.has(name.toLowerCase()))
.map((name) => ({ von: path.join(entry.gameDir, name), name }));
function importOne(store, entry, copyData = true) { }
const created = store.createOrReplaceImportedInstance({
name: entry.name, function copyInto(quelle, ziel) {
group: entry.group, fs.mkdirSync(ziel, { recursive: true });
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion }, for (const name of fs.readdirSync(quelle)) {
}); if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
const patch = {}; fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
if (entry.javaPath || entry.maxMemMb || entry.extraArgs) { }
patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' }; }
}
if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand; function importOne(store, entry, copyData = true) {
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand; const created = store.createOrReplaceImportedInstance({
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere) name: entry.name,
const vorher = store.getInstance(created.id) || {}; group: entry.group,
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds; minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed; });
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
const patch = {};
let copied = false; if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
copyInto(entry.gameDir, store.gameDir(created.id)); }
copied = true; if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
} if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
if (entry.iconPath) { // Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ } const vorher = store.getInstance(created.id) || {};
} if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
return { id: created.id, name: created.name, copied }; if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
} if (Object.keys(patch).length) store.updateInstance(created.id, patch);
module.exports = { scan, importOne, standardOrdner }; let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
if (entry.iconPath) {
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, kopierQuellen, standardOrdner };
+31 -1
View File
@@ -16,6 +16,21 @@ window.addEventListener('unhandledrejection', (e) => {
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason)); ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
}); });
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
let letzterKlick = null;
window.addEventListener('pointerdown', (e) => {
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
if (!t || !t.getBoundingClientRect) return;
const r = t.getBoundingClientRect();
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
}, true);
function frischerKlick() {
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
const { x, y, width, height } = letzterKlick;
return { x, y, width, height };
}
contextBridge.exposeInMainWorld('api', { contextBridge.exposeInMainWorld('api', {
// Einstellungen // Einstellungen
getSettings: () => ipcRenderer.invoke('settings:get'), getSettings: () => ipcRenderer.invoke('settings:get'),
@@ -159,6 +174,14 @@ contextBridge.exposeInMainWorld('api', {
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad), skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64), skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante), skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
capeInfo: () => ipcRenderer.invoke('cape:info'),
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
capeRemove: () => ipcRenderer.invoke('cape:remove'),
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
authRemove: (id) => ipcRenderer.invoke('auth:remove', id), authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
authLogin: () => ipcRenderer.invoke('auth:login'), authLogin: () => ipcRenderer.invoke('auth:login'),
authLogout: () => ipcRenderer.invoke('auth:logout'), authLogout: () => ipcRenderer.invoke('auth:logout'),
@@ -188,6 +211,13 @@ contextBridge.exposeInMainWorld('api', {
// Dialoge / App // Dialoge / App
// MultiMC-Import // MultiMC-Import
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
onImportFortschritt: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('import:fortschritt', l);
return () => ipcRenderer.removeListener('import:fortschritt', l);
},
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'), mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder), mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData), mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
@@ -280,7 +310,7 @@ contextBridge.exposeInMainWorld('api', {
appLicense: () => ipcRenderer.invoke('app:license'), appLicense: () => ipcRenderer.invoke('app:license'),
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params), popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
popoutHide: () => ipcRenderer.send('popout:hide'), popoutHide: () => ipcRenderer.send('popout:hide'),
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design), setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
onPopoutReopen: (cb) => { onPopoutReopen: (cb) => {
+541 -75
View File
@@ -541,6 +541,31 @@ function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch // 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design. // bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft']; const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
// ---------- Bewegte Oberfläche (Optionen → Darstellung → Animationen) ----------
let uiAnimationen = true;
const MIT_STARTANIMATION = new URLSearchParams(location.search).get('startanimation') === '1';
function bewegungErlaubt() {
return uiAnimationen && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setzeUiAnimationen(an) {
uiAnimationen = an !== false;
document.documentElement.classList.toggle('ui-animiert', bewegungErlaubt());
if (!bewegungErlaubt()) beendeSkinLive();
}
// Design weich überblenden statt hart umzuschalten (View Transitions von Chromium)
function designUeberblenden(theme) {
const root = document.documentElement;
const vorher = root.getAttribute('data-design') + '/' + root.getAttribute('data-theme');
const t = DESIGNS.includes(theme) ? theme : 'dark';
const nachher = (['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch') + '/' + (t === 'light' ? 'light' : 'dark');
// versteckte Fenster (z. B. Hauptfenster im Infobereich) zeichnen nicht –
// dort liefe der Übergang nie an, also gleich umschalten
if (vorher === nachher || !bewegungErlaubt() || !document.startViewTransition || document.hidden) { applyTheme(theme); return; }
document.startViewTransition(() => applyTheme(theme));
setTimeout(() => applyTheme(theme), 600); // Sicherheitsnetz, falls der Übergang hängt
}
function applyTheme(theme) { function applyTheme(theme) {
const t = DESIGNS.includes(theme) ? theme : 'dark'; const t = DESIGNS.includes(theme) ? theme : 'dark';
const root = document.documentElement; const root = document.documentElement;
@@ -561,6 +586,73 @@ let importKind = 'zip';
let importWarteschlange = []; let importWarteschlange = [];
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten // Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
// ---------- Fortschritt großer Importe ----------
function zuGroesse(b) {
const gb = b / 1073741824;
if (gb >= 1) return gb.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' GB';
return Math.max(0, Math.round(b / 1048576)).toLocaleString('de-DE') + ' MB';
}
function zuDauer(s) {
if (s == null || !isFinite(s)) return '';
if (s < 60) return 'noch wenige Sekunden';
const m = Math.round(s / 60);
if (m < 60) return `noch ca. ${m} Min.`;
return `noch ca. ${Math.floor(m / 60)} Std. ${m % 60} Min.`;
}
function zeigeImportFortschritt(p) {
const balken = el('import-lauf-balken');
const leiste = balken.parentElement;
el('import-lauf').classList.remove('hidden');
if (p.phase === 'zaehlen') {
el('import-lauf-titel').textContent = 'Größe wird ermittelt …';
leiste.classList.add('unbestimmt');
el('import-lauf-zeile').textContent = `${p.dateien.toLocaleString('de-DE')} ${p.dateien === 1 ? 'Datei' : 'Dateien'} · ${zuGroesse(p.bytes)} gefunden`;
el('import-lauf-datei').textContent = '';
return;
}
leiste.classList.remove('unbestimmt');
const titel = p.von > 1 ? `Instanz ${p.nr} von ${p.von}: ${p.instanz}` : `Übernehme ${p.instanz}`;
if (p.phase === 'modpack') {
el('import-lauf-titel').textContent = `${titel} – Modpack-Verknüpfung …`;
return;
}
el('import-lauf-titel').textContent = titel;
const anteil = p.gesamtBytes > 0 ? Math.min(1, p.bytes / p.gesamtBytes) : (p.gesamtDateien ? p.dateien / p.gesamtDateien : 0);
balken.style.width = (anteil * 100).toFixed(1) + '%';
const teile = [`${zuGroesse(p.bytes)} von ${zuGroesse(p.gesamtBytes)} (${Math.round(anteil * 100)} %)`];
if (p.tempo > 0) teile.push(zuGroesse(p.tempo) + '/s');
const rest = zuDauer(p.restSekunden);
if (rest) teile.push(rest);
el('import-lauf-zeile').textContent = teile.join(' · ');
el('import-lauf-datei').textContent = p.datei ? `${p.dateien.toLocaleString('de-DE')} / ${p.gesamtDateien.toLocaleString('de-DE')} Dateien · ${p.datei}` : '';
}
/*
* Führt einen Launcher-Import mit Fortschrittsanzeige aus. Gibt das Ergebnis
* zurück – oder null, wenn er gar nicht erst lief (z. B. zu wenig Platz).
*/
async function importMitFortschritt(aufruf) {
el('import-lauf-titel').textContent = 'Wird vorbereitet …';
el('import-lauf-zeile').textContent = '';
el('import-lauf-datei').textContent = '';
el('import-lauf-balken').style.width = '0';
el('import-lauf-abbrechen').disabled = false;
el('import-lauf').classList.remove('hidden');
let res;
try { res = await aufruf(); }
catch (err) { res = { ok: false, message: err.message }; }
el('import-lauf').classList.add('hidden');
if (!res || !res.ok) {
toast(res && res.message ? res.message : 'Import fehlgeschlagen.', true);
return null;
}
if (res.uebersprungen && res.uebersprungen.length) {
// nach der Zusammenfassung zeigen, sonst überschreibt diese den Hinweis
setTimeout(() => toast(`${res.uebersprungen.length} Datei${res.uebersprungen.length === 1 ? '' : 'en'} konnte${res.uebersprungen.length === 1 ? '' : 'n'} nicht kopiert werden (z. B. gerade geöffnet) und wurde${res.uebersprungen.length === 1 ? '' : 'n'} übersprungen.`, true), 3000);
}
return res;
}
function importAbgeschlossen(kind) { function importAbgeschlossen(kind) {
importWarteschlange = importWarteschlange.filter((k) => k !== kind); importWarteschlange = importWarteschlange.filter((k) => k !== kind);
if (importWarteschlange.length) { if (importWarteschlange.length) {
@@ -864,12 +956,13 @@ async function runMcImport() {
el('mc-progress').textContent = 'Übernehme …'; el('mc-progress').textContent = 'Übernehme …';
el('mc-progress').classList.remove('hidden'); el('mc-progress').classList.remove('hidden');
const res = await window.api.mcImport(entries, copy); const res = await importMitFortschritt(() => window.api.mcImport(entries, copy));
if (!res) { el('mc-do').disabled = false; el('mc-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('mc'); importAbgeschlossen('mc');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -934,12 +1027,13 @@ async function runCfImport() {
el('cf-progress').textContent = 'Übernehme …'; el('cf-progress').textContent = 'Übernehme …';
el('cf-progress').classList.remove('hidden'); el('cf-progress').classList.remove('hidden');
const res = await window.api.cfImport(entries, copy); const res = await importMitFortschritt(() => window.api.cfImport(entries, copy));
if (!res) { el('cf-do').disabled = false; el('cf-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('cf'); importAbgeschlossen('cf');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1008,13 +1102,14 @@ async function runMrImport() {
el('mr-progress').textContent = 'Übernehme …'; el('mr-progress').textContent = 'Übernehme …';
el('mr-progress').classList.remove('hidden'); el('mr-progress').classList.remove('hidden');
const res = await window.api.mrImport(entries, copy); const res = await importMitFortschritt(() => window.api.mrImport(entries, copy));
if (!res) { el('mr-do').disabled = false; el('mr-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('mr'); importAbgeschlossen('mr');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
const ohneLink = res.imported.filter((i) => i.modpack === false).length; const ohneLink = res.imported.filter((i) => i.modpack === false).length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}` toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}`
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0); + (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1080,12 +1175,13 @@ async function runMmcImport() {
el('mmc-progress').textContent = 'Übernehme …'; el('mmc-progress').textContent = 'Übernehme …';
el('mmc-progress').classList.remove('hidden'); el('mmc-progress').classList.remove('hidden');
const res = await window.api.mmcImport(entries, copy); const res = await importMitFortschritt(() => window.api.mmcImport(entries, copy));
if (!res) { el('mmc-do').disabled = false; el('mmc-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('mmc'); importAbgeschlossen('mmc');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1206,12 +1302,13 @@ async function runTechnicImport() {
el('technic-progress').textContent = 'Übernehme …'; el('technic-progress').textContent = 'Übernehme …';
el('technic-progress').classList.remove('hidden'); el('technic-progress').classList.remove('hidden');
const res = await window.api.technicImport(entries, copy); const res = await importMitFortschritt(() => window.api.technicImport(entries, copy));
if (!res) { el('technic-do').disabled = false; el('technic-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('technic'); importAbgeschlossen('technic');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1427,6 +1524,55 @@ function closeContextMenu() {
el('ctx-menu').classList.add('hidden'); el('ctx-menu').classList.add('hidden');
} }
// ---------- Konto-Menü (Klick auf den Kontoknopf) ----------
function schliesseKontoMenue() {
el('konto-menu').classList.add('hidden');
}
async function oeffneKontoMenue() {
const menu = el('konto-menu');
if (!menu.classList.contains('hidden')) { schliesseKontoMenue(); return; }
closeContextMenu();
// feste, alphabetische Reihenfolge – sonst springt das aktive Konto nach oben
const konten = (await window.api.authAccounts()).slice().sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }));
menu.innerHTML = '';
const eintrag = (inhalt, aktion, klasse = '') => {
const b = document.createElement('button');
b.className = 'ctx-item konto-eintrag ' + klasse;
b.innerHTML = inhalt;
b.addEventListener('click', async () => { schliesseKontoMenue(); await aktion(); });
menu.appendChild(b);
return b;
};
for (const k of konten) {
const b = eintrag(
`<span class="konto-haken">${k.active ? '✓' : ''}</span><img alt="" /><span class="konto-name">${escapeHtml(k.name)}</span>${k.playing ? '<span class="konto-spielt">spielt</span>' : ''}`,
async () => {
if (k.active) return;
await window.api.authSetActive(k.id);
await refreshAccountButton();
await refreshAllAccountLists();
await refreshSkinBackground();
toast('Aktives Konto: ' + k.name);
},
k.active ? 'aktiv' : '');
b.title = k.active ? 'Wird beim nächsten Start verwendet' : 'Für den nächsten Start verwenden';
const img = b.querySelector('img');
kontoKopf(k.id).then((src) => { img.src = src; });
}
if (konten.length) menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
eintrag(`${svgIcon('plus')}<span class="konto-name">Konto hinzufügen…</span>`, () => openLogin({ addAccount: true }));
if (konten.some((k) => k.active)) eintrag(`${svgIcon('user')}<span class="konto-name">Skin ändern…</span>`, () => openSkin());
eintrag(`${svgIcon('users')}<span class="konto-name">Konten verwalten…</span>`, () => openLogin());
// unter dem Knopf, rechtsbündig – aber im Fenster bleiben
const r = el('btn-account').getBoundingClientRect();
menu.classList.remove('hidden');
const breite = menu.offsetWidth;
menu.style.left = Math.max(6, Math.min(r.right - breite, innerWidth - breite - 6)) + 'px';
menu.style.top = (r.bottom + 4) + 'px';
}
function formatVersionLabel(version) { function formatVersionLabel(version) {
return String(version || '') return String(version || '')
.replace(/^v/i, '') .replace(/^v/i, '')
@@ -1553,7 +1699,7 @@ async function applySkinBackground(uuidOverride) {
} else if (s.bgSkin) { } else if (s.bgSkin) {
uuid = s.bgSkin; uuid = s.bgSkin;
} }
if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; } if (!uuid) { beendeSkinLive(); root.style.setProperty('--bg-skin', 'none'); return; }
root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100)); root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100));
@@ -1564,7 +1710,14 @@ async function applySkinBackground(uuidOverride) {
// schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm) // schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm)
let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : ''; let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : '';
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } } if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell }); let umhang = null;
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
// 1a) bewegt (Atmen, Kopf folgt der Maus) – sonst wie bisher als Standbild
if (await zeigeLebendigeFigur(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang })) {
root.style.setProperty('--bg-skin', 'none');
return;
}
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; } if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
// 2) Rückfall: externe Render-Dienste // 2) Rückfall: externe Render-Dienste
@@ -1584,6 +1737,119 @@ async function applySkinBackground(uuidOverride) {
root.style.setProperty('--bg-skin', 'none'); root.style.setProperty('--bg-skin', 'none');
} }
// ---------- Lebendige Skin-Figur im Hintergrund ----------
// Statt eines starren Bildes läuft eine echte 3D-Figur in der gewählten Pose:
// sie atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem
// Mauszeiger. Pausiert, solange das Fenster minimiert/im Hintergrund ist.
let skinLive = null;
function skinLiveGroesse() {
const r = document.querySelector('.list-area').getBoundingClientRect();
// wie der frühere Hintergrund: 52 % Breite × 88 % Höhe, Seitenverhältnis 420:700
const w = Math.max(60, Math.min(r.width * 0.52, r.height * 0.88 * 0.6));
return { w: Math.round(w), h: Math.round(w / 0.6) };
}
function beendeSkinLive() {
if (!skinLive) return;
const { viewer, abmelden } = skinLive;
skinLive = null;
abmelden();
try { viewer.dispose(); } catch { /* egal */ }
const c = el('skin-live');
if (c) c.classList.add('hidden');
const flaeche = document.querySelector('.list-area');
if (flaeche) flaeche.classList.remove('skin-lebt');
}
async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
beendeSkinLive();
const canvas = el('skin-live');
if (POPOUT || !canvas || typeof skinview3d === 'undefined' || !bewegungErlaubt()) return false;
const { w, h } = skinLiveGroesse();
let viewer;
try {
viewer = new skinview3d.SkinViewer({ canvas, width: w, height: h, skin: quelle, model: opts.modell === 'slim' ? 'slim' : 'default' });
await new Promise((resolve, reject) => {
const t = setTimeout(reject, 8000);
const check = () => {
if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
else setTimeout(check, 60);
};
check();
});
} catch {
try { if (viewer) viewer.dispose(); } catch { /* egal */ }
return false;
}
const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
viewer.fov = pose.fov;
viewer.zoom = pose.zoom;
viewer.autoRotate = false;
viewer.controls.enabled = false;
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
// Grundhaltung merken: Pose einmal anwenden und Kopf/Umhang/Höhe festhalten
const p = viewer.playerObject;
pose.apply(p);
const kopfBasis = p.skin.head.rotation.clone();
const umhangBasis = p.cape ? p.cape.rotation.x : 0;
const hoeheBasis = p.position.y;
const koerperBasis = p.rotation.y;
const ziel = { yaw: 0, pitch: 0 };
const kopf = { yaw: 0, pitch: 0 };
const maus = (e) => {
const r = canvas.getBoundingClientRect();
const kx = r.left + r.width * 0.5;
const ky = r.top + r.height * 0.22; // Kopf sitzt im oberen Fünftel der Figur
ziel.yaw = Math.max(-0.75, Math.min(0.75, (e.clientX - kx) / 520));
ziel.pitch = Math.max(-0.4, Math.min(0.45, (e.clientY - ky) / 650));
};
const weg = () => { ziel.yaw = 0; ziel.pitch = 0; };
viewer.animation = new skinview3d.FunctionAnimation((pl, t) => {
pose.apply(pl); // Arme/Beine jedes Mal frisch aus der Pose
const atem = Math.sin(t * 1.8);
pl.position.y = hoeheBasis + atem * 0.18; // Brustkorb hebt und senkt sich
pl.rotation.y = koerperBasis + Math.sin(t * 0.55) * 0.05;
pl.skin.leftArm.rotation.z += atem * 0.03;
pl.skin.rightArm.rotation.z -= atem * 0.03;
if (pl.cape) pl.cape.rotation.x = umhangBasis + 0.06 + Math.sin(t * 1.3) * 0.05;
kopf.yaw += (ziel.yaw - kopf.yaw) * 0.07; // weich nachführen
kopf.pitch += (ziel.pitch - kopf.pitch) * 0.07;
// ohne Maus schaut die Figur halb zum Betrachter, sonst dem Zeiger hinterher
pl.skin.head.rotation.set(kopfBasis.x + kopf.pitch, kopfBasis.y - koerperBasis * 0.45 + kopf.yaw, kopfBasis.z);
});
const sicht = () => { viewer.renderPaused = document.hidden || !document.hasFocus(); };
const ro = new ResizeObserver(() => {
const g = skinLiveGroesse();
viewer.setSize(g.w, g.h);
});
ro.observe(document.querySelector('.list-area'));
window.addEventListener('mousemove', maus);
document.documentElement.addEventListener('mouseleave', weg);
document.addEventListener('visibilitychange', sicht);
window.addEventListener('blur', sicht);
window.addEventListener('focus', sicht);
sicht();
skinLive = {
viewer,
abmelden: () => {
ro.disconnect();
window.removeEventListener('mousemove', maus);
document.documentElement.removeEventListener('mouseleave', weg);
document.removeEventListener('visibilitychange', sicht);
window.removeEventListener('blur', sicht);
window.removeEventListener('focus', sicht);
},
};
canvas.classList.remove('hidden');
document.querySelector('.list-area').classList.add('skin-lebt');
return true;
}
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ---------- // ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
let skinCycleTimer = null; let skinCycleTimer = null;
let skinCycleIndex = 0; let skinCycleIndex = 0;
@@ -1666,6 +1932,15 @@ const SKIN_POSES = {
p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt
p.skin.rightLeg.rotation.set(-1.5, 0, -0.12); p.skin.rightLeg.rotation.set(-1.5, 0, -0.12);
} }, } },
kopf: { label: 'Büste', fov: 28, zoom: 2.15, apply: (p) => {
p.rotation.set(0.08, -0.5, 0);
p.position.y = -9.5; // Kopf in die Bildmitte
p.skin.head.rotation.set(0.05, 0.22, 0); // Blick leicht zum Betrachter
p.skin.leftArm.rotation.set(0, 0, 0.06);
p.skin.rightArm.rotation.set(0, 0, -0.06);
p.skin.leftLeg.rotation.set(0, 0, 0);
p.skin.rightLeg.rotation.set(0, 0, 0);
} },
spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => { spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => {
p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht
p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt
@@ -1683,7 +1958,15 @@ const SKIN_POSES = {
* die noch gar nicht hochgeladen ist. * die noch gar nicht hochgeladen ist.
* modell: 'default' (breite Arme) oder 'slim' (Alex) * modell: 'default' (breite Arme) oder 'slim' (Alex)
*/ */
async function renderSkin3D(quelle, poseKey, opts = {}) { let renderWarteschlange = Promise.resolve();
function renderSkin3D(quelle, poseKey, opts = {}) {
// nacheinander: alle Aufrufe zeichnen auf dieselbe Leinwand (#skin-canvas)
const auftrag = renderWarteschlange.then(() => renderSkin3DJetzt(quelle, poseKey, opts));
renderWarteschlange = auftrag.catch(() => null);
return auftrag;
}
async function renderSkin3DJetzt(quelle, poseKey, opts = {}) {
if (typeof skinview3d === 'undefined') return null; if (typeof skinview3d === 'undefined') return null;
const canvas = el('skin-canvas'); const canvas = el('skin-canvas');
const breite = opts.breite || 420; const breite = opts.breite || 420;
@@ -1711,6 +1994,7 @@ async function renderSkin3D(quelle, poseKey, opts = {}) {
viewer.autoRotate = false; viewer.autoRotate = false;
viewer.animation = null; viewer.animation = null;
pose.apply(viewer.playerObject); pose.apply(viewer.playerObject);
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
viewer.render(); viewer.render();
const data = canvas.toDataURL('image/png'); const data = canvas.toDataURL('image/png');
@@ -1740,6 +2024,7 @@ const ICONS_SVG = {
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>', export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>', trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>', chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4.2-4.2"/>',
}; };
function svgIcon(name) { function svgIcon(name) {
const p = ICONS_SVG[name]; const p = ICONS_SVG[name];
@@ -1844,7 +2129,7 @@ async function refreshSelectionPanel() {
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`; if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); } else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name; el('p-name').textContent = inst.name;
el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe'; el('p-groupline').textContent = inst.group ? `Gruppe: ${gruppenAnzeige(inst.group)}` : 'Ohne Gruppe';
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla' const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : ''; ? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
@@ -1909,6 +2194,19 @@ function buildCard(inst) {
return card; return card;
} }
// Suchfeld in der Werkzeugleiste: standardmäßig nur eine Lupe
function sucheOeffnen() {
el('search-wrap').classList.add('offen');
el('search').focus();
el('search').select();
}
function sucheSchliessen() {
el('search-wrap').classList.remove('offen');
if (document.activeElement === el('search')) el('search').blur();
}
let instanzenEingeblendet = false;
function render() { function render() {
const q = el('search').value.trim().toLowerCase(); const q = el('search').value.trim().toLowerCase();
const grid = el('grid'); const grid = el('grid');
@@ -1920,59 +2218,113 @@ function render() {
el('empty').classList.toggle('hidden', instances.length !== 0); el('empty').classList.toggle('hidden', instances.length !== 0);
grid.classList.toggle('hidden', instances.length === 0); grid.classList.toggle('hidden', instances.length === 0);
// nach Gruppen bündeln // nach Gruppen (und Untergruppen, "Modpacks/Survival") bündeln
const groups = new Map(); const baum = baueGruppenbaum(filtered);
for (const inst of filtered) { const showHeaders = baum.kinder.size > 0;
const g = (inst.group && inst.group.trim()) || ''; for (const kind of sortierteKinder(baum)) grid.appendChild(baueGruppenSektion(kind, 0, showHeaders));
if (!groups.has(g)) groups.set(g, []); // Instanzen ohne Gruppe zuletzt
groups.get(g).push(inst); if (baum.instanzen.length) {
grid.appendChild(baueGruppenSektion({ name: 'Ohne Gruppe', pfad: '', kinder: new Map(), instanzen: baum.instanzen }, 0, showHeaders));
} }
// benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt // Beim ersten Anzeigen kurz nacheinander einblenden – nicht nach der
const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))); // Startanimation (da ist der Fensteraufbau schon der Auftritt)
const showHeaders = names.some((n) => n !== ''); if (!instanzenEingeblendet && instances.length) {
instanzenEingeblendet = true;
for (const g of names) { if (bewegungErlaubt() && !MIT_STARTANIMATION && !POPOUT) {
const list = groups.get(g); grid.querySelectorAll('.group-header, .card').forEach((k, i) => k.style.setProperty('--i', String(Math.min(i, 24))));
const section = document.createElement('div'); grid.classList.add('einblenden');
section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : ''); setTimeout(() => grid.classList.remove('einblenden'), 1400);
if (showHeaders) {
const header = document.createElement('div');
header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : '');
header.innerHTML = `<span class="chev">${collapsedGroups.has(g) ? '▸' : '▾'}</span> ${escapeHtml(g || 'Ohne Gruppe')} <span class="group-count">(${list.length})</span>`;
header.addEventListener('click', () => {
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
render();
});
section.appendChild(header);
} }
const gridEl = document.createElement('div');
gridEl.className = 'group-grid';
for (const inst of list) gridEl.appendChild(buildCard(inst));
section.appendChild(gridEl);
// Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben
section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); });
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
section.addEventListener('drop', async (e) => {
e.preventDefault();
section.classList.remove('drop-hover');
const dropId = e.dataTransfer.getData('text/plain');
const dragged = instances.find((i) => i.id === dropId);
if (dragged && (dragged.group || '') !== g) {
await window.api.updateInstance(dropId, { group: g });
await reload();
toast('Verschoben nach: ' + (g || 'Ohne Gruppe'));
}
});
grid.appendChild(section);
} }
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`); status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
} }
// ---------- Gruppen und Untergruppen ----------
// Eine Untergruppe steckt im Gruppennamen, getrennt mit "/": "Modpacks/Survival".
// Ohne "/" bleibt alles wie bisher (eine Ebene).
function gruppenPfad(g) {
return String(g || '').split(/[\/\\]/).map((t) => t.trim()).filter(Boolean).join('/');
}
function gruppenAnzeige(g) {
return gruppenPfad(g).split('/').join(' › ');
}
function baueGruppenbaum(liste) {
const wurzel = { name: '', pfad: '', kinder: new Map(), instanzen: [] };
for (const inst of liste) {
let knoten = wurzel;
const pfad = gruppenPfad(inst.group);
if (pfad) {
for (const teil of pfad.split('/')) {
if (!knoten.kinder.has(teil)) {
knoten.kinder.set(teil, { name: teil, pfad: knoten.pfad ? knoten.pfad + '/' + teil : teil, kinder: new Map(), instanzen: [] });
}
knoten = knoten.kinder.get(teil);
}
}
knoten.instanzen.push(inst);
}
return wurzel;
}
const anzahlImZweig = (k) => k.instanzen.length + [...k.kinder.values()].reduce((s, c) => s + anzahlImZweig(c), 0);
const sortierteKinder = (k) => [...k.kinder.values()].sort((a, b) => a.name.localeCompare(b.name, 'de', { numeric: true }));
// Abschnitt für eine Gruppe samt Untergruppen (rekursiv)
function baueGruppenSektion(knoten, tiefe, showHeaders) {
const g = knoten.pfad;
const zu = collapsedGroups.has(g);
const section = document.createElement('div');
section.className = 'group-section' + (tiefe ? ' group-sub' : '') + (zu ? ' collapsed' : '');
if (showHeaders) {
const header = document.createElement('div');
header.className = 'group-header' + (zu ? ' collapsed' : '');
header.title = g ? gruppenAnzeige(g) : 'Instanzen ohne Gruppe';
header.innerHTML = `<span class="chev">${zu ? '▸' : '▾'}</span> ${escapeHtml(knoten.name)} <span class="group-count">(${anzahlImZweig(knoten)})</span>`;
header.addEventListener('click', () => {
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
render();
});
section.appendChild(header);
}
const inhalt = document.createElement('div');
inhalt.className = 'group-body';
if (knoten.instanzen.length) {
const gridEl = document.createElement('div');
gridEl.className = 'group-grid';
for (const inst of knoten.instanzen) gridEl.appendChild(buildCard(inst));
inhalt.appendChild(gridEl);
}
for (const kind of sortierteKinder(knoten)) inhalt.appendChild(baueGruppenSektion(kind, tiefe + 1, showHeaders));
section.appendChild(inhalt);
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
section.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
section.classList.remove('drop-hover');
const dropId = e.dataTransfer.getData('text/plain');
const dragged = instances.find((i) => i.id === dropId);
if (dragged && gruppenPfad(dragged.group) !== g) {
await window.api.updateInstance(dropId, { group: g });
await reload();
toast('Verschoben nach: ' + (g ? gruppenAnzeige(g) : 'Ohne Gruppe'));
}
});
return section;
}
function fillGroupDatalist() { function fillGroupDatalist() {
const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort(); // alle Gruppen samt ihrer übergeordneten Ebenen vorschlagen
const alle = new Set();
for (const i of instances) {
const teile = gruppenPfad(i.group).split('/').filter(Boolean);
for (let n = 1; n <= teile.length; n++) alle.add(teile.slice(0, n).join('/'));
}
const groups = [...alle].sort((a, b) => a.localeCompare(b, 'de', { numeric: true }));
el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).join(''); el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).join('');
} }
@@ -2231,7 +2583,7 @@ async function saveInstance() {
const payload = { const payload = {
name, name,
group: el('f-group').value.trim(), group: gruppenPfad(el('f-group').value),
icon: el('f-icon').value, icon: el('f-icon').value,
notes: el('f-notes').value.trim(), notes: el('f-notes').value.trim(),
minecraft: { minecraft: {
@@ -2607,7 +2959,7 @@ function schliesseSkinViewer(bildId) {
* Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht, * Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht,
* wird stattdessen ein Hinweistext gezeigt. * wird stattdessen ein Hinweistext gezeigt.
*/ */
async function zeigeSkinFigur(bildId, leerId, textur, leerText) { async function zeigeSkinFigur(bildId, leerId, textur, leerText, umhang) {
schliesseSkinViewer(bildId); schliesseSkinViewer(bildId);
const canvas = el(bildId); const canvas = el(bildId);
const leer = el(leerId); const leer = el(leerId);
@@ -2644,6 +2996,7 @@ async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
viewer.autoRotate = false; viewer.autoRotate = false;
viewer.animation = null; viewer.animation = null;
pose.apply(viewer.playerObject); pose.apply(viewer.playerObject);
if (umhang) { try { await viewer.loadCape(umhang); } catch { /* ohne Umhang weiter */ } }
viewer.controls.enableRotate = true; viewer.controls.enableRotate = true;
viewer.controls.enableZoom = true; viewer.controls.enableZoom = true;
viewer.controls.enablePan = false; viewer.controls.enablePan = false;
@@ -2679,14 +3032,78 @@ async function openSkin() {
// Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird // Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird
if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic'; if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic';
skinTexturAktuell = p.skinUrl || ''; skinTexturAktuell = p.skinUrl || '';
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
'Standard-Skin (keine eigene Datei hinterlegt)'); 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
// ---------- Umhänge im Skin-Fenster ----------
let skinUmhang = null; // data:-Adresse des angezeigten Umhangs (AeroMC vor offiziell)
async function ladeUmhangInfo() {
const sel = el('skin-cape-offiziell');
const info = await window.api.capeInfo();
sel.innerHTML = '<option value="">Kein Umhang</option>';
if (!info.ok) {
el('skin-cape-aero').textContent = info.message || '–';
el('skin-cape-remove').classList.add('hidden');
skinUmhang = null;
return;
}
for (const c of info.offiziell) {
const o = document.createElement('option');
o.value = c.id;
o.textContent = c.name;
if (c.aktiv) o.selected = true;
sel.appendChild(o);
}
sel.disabled = !info.offiziell.length;
sel.title = info.offiziell.length ? 'Nur Umhänge, die dein Konto bei Mojang besitzt' : 'Dein Konto besitzt keinen offiziellen Umhang';
if (info.aeromc) {
el('skin-cape-aero').textContent = `Hochgeladen (${info.aeromc.width}×${info.aeromc.height})`;
el('skin-cape-remove').classList.remove('hidden');
el('skin-cape-edit').textContent = 'Bearbeiten…';
} else {
el('skin-cape-aero').textContent = info.aeromcFehlt ? 'Server noch nicht bereit' : info.aeromcFehler ? 'Umhang-Server nicht erreichbar' : 'Noch keiner';
el('skin-cape-aero').title = info.aeromcFehler || '';
el('skin-cape-remove').classList.add('hidden');
el('skin-cape-edit').textContent = 'Gestalten…';
}
const konto = await window.api.authStatus();
const r = konto && konto.profile ? await window.api.capeForUuid(konto.profile.id) : null;
skinUmhang = r ? r.dataUri : null;
}
async function waehleOffiziellenUmhang() {
const sel = el('skin-cape-offiziell');
sel.disabled = true;
el('skin-cape-status').textContent = 'Umhang wird gesetzt …';
const res = await window.api.capeSetOfficial(sel.value || null);
sel.disabled = false;
if (res.ok) {
el('skin-cape-status').textContent = sel.value ? '✔ Offizieller Umhang gesetzt.' : '✔ Kein offizieller Umhang mehr.';
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
} else {
el('skin-cape-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
}
function entferneAeroUmhang() {
askConfirm('AeroMC-Umhang entfernen?', 'Dein eigener Umhang wird vom AeroMC-Umhang-Server gelöscht. Hast du ihn nicht als PNG gespeichert, ist er danach weg.', 'Entfernen', async () => {
const res = await window.api.capeRemove();
el('skin-cape-status').textContent = res.ok ? '✔ AeroMC-Umhang entfernt.' : '✖ ' + (res.message || 'Fehlgeschlagen.');
if (res.ok) {
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
});
} }
// Armform gewechselt -> beide Figuren neu zeichnen // Armform gewechselt -> beide Figuren neu zeichnen
async function skinVarianteGewechselt() { async function skinVarianteGewechselt() {
if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, ''); if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, '', skinUmhang);
if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, ''); if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, '', skinUmhang);
} }
async function waehleSkinDatei() { async function waehleSkinDatei() {
@@ -2697,7 +3114,7 @@ async function waehleSkinDatei() {
gewaehlterSkin = res.pfad; gewaehlterSkin = res.pfad;
el('skin-pfad').value = res.pfad; el('skin-pfad').value = res.pfad;
skinTexturNeu = res.dataUri || ''; skinTexturNeu = res.dataUri || '';
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar'); await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar', skinUmhang);
el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`; el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`;
el('skin-apply').disabled = false; el('skin-apply').disabled = false;
} }
@@ -4913,11 +5330,14 @@ async function openSettings(pane) {
sel.disabled = !!s.bgSkinCycle; sel.disabled = !!s.bgSkinCycle;
el('s-sortby').value = s.sortBy || 'lastPlayed'; el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false; el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-start-animation').checked = s.startAnimation !== false;
el('s-ui-animationen').checked = s.uiAnimationen !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false; el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false; el('s-notify').checked = s.desktopNotifications !== false;
el('s-friends').checked = s.friendsEnabled !== false; el('s-friends').checked = s.friendsEnabled !== false;
el('s-friends-server').checked = s.friendsShareServer !== false; el('s-friends-server').checked = s.friendsShareServer !== false;
el('s-auto-mod-updates').checked = !!s.autoModUpdates; el('s-auto-mod-updates').checked = !!s.autoModUpdates;
el('s-aeromc-capes').checked = s.aeromcCapes !== false;
el('s-autostart').checked = !!s.autostart; el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false; el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled; el('s-autobackup').checked = !!s.autoBackupEnabled;
@@ -5046,11 +5466,14 @@ async function saveSettings() {
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30), bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value, sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked, iso3dIcons: el('s-iso3d').checked,
startAnimation: el('s-start-animation').checked,
uiAnimationen: el('s-ui-animationen').checked,
bedrockEnabled: el('s-bedrock').checked, bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked, desktopNotifications: el('s-notify').checked,
friendsEnabled: el('s-friends').checked, friendsEnabled: el('s-friends').checked,
friendsShareServer: el('s-friends-server').checked, friendsShareServer: el('s-friends-server').checked,
autoModUpdates: el('s-auto-mod-updates').checked, autoModUpdates: el('s-auto-mod-updates').checked,
aeromcCapes: el('s-aeromc-capes').checked,
autostart: el('s-autostart').checked, autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked, autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked, autoBackupEnabled: el('s-autobackup').checked,
@@ -5084,6 +5507,7 @@ async function saveSettings() {
// Theme hier direkt anwenden (für dieses Fenster selbst); alles, was das // Theme hier direkt anwenden (für dieses Fenster selbst); alles, was das
// Hauptfenster betrifft (Bedrock-Sichtbarkeit, Skin-Hintergrund, // Hauptfenster betrifft (Bedrock-Sichtbarkeit, Skin-Hintergrund,
// Instanzliste), zieht das Hauptfenster über "settings:changed" selbst nach // Instanzliste), zieht das Hauptfenster über "settings:changed" selbst nach
setzeUiAnimationen(patch.uiAnimationen);
applyTheme(patch.theme); applyTheme(patch.theme);
iso3dEnabled = patch.iso3dIcons; iso3dEnabled = patch.iso3dIcons;
hide('modal-settings'); hide('modal-settings');
@@ -5342,6 +5766,22 @@ function promptGlobalRestore() {
} }
// ---------- Microsoft-Login / Kontoverwaltung ---------- // ---------- Microsoft-Login / Kontoverwaltung ----------
// Kopf/Büste eines Kontos selbst in 3D zeichnen – so steht die 2. Ebene
// (Mütze, Haare, Kragen, Ärmel) wie im Spiel plastisch ab. Fertige Bilder von
// crafthead zeigen sie nur flach aufgemalt; die dienen nur noch als Rückfall.
const kontoKoepfe = new Map(); // uuid -> data:-Bild
async function kontoKopf(uuid) {
if (kontoKoepfe.has(uuid)) return kontoKoepfe.get(uuid);
const auftrag = (async () => {
let modell = 'classic';
try { modell = await window.api.skinModelFor(uuid); } catch { /* breite Arme */ }
const bild = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, 'kopf', { modell, breite: 128, hoehe: 128 });
return bild || `https://crafthead.net/armor/bust/${uuid}?size=64`;
})();
kontoKoepfe.set(uuid, auftrag);
return auftrag;
}
async function refreshAccountButton() { async function refreshAccountButton() {
const acc = await window.api.authStatus(); const acc = await window.api.authStatus();
const av = el('account-av'); const av = el('account-av');
@@ -5349,7 +5789,7 @@ async function refreshAccountButton() {
av.innerHTML = ''; av.innerHTML = '';
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'acct-head'; img.className = 'acct-head';
img.src = `https://crafthead.net/bust/${acc.profile.id}?size=64`; img.src = await kontoKopf(acc.profile.id);
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); }); img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
av.appendChild(img); av.appendChild(img);
el('account-name').textContent = acc.profile.name; el('account-name').textContent = acc.profile.name;
@@ -5380,7 +5820,7 @@ async function renderAccounts(boxId = 'login-accounts') {
const av = document.createElement('img'); const av = document.createElement('img');
av.className = 'account-av'; av.className = 'account-av';
av.src = `https://crafthead.net/avatar/${a.id}?size=64`; av.src = await kontoKopf(a.id);
av.addEventListener('error', () => { av.addEventListener('error', () => {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'account-av'; d.textContent = '🎮'; d.className = 'account-av'; d.textContent = '🎮';
@@ -5527,6 +5967,18 @@ function wire() {
// ohne Wrapper käme das Klick-Ereignis als Bereichsname an // ohne Wrapper käme das Klick-Ereignis als Bereichsname an
el('btn-settings').addEventListener('click', () => openSettings()); el('btn-settings').addEventListener('click', () => openSettings());
el('search').addEventListener('input', render); el('search').addEventListener('input', render);
el('btn-search').addEventListener('click', () => sucheOeffnen());
el('search').addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
// Esc gehört hier der Suche, nicht dem globalen Dialog-Schließen
e.stopPropagation();
el('search').value = '';
render();
sucheSchliessen();
});
// leer verlassen = wieder zur Lupe; mit Suchbegriff bleibt das Feld offen,
// damit man sieht, dass die Liste gerade gefiltert ist
el('search').addEventListener('blur', () => { if (!el('search').value.trim()) sucheSchliessen(); });
el('btn-open-dir').addEventListener('click', () => { el('btn-open-dir').addEventListener('click', () => {
if (currentDetailId) window.api.openInstanceFolder(currentDetailId); if (currentDetailId) window.api.openInstanceFolder(currentDetailId);
@@ -5568,7 +6020,9 @@ function wire() {
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC // schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('#ctx-menu')) closeContextMenu(); if (!e.target.closest('#ctx-menu')) closeContextMenu();
if (!e.target.closest('#konto-menu') && !e.target.closest('#btn-account')) schliesseKontoMenue();
}); });
window.addEventListener('blur', schliesseKontoMenue);
document.addEventListener('contextmenu', (e) => { document.addEventListener('contextmenu', (e) => {
if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu(); if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu();
}); });
@@ -5677,6 +6131,12 @@ function wire() {
}); });
el('technic-list').addEventListener('change', updateTechnicButton); el('technic-list').addEventListener('change', updateTechnicButton);
el('technic-do').addEventListener('click', runTechnicImport); el('technic-do').addEventListener('click', runTechnicImport);
window.api.onImportFortschritt(zeigeImportFortschritt);
el('import-lauf-abbrechen').addEventListener('click', async () => {
el('import-lauf-abbrechen').disabled = true;
el('import-lauf-titel').textContent = 'Wird abgebrochen …';
await window.api.importAbbrechen();
});
window.api.onMcProgress((p) => { window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden'); el('mc-progress').classList.remove('hidden');
@@ -5925,6 +6385,9 @@ function wire() {
el('login-skin').addEventListener('click', openSkin); el('login-skin').addEventListener('click', openSkin);
el('skin-pick').addEventListener('click', waehleSkinDatei); el('skin-pick').addEventListener('click', waehleSkinDatei);
el('skin-variante').addEventListener('change', skinVarianteGewechselt); el('skin-variante').addEventListener('change', skinVarianteGewechselt);
el('skin-cape-offiziell').addEventListener('change', waehleOffiziellenUmhang);
el('skin-cape-edit').addEventListener('click', () => window.openSkinEditor({ modus: 'umhang', quelle: 'aktuell' }));
el('skin-cape-remove').addEventListener('click', entferneAeroUmhang);
el('skin-apply').addEventListener('click', setzeSkin); el('skin-apply').addEventListener('click', setzeSkin);
// gewählte Datei (sonst der aktuelle Skin des Kontos) im Editor öffnen // gewählte Datei (sonst der aktuelle Skin des Kontos) im Editor öffnen
el('skin-editor').addEventListener('click', () => window.openSkinEditor(gewaehlterSkin el('skin-editor').addEventListener('click', () => window.openSkinEditor(gewaehlterSkin
@@ -6115,7 +6578,7 @@ function wire() {
// Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an // Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an
document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => { document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => {
el('s-theme').value = b.dataset.design; el('s-theme').value = b.dataset.design;
applyTheme(b.dataset.design); designUeberblenden(b.dataset.design);
})); }));
el('s-bgskin-opacity').addEventListener('input', (e) => { el('s-bgskin-opacity').addEventListener('input', (e) => {
el('s-bgskin-val').textContent = e.target.value; el('s-bgskin-val').textContent = e.target.value;
@@ -6201,7 +6664,7 @@ function wire() {
} }
// Microsoft-Login / Kontoverwaltung // Microsoft-Login / Kontoverwaltung
el('btn-account').addEventListener('click', openLogin); el('btn-account').addEventListener('click', (e) => { e.stopPropagation(); oeffneKontoMenue(); });
el('login-add').addEventListener('click', addAccount); el('login-add').addEventListener('click', addAccount);
el('login-url').addEventListener('click', (e) => { el('login-url').addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
@@ -6258,6 +6721,7 @@ function wire() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; } if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
if (!el('konto-menu').classList.contains('hidden')) { schliesseKontoMenue(); return; }
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; } if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop(); const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open && open.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; } if (open && open.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; }
@@ -6274,8 +6738,7 @@ function wire() {
// der eine eigene Suche hat (Mods, Modpack-Katalog, Import …) // der eine eigene Suche hat (Mods, Modpack-Katalog, Import …)
if (strg && e.key.toLowerCase() === 'f' && !modalOffen) { if (strg && e.key.toLowerCase() === 'f' && !modalOffen) {
e.preventDefault(); e.preventDefault();
el('search').focus(); sucheOeffnen();
el('search').select();
return; return;
} }
// Strg+1 … Strg+5: Schnellstart-Instanz starten (nur im Hauptfenster) // Strg+1 … Strg+5: Schnellstart-Instanz starten (nur im Hauptfenster)
@@ -6437,6 +6900,7 @@ window.addEventListener('DOMContentLoaded', async () => {
applyIcons(); applyIcons();
try { try {
const s = await window.api.getSettings(); const s = await window.api.getSettings();
setzeUiAnimationen(s.uiAnimationen);
applyTheme(s.theme); applyTheme(s.theme);
iso3dEnabled = s.iso3dIcons !== false; iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3); consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
@@ -6519,7 +6983,8 @@ window.addEventListener('DOMContentLoaded', async () => {
// unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur // unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur
// im Hauptfenster sinnvoll // im Hauptfenster sinnvoll
window.api.onSettingsChanged((s) => { window.api.onSettingsChanged((s) => {
applyTheme(s.theme); setzeUiAnimationen(s.uiAnimationen);
designUeberblenden(s.theme);
iso3dEnabled = s.iso3dIcons !== false; iso3dEnabled = s.iso3dIcons !== false;
schnellstartBelegung = s.schnellstart || {}; schnellstartBelegung = s.schnellstart || {};
if (!POPOUT) { if (!POPOUT) {
@@ -6533,9 +6998,10 @@ window.addEventListener('DOMContentLoaded', async () => {
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im // Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an // Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
window.api.onSkinChanged((info) => { window.api.onSkinChanged((info) => {
if (!POPOUT) refreshSkinBackground(); kontoKoepfe.clear();
if (!POPOUT) { refreshSkinBackground(); refreshAccountButton(); }
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand // im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
if (POPOUT === 'skin' && info.quelle === 'editor' && !el('modal-skin').classList.contains('hidden')) openSkin(); if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
}); });
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); }); window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ } try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
+1532 -983
View File
File diff suppressed because it is too large Load Diff
+179 -121
View File
@@ -1,121 +1,179 @@
'use strict'; 'use strict';
/* /*
* skins.js – Skin und Umhang über die Minecraft-Dienste ändern * skins.js – Skin und Umhang über die Minecraft-Dienste ändern
* ------------------------------------------------------------- * -------------------------------------------------------------
* Der Launcher hält bereits gültige Zugangsdaten des angemeldeten Kontos. * Der Launcher hält bereits gültige Zugangsdaten des angemeldeten Kontos.
* Damit lässt sich der Skin direkt setzen, ohne Umweg über den Browser. * Damit lässt sich der Skin direkt setzen, ohne Umweg über den Browser.
* *
* Verwendete Wege (api.minecraftservices.com): * Verwendete Wege (api.minecraftservices.com):
* POST /minecraft/profile/skins Skin hochladen (Datei oder Adresse) * POST /minecraft/profile/skins Skin hochladen (Datei oder Adresse)
* DELETE /minecraft/profile/skins/active Skin zurücksetzen (Standard-Steve/Alex) * DELETE /minecraft/profile/skins/active Skin zurücksetzen (Standard-Steve/Alex)
* GET /minecraft/profile aktuelles Profil samt Skin-Adresse * GET /minecraft/profile aktuelles Profil samt Skin-Adresse
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const API = 'https://api.minecraftservices.com'; const API = 'https://api.minecraftservices.com';
const UA = 'AeroMC-Launcher'; const UA = 'AeroMC-Launcher';
// Minecraft nimmt nur PNG, und die Vorlagen sind 64x64 bzw. 64x32 // Minecraft nimmt nur PNG, und die Vorlagen sind 64x64 bzw. 64x32
const MAX_BYTES = 512 * 1024; const MAX_BYTES = 512 * 1024;
function pruefeDatei(dateiPfad) { function pruefeDatei(dateiPfad) {
if (!dateiPfad || !fs.existsSync(dateiPfad)) return { ok: false, message: 'Datei nicht gefunden.' }; if (!dateiPfad || !fs.existsSync(dateiPfad)) return { ok: false, message: 'Datei nicht gefunden.' };
if (!/\.png$/i.test(dateiPfad)) return { ok: false, message: 'Minecraft nimmt nur PNG-Dateien.' }; if (!/\.png$/i.test(dateiPfad)) return { ok: false, message: 'Minecraft nimmt nur PNG-Dateien.' };
const st = fs.statSync(dateiPfad); const st = fs.statSync(dateiPfad);
if (!st.isFile()) return { ok: false, message: 'Das ist keine Datei.' }; if (!st.isFile()) return { ok: false, message: 'Das ist keine Datei.' };
if (st.size > MAX_BYTES) return { ok: false, message: 'Die Datei ist zu groß (über 512 KB).' }; if (st.size > MAX_BYTES) return { ok: false, message: 'Die Datei ist zu groß (über 512 KB).' };
// PNG-Kennung und Maße aus dem Kopf lesen, bevor wir sie hochschicken // PNG-Kennung und Maße aus dem Kopf lesen, bevor wir sie hochschicken
const kopf = Buffer.alloc(33); const kopf = Buffer.alloc(33);
const fd = fs.openSync(dateiPfad, 'r'); const fd = fs.openSync(dateiPfad, 'r');
try { fs.readSync(fd, kopf, 0, 33, 0); } finally { fs.closeSync(fd); } try { fs.readSync(fd, kopf, 0, 33, 0); } finally { fs.closeSync(fd); }
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (!kopf.subarray(0, 8).equals(PNG)) return { ok: false, message: 'Die Datei ist kein gültiges PNG.' }; if (!kopf.subarray(0, 8).equals(PNG)) return { ok: false, message: 'Die Datei ist kein gültiges PNG.' };
const breite = kopf.readUInt32BE(16); const breite = kopf.readUInt32BE(16);
const hoehe = kopf.readUInt32BE(20); const hoehe = kopf.readUInt32BE(20);
// 64x64 ist heute üblich, 64x32 sind die alten Skins // 64x64 ist heute üblich, 64x32 sind die alten Skins
if (!(breite === 64 && (hoehe === 64 || hoehe === 32))) { if (!(breite === 64 && (hoehe === 64 || hoehe === 32))) {
return { ok: false, message: `Ein Skin muss 64×64 (oder 64×32) Pixel groß sein – diese Datei ist ${breite}×${hoehe}.` }; return { ok: false, message: `Ein Skin muss 64×64 (oder 64×32) Pixel groß sein – diese Datei ist ${breite}×${hoehe}.` };
} }
return { ok: true, breite, hoehe }; return { ok: true, breite, hoehe };
} }
function fehlerText(status) { function fehlerText(status) {
if (status === 401) return 'Die Anmeldung ist abgelaufen. Bitte das Konto neu verbinden.'; if (status === 401) return 'Die Anmeldung ist abgelaufen. Bitte das Konto neu verbinden.';
if (status === 403) return 'Minecraft lehnt die Änderung ab. Hat das Konto Minecraft Java Edition?'; if (status === 403) return 'Minecraft lehnt die Änderung ab. Hat das Konto Minecraft Java Edition?';
if (status === 429) return 'Zu viele Änderungen in kurzer Zeit. Bitte einen Moment warten.'; if (status === 429) return 'Zu viele Änderungen in kurzer Zeit. Bitte einen Moment warten.';
return 'Minecraft-Dienste antworten mit HTTP ' + status + '.'; return 'Minecraft-Dienste antworten mit HTTP ' + status + '.';
} }
/* /*
* Skin setzen. * Skin setzen.
* variante: 'classic' (breite Arme) oder 'slim' (schmale Arme, Alex) * variante: 'classic' (breite Arme) oder 'slim' (schmale Arme, Alex)
*/ */
async function setzeSkin(accessToken, dateiPfad, variante) { async function setzeSkin(accessToken, dateiPfad, variante) {
const geprueft = pruefeDatei(dateiPfad); const geprueft = pruefeDatei(dateiPfad);
if (!geprueft.ok) return geprueft; if (!geprueft.ok) return geprueft;
const form = new FormData(); const form = new FormData();
form.append('variant', variante === 'slim' ? 'slim' : 'classic'); form.append('variant', variante === 'slim' ? 'slim' : 'classic');
const daten = fs.readFileSync(dateiPfad); const daten = fs.readFileSync(dateiPfad);
form.append('file', new Blob([daten], { type: 'image/png' }), path.basename(dateiPfad)); form.append('file', new Blob([daten], { type: 'image/png' }), path.basename(dateiPfad));
const res = await fetch(API + '/minecraft/profile/skins', { const res = await fetch(API + '/minecraft/profile/skins', {
method: 'POST', method: 'POST',
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA }, headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
body: form, body: form,
}); });
if (!res.ok) return { ok: false, message: fehlerText(res.status) }; if (!res.ok) return { ok: false, message: fehlerText(res.status) };
let profil = null; let profil = null;
try { profil = await res.json(); } catch { /* Antwort ohne Rumpf ist auch in Ordnung */ } try { profil = await res.json(); } catch { /* Antwort ohne Rumpf ist auch in Ordnung */ }
return { ok: true, variante: variante === 'slim' ? 'slim' : 'classic', profil }; return { ok: true, variante: variante === 'slim' ? 'slim' : 'classic', profil };
} }
// Skin auf den Standard zurücksetzen // Skin auf den Standard zurücksetzen
async function setzeSkinZurueck(accessToken) { async function setzeSkinZurueck(accessToken) {
const res = await fetch(API + '/minecraft/profile/skins/active', { const res = await fetch(API + '/minecraft/profile/skins/active', {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA }, headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
}); });
if (!res.ok) return { ok: false, message: fehlerText(res.status) }; if (!res.ok) return { ok: false, message: fehlerText(res.status) };
return { ok: true }; return { ok: true };
} }
/* /*
* Aktuelles Profil abrufen – liefert die Adresse des aktiven Skins, * Aktuelles Profil abrufen – liefert die Adresse des aktiven Skins,
* damit die Oberfläche vorher/nachher zeigen kann. * damit die Oberfläche vorher/nachher zeigen kann.
*/ */
/* /*
* Mojang liefert Skin-Adressen bis heute als "http://textures.minecraft.net/…". * Mojang liefert Skin-Adressen bis heute als "http://textures.minecraft.net/…".
* Die Oberfläche lässt nach ihrer Sicherheitsrichtlinie nur https zu, das Bild * Die Oberfläche lässt nach ihrer Sicherheitsrichtlinie nur https zu, das Bild
* würde also blockiert. textures.minecraft.net beantwortet dieselbe Adresse * würde also blockiert. textures.minecraft.net beantwortet dieselbe Adresse
* auch über https – daher heben wir sie an. * auch über https – daher heben wir sie an.
*/ */
function aufHttps(url) { function aufHttps(url) {
const s = String(url || ''); const s = String(url || '');
return s.startsWith('http://') ? 'https://' + s.slice('http://'.length) : s; return s.startsWith('http://') ? 'https://' + s.slice('http://'.length) : s;
} }
async function holeProfil(accessToken) { async function holeProfil(accessToken) {
const res = await fetch(API + '/minecraft/profile', { const res = await fetch(API + '/minecraft/profile', {
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, Accept: 'application/json' }, headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, Accept: 'application/json' },
}); });
if (!res.ok) return { ok: false, message: fehlerText(res.status) }; if (!res.ok) return { ok: false, message: fehlerText(res.status) };
const daten = await res.json(); const daten = await res.json();
const aktiv = (daten.skins || []).find((s) => s.state === 'ACTIVE') || null; const aktiv = (daten.skins || []).find((s) => s.state === 'ACTIVE') || null;
return { return {
ok: true, ok: true,
name: daten.name, name: daten.name,
id: daten.id, id: daten.id,
skinUrl: aktiv ? aufHttps(aktiv.url) : '', skinUrl: aktiv ? aufHttps(aktiv.url) : '',
variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '', variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '',
}; // offizielle Umhänge, die dieses Konto besitzt (Migrator, MINECON …)
} capes: (daten.capes || []).map((c) => ({
id: c.id,
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps }; name: c.alias || 'Umhang',
url: aufHttps(c.url),
aktiv: c.state === 'ACTIVE',
})),
};
}
/*
* Offiziellen Umhang wählen (nur einer, den das Konto besitzt) bzw. mit
* capeId = null keinen tragen. Gilt im Spiel für alle Spieler.
*/
async function setzeUmhang(accessToken, capeId) {
const res = await fetch(API + '/minecraft/profile/capes/active', capeId
? {
method: 'PUT',
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, 'Content-Type': 'application/json' },
body: JSON.stringify({ capeId }),
}
: { method: 'DELETE', headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA } });
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
return { ok: true };
}
/*
* Texturen, die andere Spieler im Spiel sehen (öffentliches Profil beim
* Sitzungsserver). Das kann vom Kontoprofil oben abweichen: Dort liefert
* Mojang bei manchen Konten einen Standard-Skin (z. B. Efe) als "ACTIVE",
* obwohl im Spiel längst ein eigener Skin getragen wird.
*/
async function holeSpielTexturen(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, {
headers: { 'User-Agent': UA, Accept: 'application/json' },
});
if (!res.ok) return null;
const profil = await res.json();
const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures');
if (!eigenschaft) return null;
const texturen = JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')).textures || {};
const skin = texturen.SKIN;
return {
skinUrl: skin && skin.url ? aufHttps(skin.url) : '',
variante: skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic',
};
}
/*
* Welcher Skin wird als "aktuell" gezeigt? Grundsätzlich der aus dem Spiel –
* nur direkt nach einer eigenen Änderung das Kontoprofil, weil der
* Sitzungsserver dann noch kurz den alten Stand liefert.
*/
function waehleAnzeigeSkin(kontoProfil, spiel, kuerzlichGeaendert) {
if (!kontoProfil || !kontoProfil.ok || kuerzlichGeaendert) return kontoProfil;
if (!spiel || !spiel.skinUrl || spiel.skinUrl === kontoProfil.skinUrl) return kontoProfil;
return Object.assign({}, kontoProfil, { skinUrl: spiel.skinUrl, variante: spiel.variante, skinAusSpiel: true });
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin };
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self'" />
<title>AeroMC</title>
<style>
@font-face { font-family: 'AeroPixel'; src: url('assets/aeropixel.ttf'); }
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; user-select: none; cursor: default; }
body { font-family: 'Segoe UI', system-ui, sans-serif; }
/* Logo + Schriftzug in der Mitte */
.marke {
position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
transition: opacity .28s ease, transform .32s ease;
}
.buehne { perspective: 700px; animation: rein .45s cubic-bezier(.2, .9, .3, 1.2) both; }
.logo {
width: 132px; height: 132px; display: block;
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(90, 160, 255, .35));
animation: drehen 1.3s cubic-bezier(.45, .05, .25, 1) .15s infinite;
}
.name {
font-size: 34px; font-weight: 600; letter-spacing: .06em; color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .75), 0 0 2px rgba(0, 0, 0, .9);
animation: schrift .5s ease .25s both;
}
@keyframes rein { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
@keyframes drehen { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
@keyframes schrift { from { opacity: 0; transform: translateY(8px); letter-spacing: .3em; } to { opacity: 1; transform: none; letter-spacing: .06em; } }
/* Schriftzug je Design */
body[data-design="aero"] .name, body[data-design="lila"] .name {
background: linear-gradient(90deg, #7fe3ff, #6c9cff); -webkit-background-clip: text; background-clip: text; color: transparent;
text-shadow: none; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .8));
}
body[data-design="lila"] .name { background-image: linear-gradient(90deg, #e3b8ff, #9d7bff); }
body[data-design="lila"] .logo { filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(170, 120, 255, .4)); }
body[data-design="minecraft"] .name {
font-family: 'AeroPixel', 'Segoe UI', sans-serif; font-weight: 400; font-size: 30px; letter-spacing: .04em;
text-shadow: 3px 3px 0 #3f3f3f, 0 0 10px rgba(0, 0, 0, .8);
}
/* das Launcher-Fenster als Bild, wächst von der Mitte nach außen */
#fenster { position: fixed; inset: 0; width: 100%; height: 100%; visibility: hidden; }
body.aufbau .marke { opacity: 0; transform: scale(.82); }
</style>
</head>
<body>
<div class="marke">
<div class="buehne"><img class="logo" src="assets/logo.png" alt="" /></div>
<div class="name">AeroMC</div>
</div>
<img id="fenster" alt="" />
<script>
document.body.dataset.design = new URLSearchParams(location.search).get('design') || 'dark';
// vom Hauptprozess aufgerufen: Bild des fertigen Launchers von der Mitte aus aufziehen
window.aufbauen = (bildAdresse) => new Promise((fertig) => {
const bild = document.getElementById('fenster');
bild.onerror = () => fertig(false);
bild.onload = () => {
const v = Math.max(0, innerHeight / 2 - 66);
const h = Math.max(0, innerWidth / 2 - 66);
bild.style.visibility = 'visible';
document.body.classList.add('aufbau');
bild.animate([
{ clipPath: `inset(${v}px ${h}px round 30px)`, opacity: 0.2 },
{ opacity: 1, offset: 0.25 },
{ clipPath: 'inset(0px 0px round 0px)', opacity: 1 },
], { duration: 560, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' })
.finished.then(() => fertig(true), () => fertig(false));
};
bild.src = bildAdresse;
});
</script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
/*
* Startanimation des Hauptfensters (abschaltbar unter Optionen → Darstellung).
*
* Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo der
* Launcher erscheinen wird, und zeigt das sich drehende Logo samt Schriftzug.
* Ist die Oberfläche fertig (renderer:ready), wird das noch unsichtbare
* Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte
* nach außen aufgezogen. Am Ende übernimmt das echte Fenster an exakt
* derselben Stelle – dadurch wirkt es, als baue sich der Launcher auf.
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const MINDESTENS_DREHEN_MS = 1600; // so lange dreht sich das Logo mindestens
const HOECHSTENS_WARTEN_MS = 8000; // spätestens dann wird der Launcher gezeigt
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
// "renderer:ready" kommt, bevor Instanzliste und Kontoname gezeichnet sind –
// erst darauf warten, sonst wächst ein halbleeres Fenster auf
async function warteAufGezeichneteOberflaeche(wc) {
const pruefe = (ausdruck) => wc.executeJavaScript(ausdruck, true).catch(() => true);
// Liste gezeichnet – oder es gibt (noch) keine Instanzen, dann steht der Leer-Hinweis da
for (let i = 0; i < 30 && !(await pruefe("document.querySelectorAll('#grid > *').length > 0 || !document.getElementById('empty').classList.contains('hidden')")); i++) await warte(100);
for (let i = 0; i < 12 && !(await pruefe("(document.getElementById('account-name') || {}).textContent !== 'Konten'")); i++) await warte(100);
await warte(200);
}
async function spielen(hauptfenster, oberflaecheBereit, design) {
const b = hauptfenster.getContentBounds();
const start = new BrowserWindow({
x: b.x, y: b.y, width: b.width, height: b.height,
frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false,
skipTaskbar: true, hasShadow: false, focusable: false, show: false,
backgroundColor: '#00000000', title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
const launcherZeigen = () => {
if (hauptfenster.isDestroyed()) return;
hauptfenster.setOpacity(1);
hauptfenster.setIgnoreMouseEvents(false);
if (!hauptfenster.isVisible()) hauptfenster.show();
hauptfenster.focus();
};
try {
await start.loadFile(path.join(__dirname, 'splash.html'), { query: { design: design || 'dark' } });
start.showInactive();
const beginn = Date.now();
await Promise.race([oberflaecheBereit, warte(HOECHSTENS_WARTEN_MS)]);
const rest = MINDESTENS_DREHEN_MS - (Date.now() - beginn);
if (rest > 0) await warte(rest);
if (hauptfenster.isDestroyed()) return;
// Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
hauptfenster.setOpacity(0);
hauptfenster.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
hauptfenster.show();
await warteAufGezeichneteOberflaeche(hauptfenster.webContents);
if (hauptfenster.isDestroyed()) return;
const bild = await hauptfenster.webContents.capturePage();
if (!bild.isEmpty() && !start.isDestroyed()) {
// höchstens 2,5 s – das Hauptfenster darf nie unsichtbar hängen bleiben
await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(bild.toDataURL())})`), warte(2500)]);
}
} catch {
// Animation ist nur Zierde – im Fehlerfall einfach den Launcher zeigen
} finally {
launcherZeigen();
setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
}
}
module.exports = { spielen };
+42 -9
View File
@@ -78,7 +78,8 @@ const DEFAULT_SETTINGS = {
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
friendsEnabled: true, friendsEnabled: true,
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten") friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
autoModUpdates: false, // vor jedem Start Mods über Modrinth aktualisieren autoModUpdates: false,
aeromcCapes: true, // AeroMC-Umhänge im Spiel anzeigen (Umhang-Agent beim Start) // vor jedem Start Mods über Modrinth aktualisieren
schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … }) schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … })
autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund
@@ -87,6 +88,8 @@ const DEFAULT_SETTINGS = {
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist) bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
startAnimation: true, // drehendes Logo + Fensteraufbau beim Öffnen des Launchers
uiAnimationen: true, // bewegte Oberfläche: lebende Skin-Figur, aufwachsende Fenster, Einblenden, Überblenden
bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex) bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
@@ -423,6 +426,35 @@ function createInstance(data) {
return writeInstance(inst); return writeInstance(inst);
} }
/*
* Große Instanzordner nicht synchron löschen (das fror den Launcher bei vielen
* GB minutenlang ein): sofort in einen versteckten Papierkorb im Instanzordner
* verschieben – das ist nur eine Umbenennung – und dort im Hintergrund löschen.
* Reste (z. B. nach einem Absturz) räumt raeumePapierkorbAuf() beim Start weg.
*/
const PAPIERKORB = '.aeromc-papierkorb';
function entsorgeOrdner(p) {
if (!fs.existsSync(p)) return;
const korb = path.join(path.dirname(p), PAPIERKORB);
const ziel = path.join(korb, path.basename(p) + '-' + Date.now());
try {
ensureDir(korb);
fs.renameSync(p, ziel);
} catch {
// Umbenennen klappt nicht (z. B. Datei gerade geöffnet): instance.json sofort
// weg, damit die Instanz aus der Liste verschwindet – den Rest im Hintergrund
try { fs.rmSync(path.join(p, 'instance.json'), { force: true }); } catch { /* egal */ }
fs.promises.rm(p, { recursive: true, force: true }).catch(() => { /* bleibt liegen, stört nicht */ });
return;
}
fs.promises.rm(ziel, { recursive: true, force: true }).catch(() => { /* beim nächsten Start erneut */ });
}
function raeumePapierkorbAuf() {
const korb = path.join(getInstancesDir(), PAPIERKORB);
if (fs.existsSync(korb)) fs.promises.rm(korb, { recursive: true, force: true }).catch(() => {});
}
function createOrReplaceImportedInstance(data) { function createOrReplaceImportedInstance(data) {
const existing = findInstanceByName(data && data.name); const existing = findInstanceByName(data && data.name);
if (!existing) return createInstance(data); if (!existing) return createInstance(data);
@@ -434,8 +466,7 @@ function createOrReplaceImportedInstance(data) {
inst.totalPlaySeconds = existing.totalPlaySeconds || 0; inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
inst.launchCount = existing.launchCount || 0; inst.launchCount = existing.launchCount || 0;
const p = instancePath(existing.id); entsorgeOrdner(instancePath(existing.id));
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return writeInstance(inst); return writeInstance(inst);
} }
@@ -451,12 +482,11 @@ function updateInstance(id, patch) {
} }
function deleteInstance(id) { function deleteInstance(id) {
const p = instancePath(id); entsorgeOrdner(instancePath(id));
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return true; return true;
} }
function duplicateInstance(id) { function duplicateInstance(id, ohneSpieldaten) {
const src = getInstance(id); const src = getInstance(id);
if (!src) throw new Error('Instanz nicht gefunden: ' + id); if (!src) throw new Error('Instanz nicht gefunden: ' + id);
const copy = JSON.parse(JSON.stringify(src)); const copy = JSON.parse(JSON.stringify(src));
@@ -468,9 +498,11 @@ function duplicateInstance(id) {
copy.launchCount = 0; copy.launchCount = 0;
ensureDir(gameDir(copy.id)); ensureDir(gameDir(copy.id));
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer // Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
try { if (!ohneSpieldaten) {
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true }); try {
} catch { /* Quelle evtl. leer */ } fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
} catch { /* Quelle evtl. leer */ }
}
writeJson(instanceFile(copy.id), copy); writeJson(instanceFile(copy.id), copy);
return copy; return copy;
} }
@@ -499,5 +531,6 @@ module.exports = {
createOrReplaceImportedInstance, createOrReplaceImportedInstance,
updateInstance, updateInstance,
deleteInstance, deleteInstance,
raeumePapierkorbAuf,
duplicateInstance, duplicateInstance,
}; };
+55 -1
View File
@@ -65,7 +65,7 @@ body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:mi
:root[data-theme="dark"] .empty h2, :root[data-theme="dark"] .empty h2,
:root[data-theme="dark"] .mods-col h4, :root[data-theme="dark"] .mods-col h4,
:root[data-theme="dark"] .field>span{color:var(--text)} :root[data-theme="dark"] .field>span{color:var(--text)}
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc} :root[data-theme="dark"] #btn-console svg,:root[data-theme="dark"] #btn-search svg{color:#c8c8cc}
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc} :root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
:root[data-theme="dark"] .mods-filters select, :root[data-theme="dark"] .mods-filters select,
:root[data-theme="dark"] .mods-search select, :root[data-theme="dark"] .mods-search select,
@@ -103,6 +103,14 @@ body.is-popout>.titelleiste{display:none}
.tbgrow{flex:1} .tbgrow{flex:1}
.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px} .search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px}
.search-wrap input:focus{outline:none;border-color:var(--accent)} .search-wrap input:focus{outline:none;border-color:var(--accent)}
/* eingeklappt nur die Lupe, aufgeklappt nur das Feld */
.search-wrap{display:flex;align-items:center}
.search-wrap:not(.offen) input{display:none}
.search-wrap.offen .tb-lupe{display:none}
.search-wrap.offen input{animation:suche-auf .16s ease-out}
@keyframes suche-auf{from{width:0;opacity:0}}
.tbtn.tb-lupe{padding:6px 8px}
#btn-search svg{color:#5a6470}
/* ---------- Arbeitsbereich ---------- */ /* ---------- Arbeitsbereich ---------- */
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)} .workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)}
@@ -115,6 +123,20 @@ body.is-popout>.titelleiste{display:none}
opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0; opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0;
} }
.list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px} .list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px}
/* lebende Skin-Figur ersetzt das Standbild (gleiche Stelle und Größe) */
.skin-live{position:absolute;right:24px;bottom:8px;opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0}
.list-area.skin-lebt::after{display:none}
/* ---------- Bewegte Oberfläche (nur mit Klasse ui-animiert) ---------- */
/* Instanzen beim ersten Anzeigen nacheinander einblenden */
.ui-animiert .list.einblenden .card,
.ui-animiert .list.einblenden .group-header{animation:karte-rein .42s cubic-bezier(.2,.8,.2,1) both;animation-delay:calc(var(--i,0) * 28ms)}
@keyframes karte-rein{from{opacity:0;transform:translateY(12px) scale(.94)}}
/* Block-Symbol hebt sich beim Darüberfahren und dreht sich ein Stück */
.ui-animiert .card-icon,.ui-animiert .card-icon-img{transition:transform .22s cubic-bezier(.2,.8,.2,1),filter .22s}
.ui-animiert .card:hover .card-icon,.ui-animiert .card:hover .card-icon-img{transform:translateY(-4px) rotate(-7deg) scale(1.06);filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
/* Designwechsel weich überblenden */
::view-transition-old(root),::view-transition-new(root){animation-duration:.45s;animation-timing-function:ease}
.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px} .side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px}
.side-empty{text-align:center;color:var(--dim);padding:24px 8px} .side-empty{text-align:center;color:var(--dim);padding:24px 8px}
.side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px} .side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px}
@@ -139,6 +161,10 @@ body.is-popout>.titelleiste{display:none}
.group-count{color:var(--dim);font-weight:400} .group-count{color:var(--dim);font-weight:400}
.group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start} .group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start}
.group-section.collapsed .group-grid{display:none} .group-section.collapsed .group-grid{display:none}
/* Untergruppen: eingerückt, mit feiner Linie links */
.group-section.collapsed > .group-body{display:none}
.group-sub{margin-left:10px;padding-left:8px;border-left:1px solid var(--border)}
.group-sub > .group-header{font-size:11.5px;padding-top:4px}
.card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative} .card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative}
.card:hover{background:#e3ecf7} .card:hover{background:#e3ecf7}
.card.sel{background:var(--sel);border-color:var(--sel-br)} .card.sel{background:var(--sel);border-color:var(--sel-br)}
@@ -337,6 +363,11 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated} .skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab} .skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
.skin-bild canvas:active{cursor:grabbing} .skin-bild canvas:active{cursor:grabbing}
.popout-host .skin-bild{height:240px}
.skin-umhang{margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)}
.skin-umhang .field-row{margin-top:6px}
.skin-umhang .field{margin-bottom:6px}
.skin-umhang-aero{display:flex;align-items:center;gap:8px;min-height:30px;flex-wrap:wrap}
.crash-details{margin-top:12px} .crash-details{margin-top:12px}
.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)} .crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word} .crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
@@ -346,6 +377,16 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
/* Import */ /* Import */
.import-pane{min-height:180px} .import-pane{min-height:180px}
/* Fortschritt großer Importe (viele GB) */
.import-lauf{margin:0 0 12px;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--input,rgba(0,0,0,.15))}
.import-lauf-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
.import-lauf-kopf b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.import-balken{height:8px;border-radius:999px;background:rgba(127,127,127,.25);overflow:hidden}
.import-balken i{display:block;height:100%;width:0;border-radius:inherit;background:var(--accent);transition:width .25s ease}
.import-balken.unbestimmt i{width:30%;animation:import-schieben 1.2s ease-in-out infinite}
@keyframes import-schieben{0%{transform:translateX(-100%)}100%{transform:translateX(340%)}}
.import-lauf-zeile{margin-top:7px;font-size:12px}
.import-lauf-datei{margin-top:3px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.import-copy{margin:0 0 10px;font-size:13px;line-height:1.5} .import-copy{margin:0 0 10px;font-size:13px;line-height:1.5}
/* MultiMC-Import */ /* MultiMC-Import */
@@ -641,6 +682,16 @@ img.account-av{object-fit:cover;image-rendering:auto}
.ctx-strong{font-weight:600} .ctx-strong{font-weight:600}
.ctx-danger:hover{background:var(--danger);color:#fff} .ctx-danger:hover{background:var(--danger);color:#fff}
.ctx-sep{height:1px;background:var(--border);margin:4px 6px} .ctx-sep{height:1px;background:var(--border);margin:4px 6px}
/* Konto-Menü oben rechts (wie in MultiMC) */
.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
.konto-menu{min-width:220px}
.konto-eintrag{display:flex;align-items:center;gap:9px}
.konto-eintrag img{width:22px;height:24px;object-fit:contain;flex:none}
.konto-eintrag .konto-name{flex:1;overflow:hidden;text-overflow:ellipsis}
.konto-eintrag .konto-haken{width:14px;text-align:center;color:var(--accent);font-weight:700}
.konto-eintrag .konto-spielt{font-size:10px;color:var(--dim)}
.konto-eintrag.aktiv .konto-name{font-weight:600}
.konto-menu .ctx-item svg{width:16px;height:16px;flex:none;color:var(--dim)}
/* Toast */ /* Toast */
.toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px} .toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px}
@@ -702,5 +753,8 @@ img.account-av{object-fit:cover;image-rendering:auto}
.se-3d canvas:active{cursor:grabbing} .se-3d canvas:active{cursor:grabbing}
.se-2d[data-werkzeug="pipette"] canvas{cursor:copy} .se-2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-3d-leer{position:absolute} .se-3d-leer{position:absolute}
.se-ablage{display:none}
.se-u2d canvas{cursor:crosshair}
.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-fuss{align-items:center} .se-fuss{align-items:center}
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+183 -172
View File
@@ -1,172 +1,183 @@
'use strict'; 'use strict';
/* /*
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen * technicimport.js – Instanzen aus dem Technic Launcher übernehmen
* ------------------------------------------------------------------- * -------------------------------------------------------------------
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner * Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows: * unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner * %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten, * Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie * Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
* bei einem klassischen .minecraft. * bei einem klassischen .minecraft.
* *
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem * Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur * schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale, * über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den * fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien. * anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const forge = require('./forge'); const forge = require('./forge');
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim // Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen. // Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
const SKIP_DIRS = new Set(['bin']); const SKIP_DIRS = new Set(['bin']);
function technicRoot() { function technicRoot() {
return path.join(process.env.APPDATA || '', '.technic'); return path.join(process.env.APPDATA || '', '.technic');
} }
function readJson(file) { function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
catch { return null; } catch { return null; }
} }
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt // findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
// oder ein einzelner Pack-Ordner angegeben wurde // oder ein einzelner Pack-Ordner angegeben wurde
function resolveModpacksDir(inputPath) { function resolveModpacksDir(inputPath) {
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot(); const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
if (!fs.existsSync(root)) return null; if (!fs.existsSync(root)) return null;
const direct = path.join(root, 'modpacks'); const direct = path.join(root, 'modpacks');
if (fs.existsSync(direct)) return direct; if (fs.existsSync(direct)) return direct;
if (path.basename(root).toLowerCase() === 'modpacks') return root; if (path.basename(root).toLowerCase() === 'modpacks') return root;
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
} }
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister // ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
function looksLikePack(dir) { function looksLikePack(dir) {
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt']; const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
return markers.some((name) => fs.existsSync(path.join(dir, name))); return markers.some((name) => fs.existsSync(path.join(dir, name)));
} }
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel; // optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell // bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
// als stabile API dokumentiert ist // als stabile API dokumentiert ist
function readInstalledPacksMeta(root) { function readInstalledPacksMeta(root) {
const data = readJson(path.join(root, 'installedPacks')); const data = readJson(path.join(root, 'installedPacks'));
const packs = data && data.installedPacks; const packs = data && data.installedPacks;
return packs && typeof packs === 'object' ? packs : {}; return packs && typeof packs === 'object' ? packs : {};
} }
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version // versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein // -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach // bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
// ohne Version/Loader stehen und lässt sich manuell nachtragen. // ohne Version/Loader stehen und lässt sich manuell nachtragen.
function detectVersion(packDir) { function detectVersion(packDir) {
const bin = path.join(packDir, 'bin'); const bin = path.join(packDir, 'bin');
const jarFile = path.join(bin, 'modpack.jar'); const jarFile = path.join(bin, 'modpack.jar');
if (fs.existsSync(jarFile)) { if (fs.existsSync(jarFile)) {
try { try {
const buf = fs.readFileSync(jarFile); const buf = fs.readFileSync(jarFile);
const idx = forge.readZipIndex(buf); const idx = forge.readZipIndex(buf);
const entry = idx['version.json']; const entry = idx['version.json'];
if (entry) { if (entry) {
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse // manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, ''); const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
const profile = JSON.parse(text); const profile = JSON.parse(text);
const libs = Array.isArray(profile.libraries) ? profile.libraries : []; const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
let loader = 'vanilla'; let loader = 'vanilla';
let loaderVersion = ''; let loaderVersion = '';
for (const lib of libs) { for (const lib of libs) {
const name = String((lib && lib.name) || ''); const name = String((lib && lib.name) || '');
if (/net\.minecraftforge:forge:/i.test(name)) { if (/net\.minecraftforge:forge:/i.test(name)) {
loader = 'forge'; loader = 'forge';
loaderVersion = (name.split(':')[2] || '').split('-').pop() || ''; loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') { } else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
loader = 'neoforge'; loader = 'neoforge';
loaderVersion = name.split(':')[2] || ''; loaderVersion = name.split(':')[2] || '';
} }
} }
const id = String(profile.id || profile.inheritsFrom || ''); const id = String(profile.id || profile.inheritsFrom || '');
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim(); const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
if (version) return { version, loader, loaderVersion }; if (version) return { version, loader, loaderVersion };
} }
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ } } catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
} }
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version // ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
const legacy = path.join(bin, 'version'); const legacy = path.join(bin, 'version');
try { try {
if (fs.existsSync(legacy)) { if (fs.existsSync(legacy)) {
const version = fs.readFileSync(legacy, 'utf8').trim(); const version = fs.readFileSync(legacy, 'utf8').trim();
if (version) return { version, loader: 'vanilla', loaderVersion: '' }; if (version) return { version, loader: 'vanilla', loaderVersion: '' };
} }
} catch { /* egal, bleibt leer */ } } catch { /* egal, bleibt leer */ }
return { version: '', loader: 'vanilla', loaderVersion: '' }; return { version: '', loader: 'vanilla', loaderVersion: '' };
} }
function scan(inputPath) { function scan(inputPath) {
const modpacksDir = resolveModpacksDir(inputPath); const modpacksDir = resolveModpacksDir(inputPath);
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' }; if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks' const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
? path.dirname(modpacksDir) : modpacksDir; ? path.dirname(modpacksDir) : modpacksDir;
const meta = readInstalledPacksMeta(root); const meta = readInstalledPacksMeta(root);
const list = []; const list = [];
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) { for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const dir = path.join(modpacksDir, entry.name); const dir = path.join(modpacksDir, entry.name);
if (!looksLikePack(dir)) continue; if (!looksLikePack(dir)) continue;
const { version, loader, loaderVersion } = detectVersion(dir); const { version, loader, loaderVersion } = detectVersion(dir);
const info = meta[entry.name] || {}; const info = meta[entry.name] || {};
list.push({ list.push({
folder: entry.name, folder: entry.name,
dir, dir,
name: info.displayName || entry.name, name: info.displayName || entry.name,
group: '', group: '',
notes: '', notes: '',
version, version,
loader, loader,
loaderVersion, loaderVersion,
gameDir: dir, gameDir: dir,
hasGameData: true, hasGameData: true,
}); });
} }
list.sort((a, b) => a.name.localeCompare(b.name)); list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list }; return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
} }
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg // kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
function copyInto(sourceDir, targetDir) { function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of fs.readdirSync(sourceDir)) {
if (SKIP_DIRS.has(name)) continue; if (SKIP_DIRS.has(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
function importOne(store, entry, copyData = true) { /*
const created = store.createOrReplaceImportedInstance({ * Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
name: entry.name, * im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
group: entry.group, * asynchron mit Fortschritt (main.js → fuehreImportAus).
notes: entry.notes, */
minecraft: { function kopierQuellen(entry) {
version: entry.version, if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
loader: entry.loader, return fs.readdirSync(entry.gameDir).filter((name) => !SKIP_DIRS.has(name))
loaderVersion: entry.loaderVersion, .map((name) => ({ von: path.join(entry.gameDir, name), name }));
}, }
});
function importOne(store, entry, copyData = true) {
let copied = false; const created = store.createOrReplaceImportedInstance({
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { name: entry.name,
copyInto(entry.gameDir, store.gameDir(created.id)); group: entry.group,
copied = true; notes: entry.notes,
} minecraft: {
return { id: created.id, name: created.name, copied }; version: entry.version,
} loader: entry.loader,
loaderVersion: entry.loaderVersion,
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion }; },
});
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, kopierQuellen, resolveModpacksDir, technicRoot, detectVersion };
+180 -162
View File
@@ -1,162 +1,180 @@
<?php <?php
/** /**
* Plugin Name: AeroMC Freunde * Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort. * Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.4.0 * Version: 1.5.1
* Author: M_Viper * Author: M_Viper
*/ */
if (!defined('ABSPATH')) { if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei exit; // kein direkter Aufruf der Datei
} }
define('AEROMC_FRIENDS_VERSION', '1.4.0'); define('AEROMC_FRIENDS_VERSION', '1.5.1');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence'); define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends'); define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites'); define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions'); define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares'); define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages'); define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports'); define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben // Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
// (ältere Launcher-Versionen funktionieren weiter). Sobald alle Nutzer // nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
// aktualisiert haben, in der wp-config.php auf true setzen: // (ältere Launcher-Versionen funktionieren weiter). Sobald alle Nutzer
// define('AEROMC_FRIENDS_REQUIRE_AUTH', true); // aktualisiert haben, in der wp-config.php auf true setzen:
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) { // define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
define('AEROMC_FRIENDS_REQUIRE_AUTH', false); if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
} define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
}
/**
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den /**
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst). * Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
*/ * neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
function aeromc_friends_install() { */
global $wpdb; function aeromc_friends_install() {
$charset_collate = $wpdb->get_charset_collate(); global $wpdb;
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE; $charset_collate = $wpdb->get_charset_collate();
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE; $presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE; $friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
$sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE; $invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE; $sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE;
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
// in includes/routes.php) - vermeidet doppelte Zeilen durch // uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
// unterschiedliche Schreibweisen derselben UUID // in includes/routes.php) - vermeidet doppelte Zeilen durch
$sql_presence = "CREATE TABLE $presence_table ( // unterschiedliche Schreibweisen derselben UUID
uuid varchar(32) NOT NULL, $sql_presence = "CREATE TABLE $presence_table (
username varchar(32) NOT NULL, uuid varchar(32) NOT NULL,
instance_name varchar(128) DEFAULT NULL, username varchar(32) NOT NULL,
server_label varchar(128) DEFAULT NULL, instance_name varchar(128) DEFAULT NULL,
mc_version varchar(32) DEFAULT NULL, server_label varchar(128) DEFAULT NULL,
last_seen datetime NOT NULL, mc_version varchar(32) DEFAULT NULL,
PRIMARY KEY (uuid) last_seen datetime NOT NULL,
) $charset_collate;"; PRIMARY KEY (uuid)
dbDelta($sql_presence); ) $charset_collate;";
dbDelta($sql_presence);
$sql_friends = "CREATE TABLE $friends_table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT, $sql_friends = "CREATE TABLE $friends_table (
uuid_a varchar(32) NOT NULL, id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
uuid_b varchar(32) NOT NULL, uuid_a varchar(32) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending', uuid_b varchar(32) NOT NULL,
created_at datetime NOT NULL, status varchar(20) NOT NULL DEFAULT 'pending',
PRIMARY KEY (id), created_at datetime NOT NULL,
KEY uuid_a (uuid_a), PRIMARY KEY (id),
KEY uuid_b (uuid_b) KEY uuid_a (uuid_a),
) $charset_collate;"; KEY uuid_b (uuid_b)
dbDelta($sql_friends); ) $charset_collate;";
dbDelta($sql_friends);
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
$sql_invites = "CREATE TABLE $invites_table ( // Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
id bigint(20) unsigned NOT NULL AUTO_INCREMENT, $sql_invites = "CREATE TABLE $invites_table (
from_uuid varchar(32) NOT NULL, id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
to_uuid varchar(32) NOT NULL, from_uuid varchar(32) NOT NULL,
server varchar(128) NOT NULL, to_uuid varchar(32) NOT NULL,
mc_version varchar(32) DEFAULT NULL, server varchar(128) NOT NULL,
created_at datetime NOT NULL, mc_version varchar(32) DEFAULT NULL,
PRIMARY KEY (id), created_at datetime NOT NULL,
KEY to_uuid (to_uuid) PRIMARY KEY (id),
) $charset_collate;"; KEY to_uuid (to_uuid)
dbDelta($sql_invites); ) $charset_collate;";
dbDelta($sql_invites);
// Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
$sql_sessions = "CREATE TABLE $sessions_table ( // Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
token_hash char(64) NOT NULL, $sql_sessions = "CREATE TABLE $sessions_table (
uuid varchar(32) NOT NULL, token_hash char(64) NOT NULL,
username varchar(32) NOT NULL, uuid varchar(32) NOT NULL,
created_at datetime NOT NULL, username varchar(32) NOT NULL,
last_used datetime NOT NULL, created_at datetime NOT NULL,
PRIMARY KEY (token_hash), last_used datetime NOT NULL,
KEY uuid (uuid) PRIMARY KEY (token_hash),
) $charset_collate;"; KEY uuid (uuid)
dbDelta($sql_sessions); ) $charset_collate;";
dbDelta($sql_sessions);
// per Code geteilte Instanzen (Mod-Liste, keine Dateien)
$sql_shares = "CREATE TABLE $shares_table ( // per Code geteilte Instanzen (Mod-Liste, keine Dateien)
code varchar(9) NOT NULL, $sql_shares = "CREATE TABLE $shares_table (
uuid varchar(32) NOT NULL, code varchar(9) NOT NULL,
manifest longtext NOT NULL, uuid varchar(32) NOT NULL,
created_at datetime NOT NULL, manifest longtext NOT NULL,
PRIMARY KEY (code), created_at datetime NOT NULL,
KEY uuid (uuid) PRIMARY KEY (code),
) $charset_collate;"; KEY uuid (uuid)
dbDelta($sql_shares); ) $charset_collate;";
dbDelta($sql_shares);
// Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
$messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE; // Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
$sql_messages = "CREATE TABLE $messages_table ( $messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE;
id bigint(20) unsigned NOT NULL AUTO_INCREMENT, $sql_messages = "CREATE TABLE $messages_table (
from_uuid varchar(32) NOT NULL, id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
to_uuid varchar(32) NOT NULL, from_uuid varchar(32) NOT NULL,
text varchar(400) NOT NULL, to_uuid varchar(32) NOT NULL,
image_url varchar(255) DEFAULT NULL, text varchar(400) NOT NULL,
created_at datetime NOT NULL, image_url varchar(255) DEFAULT NULL,
read_at datetime DEFAULT NULL, created_at datetime NOT NULL,
PRIMARY KEY (id), read_at datetime DEFAULT NULL,
KEY to_uuid (to_uuid), PRIMARY KEY (id),
KEY from_uuid (from_uuid) KEY to_uuid (to_uuid),
) $charset_collate;"; KEY from_uuid (from_uuid)
dbDelta($sql_messages); ) $charset_collate;";
dbDelta($sql_messages);
// Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
$reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE; // Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
$sql_reports = "CREATE TABLE $reports_table ( $reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE;
id bigint(20) unsigned NOT NULL AUTO_INCREMENT, $sql_reports = "CREATE TABLE $reports_table (
created_at datetime NOT NULL, id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
uuid varchar(32) DEFAULT NULL, created_at datetime NOT NULL,
username varchar(32) DEFAULT NULL, uuid varchar(32) DEFAULT NULL,
launcher_version varchar(32) DEFAULT NULL, username varchar(32) DEFAULT NULL,
mc_version varchar(32) DEFAULT NULL, launcher_version varchar(32) DEFAULT NULL,
loader varchar(32) DEFAULT NULL, mc_version varchar(32) DEFAULT NULL,
loader_version varchar(64) DEFAULT NULL, loader varchar(32) DEFAULT NULL,
system varchar(128) DEFAULT NULL, loader_version varchar(64) DEFAULT NULL,
funde text, system varchar(128) DEFAULT NULL,
mods longtext, funde text,
bericht longtext, mods longtext,
kommentar text, bericht longtext,
status varchar(20) NOT NULL DEFAULT 'neu', kommentar text,
PRIMARY KEY (id), status varchar(20) NOT NULL DEFAULT 'neu',
KEY status (status) PRIMARY KEY (id),
) $charset_collate;"; KEY status (status)
dbDelta($sql_reports); ) $charset_collate;";
dbDelta($sql_reports);
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
} // eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
register_activation_hook(__FILE__, 'aeromc_friends_install'); $capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
$sql_capes = "CREATE TABLE $capes_table (
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der uuid varchar(32) NOT NULL,
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen, username varchar(32) NOT NULL,
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt. datei varchar(64) NOT NULL DEFAULT '',
add_action('plugins_loaded', function () { breite smallint(5) unsigned NOT NULL DEFAULT 0,
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) { hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
aeromc_friends_install(); hash varchar(40) NOT NULL DEFAULT '',
} status varchar(16) NOT NULL DEFAULT 'aktiv',
}); updated_at datetime NOT NULL,
PRIMARY KEY (uuid),
require_once plugin_dir_path(__FILE__) . 'includes/routes.php'; KEY status (status)
if (is_admin()) { ) $charset_collate;";
require_once plugin_dir_path(__FILE__) . 'includes/admin.php'; dbDelta($sql_capes);
}
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
}
register_activation_hook(__FILE__, 'aeromc_friends_install');
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
add_action('plugins_loaded', function () {
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
aeromc_friends_install();
}
});
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
if (is_admin()) {
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
}
@@ -0,0 +1,306 @@
<?php
/**
* AeroMC-Umhänge: eigene (auch HD-)Umhänge, die Spieler im Launcher gestalten.
*
* POST /aeromc/v1/cape { uuid, image } Umhang hochladen/ersetzen (Anmeldung Pflicht)
* POST /aeromc/v1/cape/remove { uuid } eigenen Umhang entfernen (Anmeldung Pflicht)
* GET /aeromc/v1/cape?uuid= Umhang eines Spielers (öffentlich)
* GET /aeromc/v1/capes?uuids=a,b,c mehrere auf einmal, für Launcher und Mod (öffentlich)
*
* Bilder werden streng geprüft (echtes PNG, Seitenverhältnis 2:1, 64×32 bis
* 1024×512) und – falls GD verfügbar ist – neu kodiert, damit keine fremden
* Daten im Bild mitreisen. Der Dateiname enthält einen Teil der Prüfsumme, so
* ändert sich die Adresse bei jedem neuen Umhang (kein veralteter Zwischenspeicher).
* Admins können Umhänge unter "AeroMC Fehler → Umhänge" löschen oder sperren.
*/
if (!defined('ABSPATH')) {
exit;
}
define('AEROMC_FRIENDS_CAPE_DIR', 'aeromc-umhaenge');
define('AEROMC_FRIENDS_CAPE_MAX_BYTES', 3000000);
define('AEROMC_FRIENDS_CAPE_MIN_GAP', 15); // Sekunden zwischen zwei Uploads
define('AEROMC_FRIENDS_CAPE_MAX_UUIDS', 100); // je Sammelabfrage
function aeromc_friends_capes_table() {
global $wpdb;
return $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
}
function aeromc_friends_cape_dir() {
$uploads = wp_upload_dir();
return [
'pfad' => trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_CAPE_DIR,
'url' => trailingslashit($uploads['baseurl']) . AEROMC_FRIENDS_CAPE_DIR,
];
}
/** Datei eines Umhangs löschen (nur Namen in unserem Schema). */
function aeromc_friends_cape_delete_file($datei) {
if (!$datei || !preg_match('/^[a-f0-9]{32}-[a-f0-9]{8}\.png$/', (string) $datei)) {
return;
}
$ort = aeromc_friends_cape_dir();
$voll = $ort['pfad'] . '/' . $datei;
if (file_exists($voll)) {
@unlink($voll);
}
}
/** Zeile -> öffentliche Angaben */
function aeromc_friends_cape_info($r) {
$ort = aeromc_friends_cape_dir();
return [
'url' => $ort['url'] . '/' . $r->datei,
'width' => (int) $r->breite,
'height' => (int) $r->hoehe,
'hash' => $r->hash,
'updated' => $r->updated_at,
];
}
/**
* Prüft ein Umhang-Bild und gibt [daten, breite, hoehe] zurück oder WP_Error.
*/
function aeromc_friends_cape_check_image($base64) {
$daten = base64_decode(preg_replace('/^data:image\/png;base64,/', '', (string) $base64), true);
if ($daten === false || strlen($daten) < 60) {
return new WP_Error('aeromc_invalid', 'Ungültiges Bild.', ['status' => 400]);
}
if (strlen($daten) > AEROMC_FRIENDS_CAPE_MAX_BYTES) {
return new WP_Error('aeromc_too_big', 'Der Umhang ist zu groß (Datei).', ['status' => 413]);
}
if (strncmp($daten, "\x89PNG\r\n\x1A\n", 8) !== 0 || substr($daten, 12, 4) !== 'IHDR') {
return new WP_Error('aeromc_invalid', 'Nur PNG-Bilder.', ['status' => 400]);
}
$kopf = unpack('Nbreite/Nhoehe', substr($daten, 16, 8));
$breite = (int) $kopf['breite'];
$hoehe = (int) $kopf['hoehe'];
if (!in_array($breite, [64, 128, 256, 512, 1024], true) || $breite !== 2 * $hoehe) {
return new WP_Error('aeromc_invalid', 'Ein Umhang muss 64×32, 128×64, 256×128, 512×256 oder 1024×512 Pixel groß sein.', ['status' => 400]);
}
// neu kodieren: entfernt alles außer den Bildpunkten
if (function_exists('imagecreatefromstring')) {
$bild = @imagecreatefromstring($daten);
if (!$bild || imagesx($bild) !== $breite || imagesy($bild) !== $hoehe) {
return new WP_Error('aeromc_invalid', 'Das Bild ist beschädigt.', ['status' => 400]);
}
imagealphablending($bild, false);
imagesavealpha($bild, true);
ob_start();
imagepng($bild, null, 9);
$daten = ob_get_clean();
imagedestroy($bild);
}
return [$daten, $breite, $hoehe];
}
add_action('rest_api_init', function () {
register_rest_route('aeromc/v1', '/cape', [
[
'methods' => 'POST',
'callback' => 'aeromc_friends_route_cape_upload',
'permission_callback' => '__return_true',
],
[
'methods' => 'GET',
'callback' => 'aeromc_friends_route_cape_get',
'permission_callback' => '__return_true',
],
]);
register_rest_route('aeromc/v1', '/cape/remove', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_cape_remove',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/capes', [
'methods' => 'GET',
'callback' => 'aeromc_friends_route_capes',
'permission_callback' => '__return_true',
]);
});
/** Anmeldung ist hier immer Pflicht – auch im Übergangsmodus (öffentlich sichtbare Bilder). */
function aeromc_friends_cape_auth($request, $uuid) {
$sitzung = aeromc_friends_check_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
if (!$sitzung) {
return new WP_Error('aeromc_auth', 'Anmeldung erforderlich.', ['status' => 401]);
}
return $sitzung;
}
function aeromc_friends_route_cape_upload($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$sitzung = aeromc_friends_cape_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
$table = aeromc_friends_capes_table();
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
if ($alt && $alt->status === 'gesperrt') {
return new WP_Error('aeromc_blocked', 'Dein Umhang wurde vom Betreiber gesperrt.', ['status' => 403]);
}
if ($alt && time() - strtotime($alt->updated_at . ' UTC') < AEROMC_FRIENDS_CAPE_MIN_GAP) {
return new WP_Error('aeromc_too_fast', 'Bitte einen Moment warten, bevor du den Umhang erneut hochlädst.', ['status' => 429]);
}
$geprueft = aeromc_friends_cape_check_image($request->get_param('image'));
if (is_wp_error($geprueft)) {
return $geprueft;
}
list($daten, $breite, $hoehe) = $geprueft;
$hash = sha1($daten);
$ort = aeromc_friends_cape_dir();
if (!wp_mkdir_p($ort['pfad'])) {
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
}
if (!file_exists($ort['pfad'] . '/index.html')) {
file_put_contents($ort['pfad'] . '/index.html', '');
}
$datei = $uuid . '-' . substr($hash, 0, 8) . '.png';
if (file_put_contents($ort['pfad'] . '/' . $datei, $daten) === false) {
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
}
if ($alt && $alt->datei !== $datei) {
aeromc_friends_cape_delete_file($alt->datei);
}
$zeile = [
'uuid' => $uuid,
'username' => $sitzung->username,
'datei' => $datei,
'breite' => $breite,
'hoehe' => $hoehe,
'hash' => $hash,
'status' => 'aktiv',
'updated_at' => current_time('mysql', true),
];
$wpdb->replace($table, $zeile);
return ['ok' => true, 'cape' => aeromc_friends_cape_info((object) $zeile)];
}
function aeromc_friends_route_cape_remove($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$sitzung = aeromc_friends_cape_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
$table = aeromc_friends_capes_table();
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
if ($alt) {
aeromc_friends_cape_delete_file($alt->datei);
if ($alt->status === 'gesperrt') {
// Sperre bleibt bestehen, nur das Bild ist weg
$wpdb->update($table, ['datei' => '', 'hash' => ''], ['uuid' => $uuid]);
} else {
$wpdb->delete($table, ['uuid' => $uuid]);
}
}
return ['ok' => true];
}
function aeromc_friends_route_cape_get($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$table = aeromc_friends_capes_table();
$r = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s AND status = 'aktiv'", $uuid));
return ['ok' => true, 'cape' => $r ? aeromc_friends_cape_info($r) : null];
}
function aeromc_friends_route_capes($request) {
global $wpdb;
$liste = array_filter(array_map('aeromc_friends_normalize_uuid', explode(',', (string) $request->get_param('uuids'))));
$liste = array_slice(array_values(array_unique($liste)), 0, AEROMC_FRIENDS_CAPE_MAX_UUIDS);
$ergebnis = [];
if ($liste) {
$table = aeromc_friends_capes_table();
$platz = implode(',', array_fill(0, count($liste), '%s'));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE status = 'aktiv' AND uuid IN ($platz)", $liste
));
foreach ($rows as $r) {
$ergebnis[$r->uuid] = aeromc_friends_cape_info($r);
}
}
$antwort = new WP_REST_Response(['ok' => true, 'capes' => (object) $ergebnis]);
// kurz zwischenspeichern lassen – der Mod fragt beim Betreten eines Servers viele auf einmal ab
$antwort->header('Cache-Control', 'public, max-age=60');
return $antwort;
}
// ---------- Admin: Umhänge prüfen, löschen, sperren ----------
if (is_admin()) {
add_action('admin_menu', function () {
add_submenu_page('aeromc-reports', 'AeroMC Umhänge', 'Umhänge', 'manage_options', 'aeromc-capes', 'aeromc_friends_capes_page');
}, 20);
}
function aeromc_friends_capes_page() {
global $wpdb;
if (!current_user_can('manage_options')) {
return;
}
$table = aeromc_friends_capes_table();
if (isset($_POST['aeromc_cape'], $_POST['aeromc_aktion']) && check_admin_referer('aeromc_capes')) {
$uuid = aeromc_friends_normalize_uuid($_POST['aeromc_cape']);
$r = $uuid ? $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid)) : null;
if ($r) {
$aktion = (string) $_POST['aeromc_aktion'];
if ($aktion === 'loeschen') {
aeromc_friends_cape_delete_file($r->datei);
$wpdb->delete($table, ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>Umhang von ' . esc_html($r->username) . ' gelöscht.</p></div>';
} elseif ($aktion === 'sperren') {
aeromc_friends_cape_delete_file($r->datei);
$wpdb->update($table, ['status' => 'gesperrt', 'datei' => '', 'hash' => ''], ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>' . esc_html($r->username) . ' gesperrt – der Umhang ist entfernt, neue Uploads werden abgelehnt.</p></div>';
} elseif ($aktion === 'entsperren') {
$wpdb->delete($table, ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>Sperre für ' . esc_html($r->username) . ' aufgehoben.</p></div>';
}
}
}
$ort = aeromc_friends_cape_dir();
$rows = $wpdb->get_results("SELECT * FROM $table ORDER BY updated_at DESC LIMIT 300");
echo '<div class="wrap"><h1>AeroMC Umhänge</h1>';
echo '<p>Eigene Umhänge, die Spieler im AeroMC-Skin-Editor hochgeladen haben. „Sperren“ entfernt den Umhang und verhindert neue Uploads dieses Spielers.</p>';
if (!$rows) {
echo '<p>Noch keine Umhänge hochgeladen.</p></div>';
return;
}
echo '<table class="widefat striped"><thead><tr><th style="width:150px">Umhang</th><th>Spieler</th><th>Größe</th><th>Geändert (UTC)</th><th>Status</th><th></th></tr></thead><tbody>';
foreach ($rows as $r) {
$bild = $r->datei
? '<img src="' . esc_url($ort['url'] . '/' . $r->datei) . '" alt="" style="width:128px;height:64px;image-rendering:pixelated;background:#ddd;border:1px solid #ccc" />'
: '–';
echo '<tr><td>' . $bild . '</td><td>' . esc_html($r->username) . '<br /><small>' . esc_html($r->uuid) . '</small></td>'
. '<td>' . esc_html($r->breite . '×' . $r->hoehe) . '</td><td>' . esc_html($r->updated_at) . '</td><td>' . esc_html($r->status) . '</td><td>';
echo '<form method="post" style="display:flex;gap:6px">';
wp_nonce_field('aeromc_capes');
echo '<input type="hidden" name="aeromc_cape" value="' . esc_attr($r->uuid) . '" />';
if ($r->status === 'gesperrt') {
echo '<button class="button" name="aeromc_aktion" value="entsperren">Sperre aufheben</button>';
} else {
echo '<button class="button" name="aeromc_aktion" value="loeschen" onclick="return confirm(\'Umhang löschen?\')">Löschen</button>';
echo '<button class="button button-link-delete" name="aeromc_aktion" value="sperren" onclick="return confirm(\'Spieler sperren? Der Umhang wird entfernt und neue Uploads abgelehnt.\')">Sperren</button>';
}
echo '</form></td></tr>';
}
echo '</tbody></table></div>';
}
File diff suppressed because it is too large Load Diff