Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de81575bcf | ||
|
|
76762ab567 | ||
|
|
6a0e92eb0f | ||
|
|
7e1fb616aa | ||
|
|
642d229d7e | ||
|
|
e782cc3058 | ||
|
|
972a3e73da | ||
|
|
e83c951256 | ||
|
|
67fe4cba48 |
+52
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Type, MinecraftProfileTexture> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <ziel.jar> <klassenordner> <asm.jar>
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,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": [
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"required": false,
|
||||||
|
"minVersion": "0.8",
|
||||||
|
"package": "de.aeromc.capes.mixin",
|
||||||
|
"compatibilityLevel": "JAVA_8",
|
||||||
|
"client": ["SessionServiceMixin", "SkinLaderMixin"],
|
||||||
|
"injectors": {
|
||||||
|
"defaultRequire": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
Generated
+4914
-4914
File diff suppressed because it is too large
Load Diff
+54
-54
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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.
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+350
-88
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+179
-121
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user