Compare commits

...
13 Commits
Author SHA1 Message Date
M_Viper 6ef51edede Upload via GUI (146 Dateien) 2026-10-05 17:20:31 +00:00
M_Viper 93c74f94a1 Upload via GUI (146 Dateien) 2026-10-05 17:20:30 +00:00
M_Viper 384354f44e Upload via GUI (146 Dateien) 2026-10-05 17:20:29 +00:00
M_Viper 9afe91a592 Upload via GUI (150 Dateien) 2026-10-05 17:20:04 +00:00
M_Viper de81575bcf Upload via GUI (143 Dateien) 2026-10-04 18:46:18 +00:00
M_Viper 76762ab567 Upload via GUI (143 Dateien) 2026-10-04 18:46:08 +00:00
M_Viper 6a0e92eb0f Upload via GUI (143 Dateien) 2026-10-04 18:46:00 +00:00
M_Viper 7e1fb616aa Upload via GUI (138 Dateien) 2026-10-04 14:49:45 +00:00
M_Viper 642d229d7e Upload via GUI (138 Dateien) 2026-10-04 14:49:44 +00:00
M_Viper e782cc3058 Upload via GUI (138 Dateien) 2026-10-04 14:49:43 +00:00
M_Viper 972a3e73da Upload via GUI (138 Dateien) 2026-10-03 14:20:38 +00:00
M_Viper e83c951256 Upload via GUI (138 Dateien) 2026-10-03 14:20:37 +00:00
M_Viper 67fe4cba48 Upload via GUI (138 Dateien) 2026-10-03 14:20:36 +00:00
54 changed files with 14306 additions and 9495 deletions
+84 -2
View File
@@ -2,7 +2,86 @@
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
## [1.1.6] - 2026-10-02 ## [1.2.1] - 2026-10-05
### Neu
- Garderobe im Skin-Fenster: Skins (samt Armform) und eigene AeroMC-Umhänge speichern und mit einem Klick wieder anziehen – vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch gesichert, denn Mojang merkt sich keine alten Skins
- Erfolge: 17 Erfolge (z. B. Spielzeit, Starts, Instanzen, Mods, Modpack, erster Freund, eigener Umhang, Skin aus dem Editor) mit „Erfolg erzielt!“-Meldung passend zum Design (im Minecraft-Design wie im Spiel); ein Klick auf die Meldung öffnet die Übersicht
- Freunde mit 3D-Figur: Neben der Freundesliste dreht sich groß die Figur des ausgewählten Freundes mit Skin, 2. Ebene und Umhang (online läuft sie, offline steht sie) – das Freunde-Fenster ist dafür breiter, schmal gezogen steht die Figur über der Liste; die Köpfe in der Liste zeigen ebenfalls die 2. Ebene
- Leistungs-Paket im Mod-Dialog: installiert mit einem Klick passende Performance-Mods – Sodium (bzw. Embeddium), Lithium (bzw. Radium), FerriteCore, Entity Culling, ImmediatelyFast, ModernFix und Dynamic FPS; Vorhandenes und für die Version nicht Verfügbares wird übersprungen, mit OptiFine bleibt Sodium weg
- Starten-Knopf mit Fortschritt: füllt sich während Download und Vorbereitung und zeigt die Phase (z. B. „Assets 57 %“), danach kurz „Läuft ✓“
- Mehr Leben in der Oberfläche (alles über „Bewegte Oberfläche“ abschaltbar):
- Laufende Instanzen haben einen pulsierenden Rand – man sieht sofort, was gerade gespielt wird
- Instanzen löschen, verschieben oder Gruppen auf-/zuklappen: Karten schrumpfen weg bzw. blenden ein, die übrigen rutschen weich nach
- Skin übernommen: Die Figur dreht sich einmal um sich selbst; nach einem Umhang-Wechsel weht der Umhang kurz auf
- Konto gewechselt: Der Spielerkopf oben rechts dreht sich wie eine Münze zum neuen Konto
- Erfolg erzielt: Funken um die Meldung, im Minecraft-Design grüne XP-Kugeln
- Freund kommt online: Sein Kopf hüpft in der Freundesliste, der grüne Punkt pulsiert
- AeroMC/Nachtlila: dezent treibende Lichtpunkte im Instanzbereich
- Minecraft-Design: Wolken ziehen durch die Werkzeugleiste, und beim Download zeigt das Block-Symbol der Instanz Abbau-Risse als Fortschritt
- Das Zahnrad dreht sich beim Darüberfahren, das Block-Symbol einer Instanz kippt leicht zur Maus
- Armform-Prüfung im Skin-Fenster: Passt die eingestellte Armform nicht zur Skin-Datei (gequetschte Arme oder dunkle Streifen), zeigt AeroMC einen Hinweis und stellt sie mit einem Klick richtig
- Absturz-Assistent erkennt zu alte Forge-Versionen, die mit aktuellem Java 8 abstürzen („ManifestEntryVerifier“), und aktualisiert Forge auf Wunsch auf die neueste Version für dieselbe Minecraft-Version
### Geändert
- Statistik-Fenster mit zwei Reitern: „Spielzeit“ und „Erfolge (x/17)“
- Update-Meldung zeigt die Änderungshinweise formatiert an (Überschriften, Listen, fett/kursiv, Code, Links) statt als rohen Markdown-Text; das Fenster ist dafür größer, Links öffnen im Browser
- Einstellungen aufgeräumt: jede Option als eigene Zeile mit kurzer Erklärung und Schalter statt Häkchen, alles einspaltig statt verstreuter Kacheln; neu sortiert in Allgemein, Darstellung, Spiel (inkl. Konsole), Java & Erweitert, Freunde & Umhänge (neu), Netzwerk, Konten, Backup (Welt-Sicherung jetzt bei „Automatisch sichern“)
### Behoben
- Alte Forge-Versionen vor Minecraft 1.13 (z. B. 1.12.2) starteten nicht („Hauptklasse net.minecraft.launchwrapper.Launch konnte nicht gefunden werden“) – Klassenpfad und Startargumente fehlten
- Bei Forge und NeoForge wurde ohne festgelegte Version teils die älteste statt der neuesten Version gewählt – die Versionsliste ist jetzt richtig sortiert
- Der Mod-Katalog zeigte bei von Hand kopierten oder importierten Mods „Installieren“, obwohl sie schon da waren – AeroMC erkennt sie jetzt per Prüfsumme über Modrinth
- Der ZIP-Import (AeroMC-Export oder gezippte MultiMC/Prism-Instanz) blockierte bei großen Archiven den Launcher – die Dateien werden jetzt im Hintergrund verschoben bzw. kopiert
## [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 +94,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
+42 -4
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung. Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
Aktueller Stand: Version 1.1.6 Aktueller Stand: Version 1.2.0
<img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" /> <img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" />
@@ -62,9 +62,13 @@ 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
- **Garderobe**: Skins und Umhänge speichern und mit einem Klick wieder anziehen – der bisherige Skin wird vor jedem Wechsel automatisch gesichert
- Armform-Prüfung: Hinweis und Ein-Klick-Korrektur, wenn die Armform nicht zur Skin-Datei passt
- 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 +134,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 +166,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 +199,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 +285,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 +341,15 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
### Darstellung ### Darstellung
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern - Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
- Block-Symbole räumlich (3D) - Block-Symbole räumlich (3D)
- Animationen:
- **Startanimation**: Beim Öffnen dreht sich das AeroMC-Logo, danach baut sich das Fenster von der Mitte aus auf (nicht beim Start im Infobereich)
- **Bewegte Oberfläche**: Die Skin-Figur im Hintergrund atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden beim Öffnen ein und heben sich beim Darüberfahren, Designs blenden weich über
- Beides einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig
- Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten - Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten
### Spiel ### Spiel
- Standard-RAM (min./max.) - Standard-RAM (min./max.)
- Umhänge: AeroMC-Umhänge im Spiel anzeigen (gilt ab dem nächsten Start)
- Fenstergröße bzw. maximiert starten - Fenstergröße bzw. maximiert starten
- Mods: vor jedem Start automatisch nach Updates suchen und installieren - Mods: vor jedem Start automatisch nach Updates suchen und installieren
+42
View File
@@ -0,0 +1,42 @@
package de.aeromc.capes;
import java.lang.instrument.Instrumentation;
/**
* AeroMC-Umhang-Agent.
*
* Wird vom Launcher per -javaagent an Minecraft angehängt (jede Version, jeder
* Loader). Er klinkt sich in Mojangs authlib ein – über sie lädt Minecraft für
* jeden Spieler Skin und Umhang – und setzt dort den AeroMC-Umhang des Spielers
* ein, falls er einen hat. Alles Weitere (Herunterladen, Zwischenspeichern,
* Anzeigen, auch HD) erledigt Minecraft selbst wie bei einem offiziellen Umhang.
*
* Argument: Basisadresse des Umhang-Servers (…/wp-json/aeromc/v1).
*/
public final class Agent {
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
public static final String SCHLUESSEL = "aeromc.capes.hook";
private Agent() {}
public static void premain(String argument, Instrumentation inst) {
starte(argument, inst);
}
public static void agentmain(String argument, Instrumentation inst) {
starte(argument, inst);
}
private static void starte(String argument, Instrumentation inst) {
try {
String basis = argument != null && argument.startsWith("http") ? argument : Nachschlagen.STANDARD_BASIS;
// Die eingefügten Befehle in authlib holen sich den Dienst von hier – nur
// über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt.
System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis));
inst.addTransformer(new Umbau());
System.out.println("[AeroMC] Umhang-Agent aktiv");
} catch (Throwable t) {
System.out.println("[AeroMC] Umhang-Agent konnte nicht starten: " + t);
}
}
}
+183
View File
@@ -0,0 +1,183 @@
package de.aeromc.capes;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Wird aus authlib heraus mit (ergebnis, argument) aufgerufen und gibt das
* Ergebnis zurück – mit eingesetztem AeroMC-Umhang, falls der Spieler einen hat.
* Arbeitet nur über Reflexion, damit es zu jeder authlib-Fassung passt, und
* fängt jeden Fehler ab: im schlimmsten Fall bleibt alles wie ohne Agent.
*/
public final class Nachschlagen implements BiFunction<Object, Object, Object> {
public static final String STANDARD_BASIS = "https://viper-network.de/wp-json/aeromc/v1";
private static final long GEFUNDEN_MS = 5 * 60 * 1000L;
private static final long KEINER_MS = 2 * 60 * 1000L;
private static final Pattern URL_MUSTER = Pattern.compile("\"url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
private static final Pattern ID_MUSTER = Pattern.compile("\"profileId\"\\s*:\\s*\"([0-9a-fA-F-]{32,36})\"");
private static final class Eintrag {
final String url; // null = kein Umhang
final long zeit;
Eintrag(String url) { this.url = url; this.zeit = System.currentTimeMillis(); }
boolean frisch() { return System.currentTimeMillis() - zeit < (url != null ? GEFUNDEN_MS : KEINER_MS); }
}
private final String basis;
private final Map<String, Eintrag> speicher = new ConcurrentHashMap<>();
private final Map<String, Boolean> unterwegs = new ConcurrentHashMap<>();
private final Map<String, Boolean> gemeldet = new ConcurrentHashMap<>();
public Nachschlagen(String basis) {
this.basis = basis.endsWith("/") ? basis.substring(0, basis.length() - 1) : basis;
}
@Override
public Object apply(Object ergebnis, Object argument) {
try {
String id = spielerId(argument);
if (id == null) return ergebnis;
String url = umhangUrl(id);
if (url == null) return ergebnis;
Object neu = mitUmhang(ergebnis, url, argument);
if (neu != ergebnis && gemeldet.putIfAbsent(id, Boolean.TRUE) == null) {
System.out.println("[AeroMC] Umhang fuer " + id + " eingesetzt");
}
return neu;
} catch (Throwable t) {
return ergebnis;
}
}
// ---------- Spieler erkennen ----------
private static String spielerId(Object argument) throws Exception {
if (argument == null) return null;
String klasse = argument.getClass().getName();
if (klasse.endsWith("GameProfile")) {
Object id = rufe(argument, "getId", "id");
return id instanceof UUID ? hex((UUID) id) : null;
}
if (klasse.endsWith("Property")) {
Object wert = rufe(argument, "getValue", "value");
if (!(wert instanceof String)) return null;
String json = new String(Base64.getDecoder().decode(((String) wert).trim()), StandardCharsets.UTF_8);
Matcher m = ID_MUSTER.matcher(json);
return m.find() ? m.group(1).replace("-", "").toLowerCase() : null;
}
return null;
}
private static String hex(UUID u) {
return u.toString().replace("-", "").toLowerCase();
}
private static Object rufe(Object ziel, String... namen) throws Exception {
for (String n : namen) {
try {
Method m = ziel.getClass().getMethod(n);
return m.invoke(ziel);
} catch (NoSuchMethodException ignoriert) {
// nächster Name
}
}
return null;
}
// ---------- Umhang-Server ----------
private String umhangUrl(String id) {
Eintrag e = speicher.get(id);
if (e != null && e.frisch()) return e.url;
// Auf dem Haupt-/Render-Thread nie warten: dort nur im Hintergrund holen;
// Minecraft fragt beim nächsten Laden des Spielers erneut
String thread = Thread.currentThread().getName();
boolean hauptThread = thread.equals("Render thread") || thread.equals("Client thread") || thread.equals("main");
if (hauptThread) {
if (unterwegs.putIfAbsent(id, Boolean.TRUE) == null) {
Thread t = new Thread(() -> { try { hole(id); } finally { unterwegs.remove(id); } }, "AeroMC-Umhang");
t.setDaemon(true);
t.start();
}
return e != null ? e.url : null;
}
return hole(id);
}
private String hole(String id) {
try {
HttpURLConnection c = (HttpURLConnection) new URL(basis + "/capes?uuids=" + id).openConnection();
c.setConnectTimeout(2000);
c.setReadTimeout(3000);
c.setRequestProperty("User-Agent", "AeroMC-Umhang/1.0");
c.setRequestProperty("Accept", "application/json");
if (c.getResponseCode() != 200) {
speicher.put(id, new Eintrag(null));
return null;
}
String json;
try (InputStream in = c.getInputStream()) {
ByteArrayOutputStream puffer = new ByteArrayOutputStream();
byte[] b = new byte[4096];
int n;
while ((n = in.read(b)) > 0 && puffer.size() < 65536) puffer.write(b, 0, n);
json = new String(puffer.toByteArray(), StandardCharsets.UTF_8);
}
String url = null;
Matcher m = URL_MUSTER.matcher(json);
if (json.contains("\"" + id + "\"") && m.find()) {
url = m.group(1).replace("\\/", "/");
if (!url.startsWith("https://")) url = null;
}
speicher.put(id, new Eintrag(url));
return url;
} catch (Throwable t) {
// Server nicht erreichbar: kurz merken, damit nicht jeder Spieler hängt
speicher.put(id, new Eintrag(null));
return null;
}
}
// ---------- Umhang einsetzen ----------
private static Object textur(ClassLoader cl, String url) throws Exception {
Class<?> k = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture", false, cl);
Constructor<?> ctor = k.getConstructor(String.class, Map.class);
return ctor.newInstance(url, null);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static Object mitUmhang(Object ergebnis, String url, Object argument) throws Exception {
ClassLoader cl = argument.getClass().getClassLoader();
if (ergebnis instanceof Map) {
// authlib 1–4: Map<MinecraftProfileTexture.Type, MinecraftProfileTexture>
Class<?> typ = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture$Type", false, cl);
Object cape = Enum.valueOf((Class<Enum>) typ, "CAPE");
Map neu = new HashMap((Map) ergebnis);
neu.put(cape, textur(cl, url));
return neu;
}
if (ergebnis == null) return null;
// authlib 5+: record MinecraftProfileTextures(skin, cape, elytra, signatureState)
Class<?> k = ergebnis.getClass();
Object skin = k.getMethod("skin").invoke(ergebnis);
Object elytra = k.getMethod("elytra").invoke(ergebnis);
Object signatur = k.getMethod("signatureState").invoke(ergebnis);
for (Constructor<?> ctor : k.getConstructors()) {
if (ctor.getParameterCount() == 4) {
return ctor.newInstance(skin, textur(cl, url), elytra, signatur);
}
}
return ergebnis;
}
}
+78
View File
@@ -0,0 +1,78 @@
package de.aeromc.capes;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
/**
* Baut YggdrasilMinecraftSessionService beim Laden um: vor jedem "return" der
* Textur-Methode wird das Ergebnis an den Nachschlage-Dienst gereicht, der den
* AeroMC-Umhang einsetzt.
*
* authlib 5+ (Minecraft 1.20.2 … 26.x):
* MinecraftProfileTextures unpackTextures(Property)
* authlib 1–4 (Minecraft 1.8 … 1.20.1):
* Map&lt;Type, MinecraftProfileTexture&gt; getTextures(GameProfile, boolean)
*
* Eingefügt wird nur geradliniger Code ohne Sprünge – die vorhandenen
* Stack-Map-Frames bleiben gültig, es muss nichts neu berechnet werden.
*/
final class Umbau implements ClassFileTransformer {
static final String ZIEL = "com/mojang/authlib/yggdrasil/YggdrasilMinecraftSessionService";
@Override
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
if (!ZIEL.equals(name) || umdefiniert != null) return null;
try {
ClassReader leser = new ClassReader(klasse);
ClassWriter schreiber = new ClassWriter(leser, ClassWriter.COMPUTE_MAXS);
final int[] umgebaut = {0};
leser.accept(new ClassVisitor(Opcodes.ASM9, schreiber) {
@Override
public MethodVisitor visitMethod(int zugriff, String mname, String desc, String sig, String[] ausn) {
MethodVisitor mv = super.visitMethod(zugriff, mname, desc, sig, ausn);
if ((zugriff & Opcodes.ACC_STATIC) != 0) return mv;
String rueckgabe = null;
if ("unpackTextures".equals(mname) && desc.startsWith("(Lcom/mojang/authlib/properties/Property;)L")) {
rueckgabe = desc.substring(desc.indexOf(")L") + 2, desc.length() - 1);
} else if ("getTextures".equals(mname) && desc.equals("(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;")) {
rueckgabe = "java/util/Map";
}
if (rueckgabe == null) return mv;
umgebaut[0]++;
final String typ = rueckgabe;
return new MethodVisitor(Opcodes.ASM9, mv) {
@Override
public void visitInsn(int op) {
if (op == Opcodes.ARETURN) {
// Stapel: [ergebnis] -> [dienst, ergebnis, argument] -> apply -> [neu]
super.visitVarInsn(Opcodes.ALOAD, 1);
super.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false);
super.visitLdcInsn(Agent.SCHLUESSEL);
super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false);
super.visitTypeInsn(Opcodes.CHECKCAST, "java/util/function/BiFunction");
super.visitInsn(Opcodes.DUP_X2);
super.visitInsn(Opcodes.POP);
super.visitMethodInsn(Opcodes.INVOKEINTERFACE, "java/util/function/BiFunction", "apply",
"(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", true);
super.visitTypeInsn(Opcodes.CHECKCAST, typ);
}
super.visitInsn(op);
}
};
}
}, 0);
if (umgebaut[0] == 0) return null;
System.out.println("[AeroMC] authlib fuer Umhaenge vorbereitet (" + umgebaut[0] + " Methode)");
return schreiber.toByteArray();
} catch (Throwable t) {
System.out.println("[AeroMC] authlib konnte nicht angepasst werden: " + t);
return null;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.Enumeration;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.commons.ClassRemapper;
import org.objectweb.asm.commons.Remapper;
/**
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
*
* Aufruf: java Shade &lt;ziel.jar&gt; &lt;klassenordner&gt; &lt;asm.jar&gt;
*/
public class Shade {
static final Remapper UMBENENNEN = new Remapper() {
@Override
public String map(String name) {
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
}
};
static byte[] umbenennen(byte[] klasse) {
ClassReader r = new ClassReader(klasse);
ClassWriter w = new ClassWriter(0);
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
return w.toByteArray();
}
static byte[] lies(InputStream in) throws Exception {
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] p = new byte[8192];
int n;
while ((n = in.read(p)) > 0) b.write(p, 0, n);
return b.toByteArray();
}
public static void main(String[] a) throws Exception {
Manifest mf = new Manifest();
Attributes at = mf.getMainAttributes();
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
at.putValue("Can-Retransform-Classes", "false");
at.putValue("Implementation-Title", "AeroMC Umhang-Agent");
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
// eigene Klassen
File basis = new File(a[1]);
for (File f : Files.walk(basis.toPath()).map(p -> p.toFile()).filter(f -> f.getName().endsWith(".class")).toArray(File[]::new)) {
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
out.putNextEntry(new JarEntry(name));
out.write(umbenennen(Files.readAllBytes(f.toPath())));
out.closeEntry();
}
// ASM (umbenannt), ohne Modul-Infos und Signaturen
try (JarFile asm = new JarFile(a[2])) {
Enumeration<JarEntry> e = asm.entries();
while (e.hasMoreElements()) {
JarEntry je = e.nextElement();
String n = je.getName();
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
out.write(neu);
out.closeEntry();
}
}
}
}
}
@@ -5,7 +5,7 @@
}, },
"projects": { "projects": {
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": { "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
"version": "1.1.6", "version": "1.2.1",
"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.1.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.6")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.1")]
[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.1.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 a17a2bc3b05635d3c29adc5c6884bde0a77826489d54b55a9b7744bfaa1ef206
+1 -1
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {} "C:\\Users\\Viper\\.nuget\\packages\\": {}
}, },
"project": { "project": {
"version": "1.1.6", "version": "1.2.1",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "lJiGEWCFg6M=", "dgSpecHash": "zBMXx+S6DFU=",
"success": true, "success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
+42
View File
@@ -0,0 +1,42 @@
# AeroMC Capes
Shows the **custom capes (including HD up to 1024×512)** of players who designed their cape in the
[AeroMC Launcher](https://aeromc.viper.ipv64.net) – right in the game, even if you use a different
launcher yourself.
## How it works
- Drop the mod into your `mods` folder – done. No configuration, no Fabric API required.
- Whenever Minecraft loads a player's skin, the mod asks the AeroMC server whether that player has
an AeroMC cape and shows it instead of the regular cape.
- If a player has no AeroMC cape, nothing changes (official Mojang cape or none).
- **Client-side only** – servers don't need anything.
## Compatibility
- **Fabric** and **Quilt**
- **One jar for all versions**: Minecraft 1.14 to 26.x
- Works with old Fabric Loader versions too (0.10 and newer)
- Players using the AeroMC Launcher don't need this mod (the launcher already does this on its
own) – but having it installed doesn't hurt either.
## Privacy
The mod only sends the (public) UUIDs of players whose skins are currently being loaded to
`viper-network.de` to look up their capes. Results are cached for a few minutes.
No account or login data is ever transmitted.
## Create your own cape
In the AeroMC Launcher: click your account name (top right) → **Skin ändern…** (Change skin) →
in the **Umhang** (Cape) section click **Gestalten…** (Design). You can paint your cape pixel by
pixel or insert any image, in sizes from 64×32 up to HD 1024×512.
---
### Hinweise zum Hochladen (nicht Teil der Beschreibung)
- Name: AeroMC Capes (URL bleibt `aeromc-umhaenge`)
- Kurzbeschreibung: Shows custom capes (HD up to 1024×512) made in the AeroMC Launcher – even if you use another launcher. One jar for 1.14–26.x, client-side.
- Datei: `dist/aeromc-umhaenge-fabric-1.0.0.jar`
- Loader: Fabric, Quilt · Umgebung: Client · Server: nicht nötig
- Spielversionen: alle Releases von 1.14 bis zur aktuellen
- Kategorien: Sozial (hervorgehoben), Werkzeug („Cosmetic“ gibt es für Mods nicht)
- Abhängigkeiten: keine
- Lizenz: aktuell „All Rights Reserved“ (in `fabric.mod.json`) – Modrinth akzeptiert das, eine
offene Lizenz (z. B. MIT) wirkt aber vertrauenswürdiger.
+10
View File
@@ -0,0 +1,10 @@
{
"required": false,
"minVersion": "0.8",
"package": "de.aeromc.capes.mixin",
"compatibilityLevel": "JAVA_8",
"client": ["SessionServiceMixin", "SkinLaderMixin"],
"injectors": {
"defaultRequire": 0
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"id": "aeromc-capes",
"version": "1.0.0",
"name": "AeroMC Umhänge",
"description": "Zeigt die eigenen Umhänge (auch HD) von AeroMC-Spielern im Spiel – gestaltet im AeroMC Launcher. Nur auf dem Client nötig. Funktioniert mit jeder Minecraft-Version, die Fabric unterstützt.",
"authors": ["M_Viper"],
"contact": {
"homepage": "https://aeromc.viper.ipv64.net"
},
"license": "LicenseRef-All-Rights-Reserved",
"icon": "assets/aeromc-capes/icon.png",
"environment": "client",
"mixins": ["aeromc-capes.mixins.json"],
"depends": {
"fabricloader": ">=0.10.0"
}
}
@@ -0,0 +1,48 @@
package de.aeromc.capes.mixin;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.Property;
import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService;
import de.aeromc.capes.mod.Umhaenge;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Hängt sich an die Textur-Methoden von authlib (beide Fassungen, siehe
* agent/…/Umbau.java). require = 0: Es greift immer nur die Methode, die es in
* der jeweiligen authlib-Fassung gibt, die andere wird still übersprungen.
* remap = false: authlib ist nicht verschleiert, Namen bleiben wie sie sind.
*/
@Mixin(value = YggdrasilMinecraftSessionService.class, remap = false)
public abstract class SessionServiceMixin {
// wird in den statischen Initialisierer von authlib übernommen: zeigt im Spielprotokoll, dass der Mod greift
static {
Umhaenge.authlibGreift = true;
Umhaenge.meldeAktiv("authlib");
}
// authlib 5+ (Minecraft 1.20.2 … 26.x)
@Inject(method = "unpackTextures", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
private void aeromc$neu(Property eigenschaft, CallbackInfoReturnable<Object> cir) {
aeromc$ersetze(cir, eigenschaft);
}
// authlib 1–4 (Minecraft 1.14 … 1.20.1)
@Inject(method = "getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
private void aeromc$alt(GameProfile profil, boolean sicher, CallbackInfoReturnable<Object> cir) {
aeromc$ersetze(cir, profil);
}
private static void aeromc$ersetze(CallbackInfoReturnable<Object> cir, Object argument) {
try {
Object alt = cir.getReturnValue();
Object neu = Umhaenge.mitUmhang(alt, argument);
if (neu != alt) cir.setReturnValue(neu);
} catch (Throwable ignoriert) {
// im Zweifel bleibt alles wie ohne Mod
}
}
}
@@ -0,0 +1,38 @@
package de.aeromc.capes.mixin;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.minecraft.MinecraftSessionService;
import de.aeromc.capes.mod.Umhaenge;
import net.minecraft.class_1071;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.util.Map;
/**
* Zweiter Weg für alte Fabric Loader (vor 0.12): Die lassen Mixins nur in
* Minecraft selbst zu, nicht in authlib – SessionServiceMixin greift dort also
* nie. Deshalb wird hier der Aufruf von getTextures im Skin-Lader von Minecraft
* (PlayerSkinProvider = class_1071, Intermediary-Name, gleich in 1.14 … 1.20.1)
* umgeleitet. Bei neuen Loadern erledigt das bereits SessionServiceMixin, dann
* reicht diese Umleitung das Ergebnis nur unverändert durch.
*/
@Mixin(value = class_1071.class, targets = "net.minecraft.class_1071$1", remap = false)
public abstract class SkinLaderMixin {
@Redirect(method = "*", at = @At(value = "INVOKE", remap = false,
target = "Lcom/mojang/authlib/minecraft/MinecraftSessionService;getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;"),
remap = false, require = 0)
private Map aeromc$texturen(MinecraftSessionService dienst, GameProfile profil, boolean sicher) {
Map texturen = dienst.getTextures(profil, sicher);
if (Umhaenge.authlibGreift) return texturen;
Umhaenge.meldeAktiv("Skin-Lader");
try {
Object neu = Umhaenge.mitUmhang(texturen, profil);
return neu instanceof Map ? (Map) neu : texturen;
} catch (Throwable ignoriert) {
return texturen;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package de.aeromc.capes.mod;
import de.aeromc.capes.Nachschlagen;
/**
* AeroMC Umhänge – Fabric/Quilt-Mod für Spieler, die NICHT den AeroMC Launcher
* nutzen. Er macht dasselbe wie der Umhang-Agent des Launchers: In Mojangs
* authlib wird der AeroMC-Umhang eines Spielers eingesetzt, sobald Minecraft
* dessen Skin lädt. Da nur authlib angepasst wird (nicht Minecraft selbst),
* passt ein und derselbe Mod zu jeder Minecraft-Version, die Fabric kennt.
* Alte Fabric Loader (vor 0.12) erlauben keine Mixins in authlib, dort greift
* stattdessen SkinLaderMixin im Skin-Lader von Minecraft.
*/
public final class Umhaenge {
private static final Nachschlagen DIENST = new Nachschlagen(Nachschlagen.STANDARD_BASIS);
/** true, sobald SessionServiceMixin in authlib eingebaut wurde (dann ist SkinLaderMixin überflüssig). */
public static volatile boolean authlibGreift;
private static volatile boolean gemeldet;
private Umhaenge() {}
/** Schreibt einmalig ins Spielprotokoll, dass (und über welchen Weg) der Mod greift. */
public static void meldeAktiv(String weg) {
if (gemeldet || agentAktiv()) return;
gemeldet = true;
System.out.println("[AeroMC] Umhang-Mod aktiv (" + weg + ")");
}
/** Läuft das Spiel über den AeroMC Launcher, setzt schon dessen Agent die Umhänge ein. */
public static boolean agentAktiv() {
return System.getProperties().containsKey("aeromc.capes.hook");
}
public static Object mitUmhang(Object ergebnis, Object argument) {
if (agentAktiv()) return ergebnis;
return DIENST.apply(ergebnis, argument);
}
}
+4
View File
@@ -0,0 +1,4 @@
package net.minecraft;
/** Nur zum Kompilieren: Platzhalter für Minecrafts PlayerSkinProvider (Intermediary-Name). Kommt nicht ins Mod-Jar. */
public class class_1071 {}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aeromc", "name": "aeromc",
"version": "1.1.6", "version": "1.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aeromc", "name": "aeromc",
"version": "1.1.6", "version": "1.2.1",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@xhayper/discord-rpc": "^1.3.4", "@xhayper/discord-rpc": "^1.3.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.1.6", "version": "1.2.1",
"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",
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/*
* build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent)
* ------------------------------------------------------------------------------
* Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
* Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab).
* Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version.
*
* Aufruf: node scripts/build-agent.js
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const WURZEL = path.join(__dirname, '..');
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
function findeJdk() {
const kandidaten = [];
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
if (fs.existsSync(adoptium)) {
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
}
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
return jdk;
}
function findeAsm(name) {
const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name);
if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`);
const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v))
.sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0));
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
return datei;
}
function quellen(ordner) {
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
const p = path.join(ordner, e.name);
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
});
}
const jdk = findeJdk();
const javac = path.join(jdk, 'bin', 'javac');
const java = path.join(jdk, 'bin', 'java');
const asm = findeAsm('asm');
const asmCommons = findeAsm('asm-commons');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
const klassen = path.join(tmp, 'klassen');
const werkzeug = path.join(tmp, 'werkzeug');
console.log('JDK:', jdk);
console.log('ASM:', asm);
try {
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen,
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' });
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
+83
View File
@@ -0,0 +1,83 @@
'use strict';
/*
* build-mod.js – baut den Fabric/Quilt-Mod "AeroMC Umhänge" (dist/aeromc-umhaenge-fabric-<version>.jar)
* -------------------------------------------------------------------------------------------------
* Für Spieler, die NICHT den AeroMC Launcher nutzen. Der Mod greift wie der
* Umhang-Agent nur in Mojangs authlib ein (Mixin, ohne Namensumsetzung) – darum
* braucht es weder Gradle noch Fabric Loom, und eine Datei passt zu allen
* Minecraft-Versionen, die Fabric unterstützt. Übersetzt wird für Java 8.
*
* Gebraucht werden (nur beim Bauen) ein JDK sowie sponge-mixin und authlib aus
* dem Bibliotheksordner von AeroMC. Der Nachschlage-Code ist derselbe wie im
* Agenten (agent/src/de/aeromc/capes/Nachschlagen.java).
*
* Aufruf: node scripts/build-mod.js
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const WURZEL = path.join(__dirname, '..');
const LIB = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries');
const modInfo = JSON.parse(fs.readFileSync(path.join(WURZEL, 'mod', 'resources', 'fabric.mod.json'), 'utf8'));
const ZIEL = path.join(WURZEL, 'dist', `aeromc-umhaenge-fabric-${modInfo.version}.jar`);
function findeJdk() {
const kandidaten = [];
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
if (fs.existsSync(adoptium)) {
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
}
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
return jdk;
}
// neueste (oder mit aelteste=true älteste) Fassung einer Bibliothek im AeroMC-Bibliotheksordner
function bibliothek(gruppe, name, aelteste = false) {
const basis = path.join(LIB, ...gruppe.split('.'), name);
if (!fs.existsSync(basis)) throw new Error(`${name} nicht gefunden unter ${basis}`);
const fassungen = fs.readdirSync(basis).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const v = aelteste ? fassungen[0] : fassungen.pop();
const datei = path.join(basis, v, `${name}-${v}.jar`);
if (!fs.existsSync(datei)) throw new Error('Datei fehlt: ' + datei);
return datei;
}
function quellen(ordner) {
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
const p = path.join(ordner, e.name);
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
});
}
// authlib in der ältesten Fassung: SkinLaderMixin ruft getTextures(GameProfile, boolean) auf, das es nur bis authlib 4 gibt
const jdk = findeJdk();
const klassenpfad = [bibliothek('net.fabricmc', 'sponge-mixin'), bibliothek('com.mojang', 'authlib', true)].join(path.delimiter);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-mod-'));
const inhalt = path.join(tmp, 'inhalt');
const stubs = path.join(tmp, 'stubs');
console.log('JDK:', jdk);
try {
// Platzhalter für Minecraft-Klassen (nur zum Kompilieren, landen nicht im Jar)
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-d', stubs,
...quellen(path.join(WURZEL, 'mod', 'stubs'))], { stdio: 'inherit' });
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-proc:none', '-cp', klassenpfad + path.delimiter + stubs, '-d', inhalt,
...quellen(path.join(WURZEL, 'mod', 'src')),
path.join(WURZEL, 'agent', 'src', 'de', 'aeromc', 'capes', 'Nachschlagen.java')], { stdio: 'inherit' });
for (const datei of fs.readdirSync(path.join(WURZEL, 'mod', 'resources'))) {
fs.copyFileSync(path.join(WURZEL, 'mod', 'resources', datei), path.join(inhalt, datei));
}
fs.mkdirSync(path.join(inhalt, 'assets', 'aeromc-capes'), { recursive: true });
fs.copyFileSync(path.join(WURZEL, 'src', 'assets', 'logo.png'), path.join(inhalt, 'assets', 'aeromc-capes', 'icon.png'));
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
if (fs.existsSync(ZIEL)) fs.rmSync(ZIEL);
execFileSync(path.join(jdk, 'bin', 'jar'), ['--create', '--file', ZIEL, '-C', inhalt, '.'], { stdio: 'inherit' });
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" />
<title>AeroMC</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
#bild {
position: absolute; display: none; transform-origin: 0 0; will-change: transform, opacity;
border-radius: 6px; box-shadow: 0 18px 48px rgba(0, 0, 0, .45);
}
</style>
</head>
<body>
<img id="bild" alt="" />
<script>
// Hilfsfenster für "Fenster wachsen aus dem Knopf" (siehe fensteraufbau.js) – wird nach einmaligem Gebrauch geschlossen
const bild = document.getElementById('bild');
let start = 1;
// Bild an die Zielstelle legen und auf Knopfgröße zusammenschrumpfen (noch unsichtbar)
window.vorbereiten = (adresse, nach, von) => new Promise((fertig) => {
bild.onerror = () => fertig(false);
bild.onload = () => {
Object.assign(bild.style, { left: nach.x + 'px', top: nach.y + 'px', width: nach.width + 'px', height: nach.height + 'px' });
// wächst aus der Mitte des angeklickten Knopfs heraus (darf außerhalb liegen)
bild.style.transformOrigin = `${von.x + von.width / 2 - nach.x}px ${von.y + von.height / 2 - nach.y}px`;
start = Math.max(0.06, Math.min(0.5, Math.max(von.width / nach.width, von.height / nach.height)));
bild.style.transform = `scale(${start})`;
bild.style.opacity = '0';
bild.style.display = 'block';
// zwei Bildtakte abwarten, damit der Anfangszustand gezeichnet ist (höchstens 120 ms)
let erledigt = false;
const fertigEinmal = () => { if (!erledigt) { erledigt = true; fertig(true); } };
requestAnimationFrame(() => requestAnimationFrame(fertigEinmal));
setTimeout(fertigEinmal, 120);
};
bild.src = adresse;
});
window.abspielen = (ms) => bild.animate([
{ transform: `scale(${start})`, opacity: 0 },
{ opacity: 1, offset: 0.3 },
{ transform: 'scale(1)', opacity: 1 },
], { duration: ms, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' }).finished.then(() => true, () => false);
</script>
</body>
</html>
+16 -3
View File
@@ -154,14 +154,27 @@ function scan(inputPath) {
return { ok: true, instancesDir, count: list.length, instances: list }; return { ok: true, instancesDir, count: list.length, instances: list };
} }
function kopierNamen(sourceDir) {
return fs.readdirSync(sourceDir).filter((name) => !META_FILES.includes(name));
}
function copyInto(sourceDir, targetDir) { function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of kopierNamen(sourceDir)) {
if (META_FILES.includes(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
@@ -193,4 +206,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, resolveInstancesDir }; module.exports = { scan, importOne, kopierQuellen, resolveInstancesDir };
+9
View File
@@ -155,6 +155,15 @@ function analysiere(text, ctx = {}) {
{ typ: 'java-auto' }); { typ: 'java-auto' });
} }
// --- Alte Forge-Version verträgt aktuelles Java 8 nicht ---
// Forge 1.16.5 vor 36.2.26 (und ähnlich alte Fassungen) nutzt eine interne
// Java-Klasse, die es seit Java 8u321 so nicht mehr gibt.
if (/sun\.security\.util\.ManifestEntryVerifier\.<init>/i.test(t) && /forge/i.test(String(ctx.loader || ''))) {
fund('forge-alt', 'Diese Forge-Version ist zu alt für das aktuelle Java',
'Ältere Forge-Versionen laufen mit aktuellem Java 8 nicht mehr (Fehler „ManifestEntryVerifier“). Eine neuere Forge-Version für dieselbe Minecraft-Version behebt das – Mods und Welten bleiben erhalten.',
{ typ: 'forge-aktualisieren' });
}
// --- Zu wenig Arbeitsspeicher --- // --- Zu wenig Arbeitsspeicher ---
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) { if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
const jetzt = Number(ctx.maxMemMb) || 2048; const jetzt = Number(ctx.maxMemMb) || 2048;
+43 -6
View File
@@ -9,8 +9,8 @@
/* ===================== Design-Auswahl in den Optionen ===================== */ /* ===================== Design-Auswahl in den Optionen ===================== */
.design-raster{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} .design-raster{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.settings-card-wide .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))} .settings-card .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
@media (max-width:900px){.settings-card-wide .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}} @media (max-width:900px){.settings-card .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
.design-wahl{display:flex;flex-direction:column;gap:5px;padding:6px;border:1px solid var(--border);border-radius:8px;background:none;color:var(--text);font-family:inherit;font-size:12px;cursor:pointer;text-align:left} .design-wahl{display:flex;flex-direction:column;gap:5px;padding:6px;border:1px solid var(--border);border-radius:8px;background:none;color:var(--text);font-family:inherit;font-size:12px;cursor:pointer;text-align:left}
.design-wahl:hover{border-color:var(--sel-br)} .design-wahl:hover{border-color:var(--sel-br)}
.design-wahl.on{border-color:var(--sel-br);box-shadow:0 0 0 1px var(--sel-br);background:var(--sel)} .design-wahl.on{border-color:var(--sel-br);box-shadow:0 0 0 1px var(--sel-br);background:var(--sel)}
@@ -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}
@@ -94,6 +94,10 @@
:root:is([data-design="aero"],[data-design="lila"]) .card.sel{background:linear-gradient(180deg,rgba(var(--p-akzent),.24),rgba(var(--p-tief),.14));border-color:rgba(var(--p-akzent2),.55);box-shadow:0 10px 28px -14px rgba(var(--p-akzent),.95),0 0 0 1px rgba(var(--p-hell),.08) inset} :root:is([data-design="aero"],[data-design="lila"]) .card.sel{background:linear-gradient(180deg,rgba(var(--p-akzent),.24),rgba(var(--p-tief),.14));border-color:rgba(var(--p-akzent2),.55);box-shadow:0 10px 28px -14px rgba(var(--p-akzent),.95),0 0 0 1px rgba(var(--p-hell),.08) inset}
:root:is([data-design="aero"],[data-design="lila"]) .card.sel .card-name{background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));color:#fff;border-radius:999px} :root:is([data-design="aero"],[data-design="lila"]) .card.sel .card-name{background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));color:#fff;border-radius:999px}
:root:is([data-design="aero"],[data-design="lila"]) .card-icon-img{border-radius:10px} :root:is([data-design="aero"],[data-design="lila"]) .card-icon-img{border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .card.laeuft::after{border-color:rgb(var(--p-akzent2));--accent:rgb(var(--p-akzent2))}
:root.ui-animiert:is([data-design="aero"],[data-design="lila"]) .lichtpunkte{display:block}
:root:is([data-design="aero"],[data-design="lila"]) .lichtpunkte i{background:rgb(var(--p-hell));box-shadow:0 0 calc(var(--g) * 3) 1px rgba(var(--p-akzent2),.85)}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg .funke{box-shadow:0 0 5px 2px rgb(var(--p-hell)),0 0 14px 4px rgba(var(--p-akzent2),.8)}
:root:is([data-design="aero"],[data-design="lila"]) .card-taste{border-radius:999px;background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)))} :root:is([data-design="aero"],[data-design="lila"]) .card-taste{border-radius:999px;background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)))}
:root:is([data-design="aero"],[data-design="lila"]) .empty h2{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));-webkit-background-clip:text;background-clip:text;color:transparent;font-size:26px} :root:is([data-design="aero"],[data-design="lila"]) .empty h2{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));-webkit-background-clip:text;background-clip:text;color:transparent;font-size:26px}
@@ -132,7 +136,7 @@
:root:is([data-design="aero"],[data-design="lila"]) .mods-filters select, :root:is([data-design="aero"],[data-design="lila"]) .mods-filters select,
:root:is([data-design="aero"],[data-design="lila"]) .mmc-bar select, :root:is([data-design="aero"],[data-design="lila"]) .mmc-bar select,
:root:is([data-design="aero"],[data-design="lila"]) .clip-length select, :root:is([data-design="aero"],[data-design="lila"]) .clip-length select,
:root:is([data-design="aero"],[data-design="lila"]) .path-row input, :root:is([data-design="aero"],[data-design="lila"]) .path-row input,:root:is([data-design="aero"],[data-design="lila"]) .path-row select,:root:is([data-design="aero"],[data-design="lila"]) select.s-wert,:root:is([data-design="aero"],[data-design="lila"]) input.s-wert,:root:is([data-design="aero"],[data-design="lila"]) .s-paar input,:root:is([data-design="aero"],[data-design="lila"]) .s-einheit input,
:root:is([data-design="aero"],[data-design="lila"]) .se-farbzeile input[type=text], :root:is([data-design="aero"],[data-design="lila"]) .se-farbzeile input[type=text],
:root:is([data-design="aero"],[data-design="lila"]) .se-feld select{background:var(--input);color:var(--text);border-color:var(--border);border-radius:10px} :root:is([data-design="aero"],[data-design="lila"]) .se-feld select{background:var(--input);color:var(--text);border-color:var(--border);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px} :root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px}
@@ -145,6 +149,9 @@
:root:is([data-design="aero"],[data-design="lila"]) input[type=checkbox],:root:is([data-design="aero"],[data-design="lila"]) input[type=radio],:root:is([data-design="aero"],[data-design="lila"]) input[type=range]{accent-color:rgb(var(--p-akzent))} :root:is([data-design="aero"],[data-design="lila"]) input[type=checkbox],:root:is([data-design="aero"],[data-design="lila"]) input[type=radio],:root:is([data-design="aero"],[data-design="lila"]) input[type=range]{accent-color:rgb(var(--p-akzent))}
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px} :root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px} :root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes li::marker{color:rgb(var(--p-akzent2))}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
/* Knöpfe – rund wie auf der Webseite */ /* Knöpfe – rund wie auf der Webseite */
:root:is([data-design="aero"],[data-design="lila"]) .btn{border-radius:999px;padding:7px 15px;background:var(--surface);border-color:var(--border);color:var(--text);transition:background .15s,border-color .15s,transform .15s} :root:is([data-design="aero"],[data-design="lila"]) .btn{border-radius:999px;padding:7px 15px;background:var(--surface);border-color:var(--border);color:var(--text);transition:background .15s,border-color .15s,transform .15s}
@@ -190,6 +197,7 @@
:root:is([data-design="aero"],[data-design="lila"]) .snav:hover{background:var(--surface)} :root:is([data-design="aero"],[data-design="lila"]) .snav:hover{background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .snav.on{background:linear-gradient(120deg,rgba(var(--p-akzent),.30),rgba(var(--p-tief),.18));border-color:rgba(var(--p-akzent2),.40);box-shadow:none} :root:is([data-design="aero"],[data-design="lila"]) .snav.on{background:linear-gradient(120deg,rgba(var(--p-akzent),.30),rgba(var(--p-tief),.18));border-color:rgba(var(--p-akzent2),.40);box-shadow:none}
:root:is([data-design="aero"],[data-design="lila"]) .settings-card{background:var(--surface);border-color:var(--border);border-radius:16px} :root:is([data-design="aero"],[data-design="lila"]) .settings-card{background:var(--surface);border-color:var(--border);border-radius:16px}
:root:is([data-design="aero"],[data-design="lila"]) .schalter input:checked+i{background:linear-gradient(120deg,rgb(var(--p-akzent)),rgb(var(--p-akzent2)));box-shadow:0 0 10px -2px rgba(var(--p-akzent),.8)}
:root:is([data-design="aero"],[data-design="lila"]) .settings-kv-row, :root:is([data-design="aero"],[data-design="lila"]) .settings-kv-row,
:root:is([data-design="aero"],[data-design="lila"]) .about-brand{border-bottom-color:var(--border2)} :root:is([data-design="aero"],[data-design="lila"]) .about-brand{border-bottom-color:var(--border2)}
:root:is([data-design="aero"],[data-design="lila"]) .appinfo-item{background:rgba(var(--p-linie),.04);border-color:var(--border2);border-radius:12px} :root:is([data-design="aero"],[data-design="lila"]) .appinfo-item{background:rgba(var(--p-linie),.04);border-color:var(--border2);border-radius:12px}
@@ -197,6 +205,12 @@
:root:is([data-design="aero"],[data-design="lila"]) .settings-hero h4{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 70%);-webkit-background-clip:text;background-clip:text;color:transparent} :root:is([data-design="aero"],[data-design="lila"]) .settings-hero h4{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 70%);-webkit-background-clip:text;background-clip:text;color:transparent}
/* Kontextmenü, Hinweis, Bildlaufleisten */ /* Kontextmenü, Hinweis, Bildlaufleisten */
/* Erfolg-Meldung: Glas-Karte mit Leuchtrand wie die Webseite */
:root:is([data-design="aero"],[data-design="lila"]) .erfolg{background:linear-gradient(135deg,rgba(var(--p-akzent),.22),var(--surface) 55%);border:1px solid rgba(var(--p-akzent),.45);border-radius:14px;
box-shadow:0 0 0 1px rgba(255,255,255,.04) inset,0 18px 40px -12px rgba(0,0,0,.8),0 0 24px -8px rgba(var(--p-akzent),.7)}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-symbol{border-radius:10px;background:rgba(var(--p-akzent),.18)}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-kopf{color:rgb(var(--p-hell))}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-karte{border-radius:12px;background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .ctx{background:var(--surface);border-color:var(--border);border-radius:12px;box-shadow:0 18px 40px -12px rgba(0,0,0,.8)} :root:is([data-design="aero"],[data-design="lila"]) .ctx{background:var(--surface);border-color:var(--border);border-radius:12px;box-shadow:0 18px 40px -12px rgba(0,0,0,.8)}
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item{border-radius:8px} :root:is([data-design="aero"],[data-design="lila"]) .ctx-item{border-radius:8px}
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)} :root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)}
@@ -267,10 +281,14 @@
:root[data-design="minecraft"] .toolbar{ :root[data-design="minecraft"] .toolbar{
background:url(assets/design/gras.png) 0 0/32px 8px repeat-x,url(assets/design/schiefer.png) 0 0/64px 64px; background:url(assets/design/gras.png) 0 0/32px 8px repeat-x,url(assets/design/schiefer.png) 0 0/64px 64px;
image-rendering:pixelated;border-bottom:2px solid #000;padding:11px 8px 6px} image-rendering:pixelated;border-bottom:2px solid #000;padding:11px 8px 6px}
:root[data-design="minecraft"] .toolbar{position:relative;isolation:isolate}
:root.ui-animiert[data-design="minecraft"] .toolbar::before{content:'';position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:.18;
background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='512' height='48' shape-rendering='crispEdges' fill='white'><rect x='20' y='8' width='56' height='8'/><rect x='8' y='16' width='96' height='8'/><rect x='170' y='22' width='40' height='6'/><rect x='158' y='28' width='80' height='8'/><rect x='184' y='36' width='36' height='4'/><rect x='300' y='4' width='72' height='8'/><rect x='288' y='12' width='120' height='8'/><rect x='312' y='20' width='64' height='6'/><rect x='438' y='26' width='48' height='8'/><rect x='426' y='34' width='70' height='6'/></svg>") 0 0/512px 100% repeat-x;animation:wolken-ziehen 70s steps(512) infinite}
@keyframes wolken-ziehen{to{background-position:512px 0}}
: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}
@@ -288,7 +306,7 @@
:root[data-design="minecraft"] .mods-filters select, :root[data-design="minecraft"] .mods-filters select,
:root[data-design="minecraft"] .mmc-bar select, :root[data-design="minecraft"] .mmc-bar select,
:root[data-design="minecraft"] .clip-length select, :root[data-design="minecraft"] .clip-length select,
:root[data-design="minecraft"] .path-row input, :root[data-design="minecraft"] .path-row input,:root[data-design="minecraft"] .path-row select,:root[data-design="minecraft"] select.s-wert,:root[data-design="minecraft"] input.s-wert,:root[data-design="minecraft"] .s-paar input,:root[data-design="minecraft"] .s-einheit input,
:root[data-design="minecraft"] .login-usercode, :root[data-design="minecraft"] .login-usercode,
:root[data-design="minecraft"] .upd-notes, :root[data-design="minecraft"] .upd-notes,
:root[data-design="minecraft"] .se-farbzeile input[type=text], :root[data-design="minecraft"] .se-farbzeile input[type=text],
@@ -308,6 +326,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}
@@ -317,6 +337,11 @@
:root[data-design="minecraft"] .card.sel .card-name{background:none;color:#ffff7f;border-radius:0} :root[data-design="minecraft"] .card.sel .card-name{background:none;color:#ffff7f;border-radius:0}
:root[data-design="minecraft"] .card-taste{border-radius:0;background:#3c8527;box-shadow:inset -1px -1px 0 #1d4d13} :root[data-design="minecraft"] .card-taste{border-radius:0;background:#3c8527;box-shadow:inset -1px -1px 0 #1d4d13}
:root[data-design="minecraft"] .card-icon-img{border-radius:0} :root[data-design="minecraft"] .card-icon-img{border-radius:0}
:root[data-design="minecraft"] .card.laeuft::after{border-color:#7fd858;--accent:#7fd858;border-radius:0}
/* Abbau-Risse: Download-Fortschritt auf dem Block, beim Fertigwerden springt er kurz */
:root[data-design="minecraft"] .card.abbau .card-riss{display:block}
:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon,:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon-img{animation:block-bricht .45s cubic-bezier(.3,.7,.4,1)}
@keyframes block-bricht{0%{transform:scale(.8)}45%{transform:scale(1.14)}100%{transform:none}}
:root[data-design="minecraft"] .empty h2{font-family:var(--pixel);font-weight:400;font-size:24px;color:#fff;text-shadow:3px 3px 0 #3f3f3f} :root[data-design="minecraft"] .empty h2{font-family:var(--pixel);font-weight:400;font-size:24px;color:#fff;text-shadow:3px 3px 0 #3f3f3f}
:root[data-design="minecraft"] .empty p{color:#d0d0d0} :root[data-design="minecraft"] .empty p{color:#d0d0d0}
@@ -330,6 +355,8 @@
:root[data-design="minecraft"] .hr{background:#000;height:2px;margin:6px 0} :root[data-design="minecraft"] .hr{background:#000;height:2px;margin:6px 0}
:root[data-design="minecraft"] .sbtn{margin-bottom:3px;padding:5px 2px} :root[data-design="minecraft"] .sbtn{margin-bottom:3px;padding:5px 2px}
:root[data-design="minecraft"] .sbtn.play{font-family:var(--pixel);font-size:16px;font-weight:400;padding:6px 4px 7px;text-shadow:var(--mc-schatten)} :root[data-design="minecraft"] .sbtn.play{font-family:var(--pixel);font-size:16px;font-weight:400;padding:6px 4px 7px;text-shadow:var(--mc-schatten)}
:root[data-design="minecraft"] .sbtn.play::before,:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play::before{background:#fff;opacity:.28}
:root[data-design="minecraft"] .sbtn.play.laedt .play-text{font-size:13px}
/* Statusleiste */ /* Statusleiste */
:root[data-design="minecraft"] .statusbar{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-top:2px solid #000} :root[data-design="minecraft"] .statusbar{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-top:2px solid #000}
@@ -389,6 +416,10 @@
:root[data-design="minecraft"] .settings-hero h4{font-family:var(--pixel);font-size:24px;font-weight:400;text-shadow:3px 3px 0 #3f3f3f} :root[data-design="minecraft"] .settings-hero h4{font-family:var(--pixel);font-size:24px;font-weight:400;text-shadow:3px 3px 0 #3f3f3f}
:root[data-design="minecraft"] .settings-hero p{color:#d0d0d0} :root[data-design="minecraft"] .settings-hero p{color:#d0d0d0}
:root[data-design="minecraft"] .settings-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f} :root[data-design="minecraft"] .settings-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f}
:root[data-design="minecraft"] .schalter i{border-radius:0;background:#1e1e1f;box-shadow:inset 2px 2px 0 #000}
:root[data-design="minecraft"] .schalter i::after{border-radius:0;background:#c6c6c6;box-shadow:inset -2px -2px 0 #555,inset 2px 2px 0 #fff}
:root[data-design="minecraft"] .schalter input:checked+i{background:#3c8527}
:root[data-design="minecraft"] .s-zeile{border-top-color:rgba(0,0,0,.55)}
:root[data-design="minecraft"] .settings-card h5{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)} :root[data-design="minecraft"] .settings-card h5{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
:root[data-design="minecraft"] .settings-kv-row, :root[data-design="minecraft"] .settings-kv-row,
:root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b} :root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b}
@@ -397,6 +428,12 @@
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)} :root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
/* Kontextmenü, Hinweis, Bildlaufleisten */ /* Kontextmenü, Hinweis, Bildlaufleisten */
/* Erfolg-Meldung wie im Spiel („Erfolg erzielt!“) */
:root[data-design="minecraft"] .erfolg{background:#212121;border:2px solid #555;border-radius:0;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #141414,0 10px 24px rgba(0,0,0,.45);color:#fff;font-family:"AeroPixel","Segoe UI",sans-serif}
:root[data-design="minecraft"] .erfolg-symbol{border-radius:0;background:#8b8b8b;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #fff}
:root[data-design="minecraft"] .erfolg-kopf{color:#ffff55;font-size:14px;font-weight:400;letter-spacing:0;text-transform:none}
:root[data-design="minecraft"] .erfolg-name{font-size:14px;font-weight:400}
:root[data-design="minecraft"] .erfolg-karte{border-radius:0;border:2px solid #000;background:rgba(0,0,0,.35)}
:root[data-design="minecraft"] .ctx{background:#1e1e1f;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a,0 8px 24px rgba(0,0,0,.5)} :root[data-design="minecraft"] .ctx{background:#1e1e1f;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a,0 8px 24px rgba(0,0,0,.5)}
:root[data-design="minecraft"] .ctx-item{border-radius:0;text-shadow:1px 1px 0 #000} :root[data-design="minecraft"] .ctx-item{border-radius:0;text-shadow:1px 1px 0 #000}
:root[data-design="minecraft"] .ctx-item:hover{background:#3c8527} :root[data-design="minecraft"] .ctx-item:hover{background:#3c8527}
+73
View File
@@ -0,0 +1,73 @@
/*
* Erfolge im Minecraft-Stil („Erfolg erzielt!“).
*
* Bewertet wird aus dem, was der Launcher ohnehin weiß (Instanzen, Spielzeit,
* Starts, Mods), plus ein paar Merkern für einzelne Aktionen (Umhang
* hochgeladen, Skin im Editor übernommen, Leistungs-Paket …). Freigeschaltete
* Erfolge landen mit Datum in userData/erfolge.json.
*/
const fs = require('fs');
const path = require('path');
const STUNDE = 3600;
const LISTE = [
{ id: 'erster-start', symbol: 'grass', titel: 'Los geht’s!', text: 'Starte zum ersten Mal ein Spiel mit AeroMC.', pruefe: (w) => w.starts >= 1 },
{ id: 'starts-10', symbol: 'pickaxe', titel: 'Stammgast', text: 'Starte 10-mal ein Spiel.', pruefe: (w) => w.starts >= 10 },
{ id: 'starts-100', symbol: 'gold', titel: 'Ohne mich geht nichts', text: 'Starte 100-mal ein Spiel.', pruefe: (w) => w.starts >= 100 },
{ id: 'zeit-1', symbol: 'map', titel: 'Erste Stunde', text: 'Spiele insgesamt eine Stunde.', pruefe: (w) => w.sekunden >= STUNDE },
{ id: 'zeit-10', symbol: 'sword', titel: 'Zeit vergeht wie im Flug', text: 'Spiele insgesamt 10 Stunden.', pruefe: (w) => w.sekunden >= 10 * STUNDE },
{ id: 'zeit-50', symbol: 'diamond', titel: 'Abenteurer', text: 'Spiele insgesamt 50 Stunden.', pruefe: (w) => w.sekunden >= 50 * STUNDE },
{ id: 'zeit-100', symbol: 'star', titel: 'Lebt in Minecraft', text: 'Spiele insgesamt 100 Stunden.', pruefe: (w) => w.sekunden >= 100 * STUNDE },
{ id: 'instanzen-5', symbol: 'chest', titel: 'Sammler', text: 'Lege 5 Instanzen an.', pruefe: (w) => w.instanzen >= 5 },
{ id: 'instanzen-10', symbol: 'book', titel: 'Archivar', text: 'Lege 10 Instanzen an.', pruefe: (w) => w.instanzen >= 10 },
{ id: 'versionen-3', symbol: 'end', titel: 'Zeitreisender', text: 'Spiele 3 verschiedene Minecraft-Versionen.', pruefe: (w) => w.versionen >= 3 },
{ id: 'erste-mod', symbol: 'redstone', titel: 'Bastler', text: 'Habe die erste Mod in einer Instanz.', pruefe: (w) => w.mods >= 1 },
{ id: 'mods-50', symbol: 'potion', titel: 'Mod-Meister', text: 'Habe 50 Mods in deinen Instanzen.', pruefe: (w) => w.mods >= 50 },
{ id: 'modpack', symbol: 'nether', titel: 'Das volle Paket', text: 'Installiere ein Modpack.', pruefe: (w) => w.modpacks >= 1 },
{ id: 'freund', symbol: 'creeper', titel: 'Gemeinsam statt einsam', text: 'Füge deinen ersten Freund hinzu.', pruefe: (w) => w.freunde >= 1 },
{ id: 'umhang', symbol: 'star', titel: 'Modeschöpfer', text: 'Lade einen eigenen AeroMC-Umhang hoch.', pruefe: (w) => w.merker.umhang },
{ id: 'skin-editor', symbol: 'map', titel: 'Pixelkünstler', text: 'Übernimm einen im Skin-Editor gemalten Skin.', pruefe: (w) => w.merker['skin-editor'] },
{ id: 'leistung', symbol: 'tnt', titel: 'Turbo', text: 'Installiere das Leistungs-Paket.', pruefe: (w) => w.merker.leistung },
];
let datei = null;
function init(userDataDir) { datei = path.join(userDataDir, 'erfolge.json'); }
function lies() {
try { return JSON.parse(fs.readFileSync(datei, 'utf8')); } catch { return null; }
}
function schreibe(stand) {
try { fs.writeFileSync(datei, JSON.stringify(stand, null, 1)); } catch { /* nicht schlimm */ }
}
/**
* werte: { starts, sekunden, instanzen, versionen, mods, modpacks, freunde }
* merker: zusätzliche Einzel-Aktionen ({ umhang: true } …)
* -> { neu: [Erfolge], ersteAuswertung }
*/
function pruefe(werte, merker = {}) {
const vorher = lies();
const stand = vorher || { freigeschaltet: {}, merker: {} };
Object.assign(stand.merker, merker);
const w = Object.assign({ starts: 0, sekunden: 0, instanzen: 0, versionen: 0, mods: 0, modpacks: 0, freunde: 0 }, werte, { merker: stand.merker });
const neu = [];
const jetzt = new Date().toISOString();
for (const e of LISTE) {
if (stand.freigeschaltet[e.id]) continue;
let erreicht = false;
try { erreicht = !!e.pruefe(w); } catch { /* Wert fehlt */ }
if (erreicht) { stand.freigeschaltet[e.id] = jetzt; neu.push(oeffentlich(e, jetzt)); }
}
if (neu.length || !vorher || Object.keys(merker).length) schreibe(stand);
return { neu, ersteAuswertung: !vorher };
}
const oeffentlich = (e, am) => ({ id: e.id, symbol: e.symbol, titel: e.titel, text: e.text, am: am || null });
/** Alle Erfolge mit Datum (null = noch offen). */
function liste() {
const stand = lies() || { freigeschaltet: {} };
return LISTE.map((e) => oeffentlich(e, stand.freigeschaltet[e.id] || null));
}
module.exports = { init, pruefe, liste };
+85
View File
@@ -0,0 +1,85 @@
/*
* "Fenster wachsen aus dem Knopf": Ein Popup-Fenster (Optionen, Mods, Skin,
* Freunde …) erscheint nicht schlagartig, sondern wächst aus dem angeklickten
* Knopf heraus. Dafür wird das noch unsichtbare Fenster abfotografiert und das
* Bild in einem durchsichtigen Hilfsfenster vom Knopf aus groß gezogen; am Ende
* übernimmt das echte Fenster an exakt derselben Stelle.
*
* Das Hilfsfenster entsteht jedes Mal neu und gleich in der richtigen Größe:
* Unter Windows färbt sich ein durchsichtiges Fenster schwarz, sobald es
* nachträglich vergrößert wird, und ein verstecktes Fenster zeichnet nicht mehr.
* Abschaltbar unter Optionen → Darstellung → Animationen („Bewegte Oberfläche“).
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const DAUER_MS = 280;
const HOECHSTENS_MS = 2000; // spätestens dann ist das Fenster in jedem Fall zu sehen
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
function vereinigung(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return {
x, y,
width: Math.max(a.x + a.width, b.x + b.width) - x,
height: Math.max(a.y + a.height, b.y + b.height) - y,
};
}
// Rechteck in Koordinaten des Hilfsfensters umrechnen
const relativ = (r, box) => ({ x: r.x - box.x, y: r.y - box.y, width: r.width, height: r.height });
async function abspielen(ziel, von, merke) {
// unsichtbar zeigen, damit Chromium den Inhalt zeichnet, dann abfotografieren
ziel.setOpacity(0);
ziel.setIgnoreMouseEvents(true);
ziel.show();
await warte(90);
if (ziel.isDestroyed()) return;
const bild = await ziel.webContents.capturePage();
if (bild.isEmpty() || ziel.isDestroyed()) return;
const nach = ziel.getBounds();
const box = vereinigung(von, nach);
const hilfe = new BrowserWindow({
x: box.x, y: box.y, width: box.width, height: box.height,
show: false, frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false, focusable: false,
skipTaskbar: true, hasShadow: false, alwaysOnTop: true, backgroundColor: '#00000000',
title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
merke(hilfe);
hilfe.setIgnoreMouseEvents(true);
await hilfe.loadFile(path.join(__dirname, 'aufbau.html'));
const ok = await hilfe.webContents.executeJavaScript(
`vorbereiten(${JSON.stringify(bild.toDataURL())}, ${JSON.stringify(relativ(nach, box))}, ${JSON.stringify(relativ(von, box))})`);
if (!ok || hilfe.isDestroyed()) return;
hilfe.showInactive();
await hilfe.webContents.executeJavaScript(`abspielen(${DAUER_MS})`);
}
/**
* Zeigt `ziel` (geladen, aber noch nicht sichtbar) mit Wachs-Animation an.
* `von` ist das Rechteck des angeklickten Knopfs in Bildschirmkoordinaten.
*/
async function wachsen(ziel, von) {
let hilfe = null;
try {
await Promise.race([abspielen(ziel, von, (h) => { hilfe = h; }), warte(HOECHSTENS_MS)]);
} catch {
// Animation ist nur Zierde – im Fehlerfall das Fenster einfach zeigen
} finally {
if (!ziel.isDestroyed()) {
ziel.setOpacity(1);
ziel.setIgnoreMouseEvents(false);
if (!ziel.isVisible()) ziel.show();
ziel.focus();
}
// Hilfsfenster erst nach dem Umschalten schließen, sonst blitzt der Hintergrund durch
setTimeout(() => { if (hilfe && !hilfe.isDestroyed()) hilfe.destroy(); }, 60);
}
}
module.exports = { wachsen };
+25
View File
@@ -200,7 +200,32 @@ async function sendReport(daten) {
} }
} }
// ---------- AeroMC-Umhänge ----------
// Hochladen/Entfernen nur angemeldet; Abrufen ist öffentlich (auch für den Mod)
function uploadCape(uuid, image) {
return anfrage('/cape', { method: 'POST', body: { uuid, image } });
}
function removeCape(uuid) {
return anfrage('/cape/remove', { method: 'POST', body: { uuid } });
}
async function getCapes(uuids) {
try {
const liste = [...new Set((uuids || []).map((u) => String(u).replace(/-/g, '').toLowerCase()))].filter(Boolean);
if (!liste.length) return { ok: true, capes: {} };
const { res, daten } = await roheAnfrage('/capes', { method: 'GET', body: { uuids: liste.join(',') } });
// ältere Plugin-Fassung ohne Umhänge
if (res.status === 404) return { ok: false, fehlt: true, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
return { ok: true, capes: (daten && daten.capes) || {} };
} catch (err) {
return { ok: false, message: 'Umhang-Server nicht erreichbar: ' + err.message };
}
}
module.exports = { module.exports = {
BASIS_URL, uploadCape, removeCape, getCapes,
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend, heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer, sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer,
sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport, sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport,
+106
View File
@@ -0,0 +1,106 @@
/*
* Garderobe: gespeicherte Skins und Umhänge zum schnellen Wechseln.
*
* Mojang merkt sich keine früheren Skins – wer einen neuen hochlädt, verliert
* den alten. Die Garderobe hält Skins (samt Armform) und eigene AeroMC-Umhänge
* als PNG-Dateien im Launcher-Datenordner fest; vor jedem Skinwechsel im
* Launcher wird der bisherige Skin automatisch gesichert. Gleiche Dateien
* (gleiche Prüfsumme) werden nicht doppelt abgelegt.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MAX_AUTOMATISCH = 20; // ältere automatische Sicherungen fliegen raus
let ordner = null;
function init(userDataDir) {
ordner = path.join(userDataDir, 'garderobe');
}
const indexDatei = () => path.join(ordner, 'garderobe.json');
function lies() {
try { return JSON.parse(fs.readFileSync(indexDatei(), 'utf8')); } catch { return []; }
}
function schreibe(liste) {
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(indexDatei(), JSON.stringify(liste, null, 1));
}
/** Alle Einträge, neueste zuerst, jeweils mit Bild als data:-Adresse. */
function liste() {
return lies()
.filter((e) => fs.existsSync(path.join(ordner, e.datei)))
.sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)))
.map((e) => Object.assign({}, e, {
dataUri: 'data:image/png;base64,' + fs.readFileSync(path.join(ordner, e.datei)).toString('base64'),
}));
}
/**
* Legt einen Skin/Umhang ab. art: 'skin' | 'umhang', daten: PNG-Buffer.
* Gibt es dieselbe Datei schon, wird nur ggf. aus "automatisch" ein fester Eintrag.
*/
function hinzufuegen({ art, daten, name, variante, automatisch }) {
const hash = crypto.createHash('sha1').update(daten).digest('hex');
const eintraege = lies();
const vorhanden = eintraege.find((e) => e.art === art && e.hash === hash);
if (vorhanden) {
if (!automatisch && vorhanden.automatisch) {
vorhanden.automatisch = false;
if (name) vorhanden.name = name;
schreibe(eintraege);
}
return { eintrag: vorhanden, neu: false };
}
fs.mkdirSync(ordner, { recursive: true });
const id = Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
const datei = `${art}-${id}.png`;
fs.writeFileSync(path.join(ordner, datei), daten);
const eintrag = {
id, art, hash, datei,
name: String(name || '').trim().slice(0, 40) || (art === 'skin' ? 'Skin' : 'Umhang'),
variante: art === 'skin' ? (variante === 'slim' ? 'slim' : 'classic') : undefined,
automatisch: !!automatisch,
erstellt: new Date().toISOString(),
};
eintraege.push(eintrag);
// automatische Sicherungen begrenzen (feste Einträge bleiben immer)
const auto = eintraege.filter((e) => e.automatisch).sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)));
for (const alt of auto.slice(MAX_AUTOMATISCH)) loescheIntern(eintraege, alt.id);
schreibe(eintraege);
return { eintrag, neu: true };
}
function loescheIntern(eintraege, id) {
const i = eintraege.findIndex((e) => e.id === id);
if (i < 0) return false;
try { fs.unlinkSync(path.join(ordner, eintraege[i].datei)); } catch { /* schon weg */ }
eintraege.splice(i, 1);
return true;
}
function loeschen(id) {
const eintraege = lies();
const ok = loescheIntern(eintraege, id);
if (ok) schreibe(eintraege);
return ok;
}
function umbenennen(id, name) {
const eintraege = lies();
const e = eintraege.find((x) => x.id === id);
if (!e) return false;
e.name = String(name || '').trim().slice(0, 40) || e.name;
e.automatisch = false; // umbenannt = bewusst behalten
schreibe(eintraege);
return true;
}
/** Eintrag samt Dateipfad (zum Anziehen). */
function holen(id) {
const e = lies().find((x) => x.id === id);
return e ? Object.assign({}, e, { pfad: path.join(ordner, e.datei) }) : null;
}
module.exports = { init, liste, hinzufuegen, loeschen, umbenennen, holen };
+311 -235
View File
@@ -27,16 +27,19 @@
<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">
<div id="lichtpunkte" class="lichtpunkte" aria-hidden="true"></div>
<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>
@@ -52,7 +55,7 @@
<div id="p-name" class="side-title">Instanz</div> <div id="p-name" class="side-title">Instanz</div>
<div id="p-groupline" class="side-grp">Gruppe ändern</div> <div id="p-groupline" class="side-grp">Gruppe ändern</div>
<div class="hr"></div> <div class="hr"></div>
<button id="p-play" class="sbtn play">Starten</button> <button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
<button id="p-offline" class="sbtn">Offline starten</button> <button id="p-offline" class="sbtn">Offline starten</button>
<div class="hr"></div> <div class="hr"></div>
<button id="p-edit" class="sbtn">Instanz bearbeiten</button> <button id="p-edit" class="sbtn">Instanz bearbeiten</button>
@@ -105,6 +108,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">
@@ -243,87 +247,62 @@
<button class="snav on" data-pane="pane-general">Allgemein</button> <button class="snav on" data-pane="pane-general">Allgemein</button>
<button class="snav" data-pane="pane-appearance">Darstellung</button> <button class="snav" data-pane="pane-appearance">Darstellung</button>
<button class="snav" data-pane="pane-minecraft">Spiel</button> <button class="snav" data-pane="pane-minecraft">Spiel</button>
<button class="snav" data-pane="pane-start">Start &amp; Java</button> <button class="snav" data-pane="pane-start">Java &amp; Erweitert</button>
<button class="snav" data-pane="pane-online">Freunde &amp; Umhänge</button>
<button class="snav" data-pane="pane-network">Netzwerk</button> <button class="snav" data-pane="pane-network">Netzwerk</button>
<button class="snav" data-pane="pane-accounts">Konten</button> <button class="snav" data-pane="pane-accounts">Konten</button>
<button class="snav" data-pane="pane-backup">Backup</button> <button class="snav" data-pane="pane-backup">Backup</button>
<button class="snav" data-pane="pane-info">Info</button> <button class="snav" data-pane="pane-info">Info</button>
</nav> </nav>
<div class="settings-content"> <div class="settings-content">
<!-- Aufbau: jede Karte enthält Zeilen – links Name + kurze Erklärung, rechts das Bedienelement -->
<div id="pane-general" class="spane"> <div id="pane-general" class="spane">
<div class="settings-hero"> <div class="settings-hero">
<h4>Allgemein</h4> <h4>Allgemein</h4>
<p>Globale Launcher-Optionen, Instanzverwaltung und sichtbare Bereiche.</p> <p>Wie sich der Launcher verhält und wo deine Instanzen liegen.</p>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-card"> <section class="settings-card">
<h5>Instanzliste</h5> <h5>Launcher</h5>
<label class="field"> <div class="s-zeile">
<span>Instanzen sortieren</span> <div class="s-text"><b>Mit Windows starten</b><small>Startet AeroMC beim Anmelden im Infobereich neben der Uhr. Solange aktiv, schließt das X den Launcher nicht, sondern legt ihn dort ab.</small></div>
<select id="s-sortby"> <label class="schalter"><input id="s-autostart" type="checkbox" /><i></i></label>
<option value="lastPlayed">Nach letztem Start</option> </div>
<option value="name">Nach Name</option> <div class="s-zeile">
</select> <div class="s-text"><b>Windows-Benachrichtigungen</b><small>Meldet, wenn ein großer Download oder ein Backup im Hintergrund fertig ist – nur, solange der Launcher nicht im Vordergrund ist.</small></div>
</label> <label class="schalter"><input id="s-notify" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Bedrock-Bereich anzeigen</b><small id="s-bedrock-hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small></div>
<label class="schalter"><input id="s-bedrock" type="checkbox" /><i></i></label>
</div>
</section> </section>
<section class="settings-card">
<section class="settings-card settings-card-wide">
<h5>Instanzen</h5> <h5>Instanzen</h5>
<label class="field"> <div class="s-zeile s-voll">
<span>Instanz-Verzeichnis</span> <div class="s-text"><b>Instanz-Verzeichnis</b><small>Jede Instanz liegt hier als Unterordner mit ihrem Namen.</small></div>
<div class="path-row"> <div class="path-row">
<input id="s-instances-dir" type="text" readonly /> <input id="s-instances-dir" type="text" readonly />
<button id="s-pick-dir" class="btn">Wählen…</button> <button id="s-pick-dir" class="btn">Wählen…</button>
</div> </div>
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small> </div>
</label> <div class="s-zeile">
</section> <div class="s-text"><b>Sortierung</b><small>Reihenfolge der Instanzen in der Übersicht.</small></div>
<select id="s-sortby" class="s-wert">
<section class="settings-card"> <option value="lastPlayed">Nach letztem Start</option>
<h5>Bereiche</h5> <option value="name">Nach Name</option>
<label class="checkbox-field"> </select>
<input id="s-bedrock" type="checkbox" /> <span>Bedrock-Bereich anzeigen</span> </div>
</label>
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
</section>
<section class="settings-card">
<h5>Benachrichtigungen</h5>
<label class="checkbox-field">
<input id="s-notify" type="checkbox" /> <span>Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird</span>
</label>
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
</section>
<section class="settings-card">
<h5>Freundesystem</h5>
<label class="checkbox-field">
<input id="s-friends" type="checkbox" /> <span>Freundesystem aktivieren</span>
</label>
<small class="hint">Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.</small>
<label class="checkbox-field" style="margin-top:10px">
<input id="s-friends-server" type="checkbox" /> <span>Server, auf dem ich spiele, für Freunde sichtbar</span>
</label>
<small class="hint">Ausgeschaltet sehen Freunde nur, dass du spielst – nicht wo. „Beitreten“ ist dann bei dir nicht möglich, Einladungen kannst du aber weiterhin verschicken.</small>
</section>
<section class="settings-card">
<h5>Autostart</h5>
<label class="checkbox-field">
<input id="s-autostart" type="checkbox" /> <span>Mit Windows starten (im Infobereich)</span>
</label>
<small class="hint">Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden – über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.</small>
</section> </section>
</div> </div>
</div> </div>
<div id="pane-appearance" class="spane hidden"> <div id="pane-appearance" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Darstellung</h4> <h4>Darstellung</h4>
<p>Optik der Oberfläche und visuelle Extras für die Instanzansicht.</p> <p>Design, Bewegung und der Skin im Hintergrund.</p>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-card settings-card-wide"> <section class="settings-card">
<h5>Design</h5> <h5>Design</h5>
<div class="field"> <div class="field">
<select id="s-theme" class="hidden"> <select id="s-theme" class="hidden">
@@ -353,25 +332,30 @@
</div> </div>
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small> <small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
</section> </section>
<section class="settings-card"> <section class="settings-card">
<h5>Icons</h5> <h5>Bewegung &amp; Effekte</h5>
<label class="checkbox-field"> <div class="s-zeile">
<input id="s-iso3d" type="checkbox" /> <span>Block-Symbole räumlich darstellen (3D)</span> <div class="s-text"><b>Startanimation</b><small>Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf. Gilt ab dem nächsten Start.</small></div>
</label> <label class="schalter"><input id="s-start-animation" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Bewegte Oberfläche</b><small>Die Skin-Figur atmet und schaut zum Mauszeiger, Fenster wachsen aus dem Knopf, Designs blenden weich über.</small></div>
<label class="schalter"><input id="s-ui-animationen" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Räumliche Block-Symbole</b><small>Zeigt die Block-Symbole der Instanzen in 3D.</small></div>
<label class="schalter"><input id="s-iso3d" type="checkbox" /><i></i></label>
</div>
</section> </section>
<section class="settings-card">
<section class="settings-card settings-card-wide">
<h5>Skin-Hintergrund</h5> <h5>Skin-Hintergrund</h5>
<label class="field"> <div class="s-zeile">
<span>Quelle <small class="opt">(optional)</small></span> <div class="s-text"><b>Quelle</b><small>Zeigt den Skin eines Kontos groß im Instanzbereich.</small></div>
<select id="s-bgskin"><option value="">Aus</option></select> <select id="s-bgskin" class="s-wert"><option value="">Aus</option></select>
<small class="hint">Zeigt den Minecraft-Skin des gewählten Kontos als Hintergrund im Instanzbereich.</small> </div>
</label> <div class="s-zeile">
<div class="field-row"> <div class="s-text"><b>Pose</b></div>
<label class="field"> <select id="s-bgskin-pose" class="s-wert">
<span>Pose</span>
<select id="s-bgskin-pose">
<option value="walking">Laufend</option> <option value="walking">Laufend</option>
<option value="standing">Stehend</option> <option value="standing">Stehend</option>
<option value="legends">Heldenhaft</option> <option value="legends">Heldenhaft</option>
@@ -380,84 +364,89 @@
<option value="sitting">Sitzend</option> <option value="sitting">Sitzend</option>
<option value="spooky">Unheimlich</option> <option value="spooky">Unheimlich</option>
</select> </select>
</label> </div>
<label class="field"> <div class="s-zeile">
<span>Armform</span> <div class="s-text"><b>Armform</b><small>„Automatisch“ übernimmt die Einstellung des Kontos.</small></div>
<select id="s-bgskin-model" title="Bei „Automatisch“ wird die Armform übernommen, die beim Konto eingestellt ist"> <select id="s-bgskin-model" class="s-wert">
<option value="auto">Automatisch (vom Konto)</option> <option value="auto">Automatisch (vom Konto)</option>
<option value="classic">Normal (breite Arme, Steve)</option> <option value="classic">Normal (breite Arme, Steve)</option>
<option value="slim">Schmal (Alex)</option> <option value="slim">Schmal (Alex)</option>
</select> </select>
</label>
<label class="field">
<span>Deckkraft: <span id="s-bgskin-val">55</span> %</span>
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
</label>
</div> </div>
<label class="checkbox-field"> <div class="s-zeile">
<input id="s-bgskin-cycle" type="checkbox" /> <span>Bei mehreren angemeldeten Konten automatisch durchwechseln</span> <div class="s-text"><b>Deckkraft</b></div>
</label> <div class="s-wert s-regler"><input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" /><span><span id="s-bgskin-val">55</span> %</span></div>
<label class="field"> </div>
<span>Wechsel alle <small class="opt">(Sekunden)</small></span> <div class="s-zeile">
<input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" /> <div class="s-text"><b>Konten durchwechseln</b><small>Bei 2 oder mehr angemeldeten Konten wechselt der Skin der Reihe nach.</small></div>
<small class="hint">Läuft nur, wenn 2 oder mehr Konten angemeldet sind, und beginnt nach dem letzten Konto wieder von vorn.</small> <label class="schalter"><input id="s-bgskin-cycle" type="checkbox" /><i></i></label>
</label> </div>
<div class="s-zeile">
<div class="s-text"><b>Wechsel alle</b></div>
<div class="s-wert s-einheit"><input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" /><span>Sekunden</span></div>
</div>
</section> </section>
</div> </div>
</div> </div>
<div id="pane-minecraft" class="spane hidden"> <div id="pane-minecraft" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Spiel</h4> <h4>Spiel</h4>
<p>Standardwerte für neue und bestehende Instanzen ohne eigene Überschreibung.</p> <p>Standardwerte für alle Instanzen ohne eigene Einstellung – und was beim Spielen passiert.</p>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-card"> <section class="settings-card">
<h5>Speicher</h5> <h5>Leistung &amp; Fenster</h5>
<div class="field-row"> <div class="s-zeile">
<label class="field"> <div class="s-text"><b>Arbeitsspeicher</b><small>Mindestens / höchstens, in MB.</small></div>
<span>Standard-RAM min. (MB)</span> <div class="s-wert s-paar"><input id="s-min-mem" type="number" min="512" step="256" aria-label="RAM min. (MB)" /><span>–</span><input id="s-max-mem" type="number" min="512" step="256" aria-label="RAM max. (MB)" /></div>
<input id="s-min-mem" type="number" min="512" step="256" />
</label>
<label class="field">
<span>Standard-RAM max. (MB)</span>
<input id="s-max-mem" type="number" min="512" step="256" />
</label>
</div> </div>
<small class="hint">Gilt für alle Instanzen ohne eigene RAM-Einstellung.</small> <div class="s-zeile">
</section> <div class="s-text"><b>Maximiert starten</b><small>Minecraft öffnet sich bildschirmfüllend.</small></div>
<label class="schalter"><input id="s-mc-max" type="checkbox" /><i></i></label>
<section class="settings-card"> </div>
<h5>Fenster</h5> <div class="s-zeile">
<label class="checkbox-field"> <div class="s-text"><b>Fenstergröße</b><small>Breite × Höhe, wenn nicht maximiert.</small></div>
<input id="s-mc-max" type="checkbox" /> <span>Minecraft maximiert starten</span> <div class="s-wert s-paar"><input id="s-mc-width" type="number" min="320" step="10" aria-label="Fensterbreite" /><span>×</span><input id="s-mc-height" type="number" min="240" step="10" aria-label="Fensterhöhe" /></div>
</label>
<div class="field-row">
<label class="field"><span>Fensterbreite</span><input id="s-mc-width" type="number" min="320" step="10" /></label>
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
</div> </div>
</section> </section>
<section class="settings-card"> <section class="settings-card">
<h5>Mods</h5> <h5>Mods</h5>
<label class="checkbox-field"> <div class="s-zeile">
<input id="s-auto-mod-updates" type="checkbox" /> <span>Vor jedem Start nach Mod-Updates suchen und installieren</span> <div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
</label> <label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></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> </div>
</section>
<section class="settings-card">
<h5>Konsole</h5>
<div class="s-zeile">
<div class="s-text"><b>Während des Spiels anzeigen</b></div>
<label class="schalter"><input id="s-console-launch" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Nach dem Beenden schließen</b></div>
<label class="schalter"><input id="s-console-autoclose" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Bei einem Absturz anzeigen</b></div>
<label class="schalter"><input id="s-console-crash" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Zeilen merken</b><small>Ältere Zeilen fallen aus der Konsole heraus.</small></div>
<input id="s-console-lines" class="s-wert s-zahl" type="number" min="1000" step="1000" />
</div>
</section> </section>
</div> </div>
</div> </div>
<div id="pane-start" class="spane hidden"> <div id="pane-start" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Start &amp; Java</h4> <h4>Java &amp; Erweitert</h4>
<p>Runtime-Auswahl, Startverhalten und globale Befehle für alle Instanzen.</p> <p>Welches Java startet und was zusätzlich vor oder nach dem Spiel läuft.</p>
</div> </div>
<div class="settings-grid settings-grid-stack"> <div class="settings-grid">
<section class="settings-card settings-card-wide"> <section class="settings-card">
<h5>Java</h5> <h5>Java</h5>
<label class="field"> <div class="s-zeile s-voll">
<span>Java (java.exe)</span> <div class="s-text"><b>Java-Programm</b><small id="s-java-status">–</small></div>
<div class="path-row"> <div class="path-row">
<input id="s-java" type="text" placeholder="leer = java aus PATH" /> <input id="s-java" type="text" placeholder="leer = java aus PATH" />
<button id="s-pick-java" class="btn">Wählen…</button> <button id="s-pick-java" class="btn">Wählen…</button>
@@ -466,66 +455,69 @@
<select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select> <select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
<button id="s-scan-java" class="btn">Erkennen</button> <button id="s-scan-java" class="btn">Erkennen</button>
</div> </div>
<small id="s-java-status" class="hint">–</small> </div>
</label> <div class="s-zeile">
<label class="checkbox-field"> <div class="s-text"><b>Fehlendes Java herunterladen</b><small>Holt beim Start die passende Version von Eclipse Temurin, wenn keine geeignete gefunden wird (rund 45 MB je Version).</small></div>
<input id="s-auto-java" type="checkbox" /> <span>Fehlende Java-Version automatisch herunterladen</span> <label class="schalter"><input id="s-auto-java" type="checkbox" /><i></i></label>
</label> </div>
<small class="hint">Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.</small>
</section> </section>
<section class="settings-card"> <section class="settings-card">
<h5>Konsole</h5> <h5>Befehle</h5>
<label class="checkbox-field"> <small class="hint s-vorab">Für Fortgeschrittene. Befehle aus dem Bearbeiten-Dialog einer Instanz haben Vorrang.</small>
<input id="s-console-launch" type="checkbox" /> <span>Konsole anzeigen, während das Spiel läuft</span> <div class="s-zeile">
</label> <div class="s-text"><b>Vor dem Start</b></div>
<label class="checkbox-field"> <input id="s-global-pre" class="s-wert s-breit" type="text" placeholder="läuft vor jedem Spielstart" />
<input id="s-console-autoclose" type="checkbox" /> <span>Konsole automatisch schließen, nachdem das Spiel beendet wurde</span> </div>
</label> <div class="s-zeile">
<label class="checkbox-field"> <div class="s-text"><b>Nach dem Beenden</b></div>
<input id="s-console-crash" type="checkbox" /> <span>Konsole anzeigen, wenn das Spiel abstürzt</span> <input id="s-global-post" class="s-wert s-breit" type="text" placeholder="läuft nach jedem Beenden" />
</label> </div>
<label class="field"> <div class="s-zeile">
<span>Historie-Limit (Zeilen)</span> <div class="s-text"><b>Wrapper</b><small>Startet Java über dieses Programm.</small></div>
<input id="s-console-lines" type="number" min="1000" step="1000" /> <input id="s-wrapper" class="s-wert s-breit" type="text" placeholder="z. B. prime-run" />
</label> </div>
</section> </section>
</div>
</div>
<div id="pane-online" class="spane hidden">
<div class="settings-hero">
<h4>Freunde &amp; Umhänge</h4>
<p>Was andere AeroMC-Spieler von dir sehen.</p>
</div>
<div class="settings-grid">
<section class="settings-card"> <section class="settings-card">
<h5>Globale Befehle</h5> <h5>Freundesystem</h5>
<label class="field"> <div class="s-zeile">
<span>Pre-Launch-Befehl</span> <div class="s-text"><b>Freundesystem aktivieren</b><small>Aus: niemand kann dich finden oder hinzufügen. Bestehende Freunde bleiben, sehen dich aber als offline.</small></div>
<input id="s-global-pre" type="text" placeholder="läuft vor jedem Spielstart" /> <label class="schalter"><input id="s-friends" type="checkbox" /><i></i></label>
</label> </div>
<label class="field"> <div class="s-zeile">
<span>Post-Exit-Befehl</span> <div class="s-text"><b>Server für Freunde sichtbar</b><small>Aus: Freunde sehen nur, dass du spielst, nicht wo – „Beitreten“ geht dann nicht, Einladungen schon.</small></div>
<input id="s-global-post" type="text" placeholder="läuft nach jedem Beenden" /> <label class="schalter"><input id="s-friends-server" type="checkbox" /><i></i></label>
</label> </div>
<small class="hint">Instanzspezifische Befehle aus dem Bearbeiten-Dialog haben Vorrang.</small>
</section> </section>
<section class="settings-card">
<section class="settings-card settings-card-wide"> <h5>Umhänge</h5>
<h5>Externe Programme</h5> <div class="s-zeile">
<label class="field"> <div class="s-text"><b>AeroMC-Umhänge im Spiel</b><small>Zeigt deinen Umhang und den anderer AeroMC-Spieler – in jeder Version, mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small></div>
<span>Wrapper-Befehl</span> <label class="schalter"><input id="s-aeromc-capes" type="checkbox" /><i></i></label>
<input id="s-wrapper" type="text" placeholder="z. B. prime-run (leer = kein Wrapper)" /> </div>
</label>
<small class="hint">Startet Java über dieses Programm. Java-Pfad und Argumente werden automatisch angehängt.</small>
</section> </section>
</div> </div>
</div> </div>
<div id="pane-network" class="spane hidden"> <div id="pane-network" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Netzwerk</h4> <h4>Netzwerk</h4>
<p>Verbindungsoptionen für Downloads und externe Dienste.</p> <p>Proxy für Downloads und der Zugang zu CurseForge.</p>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-card settings-card-wide"> <section class="settings-card">
<h5>Proxy</h5> <h5>Proxy</h5>
<label class="checkbox-field"> <div class="s-zeile">
<input id="s-proxy-enabled" type="checkbox" /> <span>HTTP-Proxy für Downloads verwenden</span> <div class="s-text"><b>HTTP-Proxy verwenden</b><small>Nur nötig, wenn dein Netzwerk Downloads nur über einen Proxy erlaubt.</small></div>
</label> <label class="schalter"><input id="s-proxy-enabled" type="checkbox" /><i></i></label>
</div>
<div class="s-felder">
<div class="field-row"> <div class="field-row">
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label> <label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label> <label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
@@ -534,34 +526,29 @@
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label> <label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label> <label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
</div> </div>
</div>
</section> </section>
<section class="settings-card">
<section class="settings-card settings-card-wide">
<h5>CurseForge</h5> <h5>CurseForge</h5>
<label class="field"> <small class="hint s-vorab">Nur für CurseForge-Modpacks und -Mods nötig. Modrinth funktioniert immer ohne.</small>
<span>Eigener API-Key <small class="opt">(optional)</small></span> <div class="s-zeile s-voll">
<div class="s-text"><b>Eigener API-Key</b><small>Von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder ein Organisationsschlüssel. Nach dem Eintragen speichern.</small></div>
<div class="path-row"> <div class="path-row">
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" /> <input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
<button type="button" id="s-cf-test" class="btn">Prüfen</button> <button type="button" id="s-cf-test" class="btn">Prüfen</button>
</div> </div>
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
<small id="s-cf-test-status" class="hint"></small> <small id="s-cf-test-status" class="hint"></small>
</label>
<div class="field-row">
<label class="field">
<span>Weiterleitung <small class="opt">(optional)</small></span>
<input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" />
</label>
<label class="field">
<span>Token <small class="opt">(falls verlangt)</small></span>
<input id="s-cf-proxy-token" type="password" />
</label>
</div> </div>
<small class="hint">Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.</small> <div class="s-zeile s-voll">
<div class="s-text"><b>Weiterleitung</b><small>Ein Server, der den Schlüssel für dich hält.</small></div>
<div class="field-row">
<label class="field"><span>Adresse</span><input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" /></label>
<label class="field"><span>Token <small class="opt">(falls verlangt)</small></span><input id="s-cf-proxy-token" type="password" /></label>
</div>
</div>
</section> </section>
</div> </div>
</div> </div>
<div id="pane-accounts" class="spane hidden"> <div id="pane-accounts" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Konten</h4> <h4>Konten</h4>
@@ -582,66 +569,58 @@
<div id="pane-backup" class="spane hidden"> <div id="pane-backup" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Backup</h4> <h4>Backup</h4>
<p>Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.</p> <p>Sicherungen aller Instanzen und deiner Welten.</p>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-card settings-card-wide"> <section class="settings-card">
<h5>Globales Backup</h5> <h5>Jetzt sichern</h5>
<small class="hint">Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.</small> <div class="s-zeile">
<div class="path-row" style="margin-top:14px"> <div class="s-text"><b>Komplett-Backup</b><small>Alle Instanzen und Launcher-Einstellungen als ZIP unter Dokumente\AeroMC Launcher\Backups. Konten und Anmeldedaten werden nicht gesichert.</small></div>
<button id="s-backup-global" class="btn btn-primary">Backup erstellen</button> <button id="s-backup-global" class="btn btn-primary">Backup erstellen</button>
<span id="s-backup-status" class="hint">Noch kein Backup erstellt.</span>
</div> </div>
<small id="s-backup-status" class="hint s-status">Noch kein Backup erstellt.</small>
<div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true"> <div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div> <div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div> <div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
</div> </div>
</section> </section>
<section class="settings-card">
<section class="settings-card settings-card-wide">
<h5>Automatisch sichern</h5> <h5>Automatisch sichern</h5>
<label class="checkbox-field"> <div class="s-zeile">
<input id="s-autobackup" type="checkbox" /> <span>Sicherungen nach Zeitplan erstellen</span> <div class="s-text"><b>Nach Zeitplan sichern</b><small id="s-autobackup-last">Noch keine automatische Sicherung.</small></div>
</label> <label class="schalter"><input id="s-autobackup" type="checkbox" /><i></i></label>
<div class="field-row"> </div>
<label class="field"> <div class="s-zeile">
<span>Wie oft</span> <div class="s-text"><b>Wie oft</b></div>
<select id="s-autobackup-every"> <select id="s-autobackup-every" class="s-wert">
<option value="daily">Täglich</option> <option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option> <option value="weekly">Wöchentlich</option>
</select> </select>
</label>
<label class="field">
<span>Wie viele behalten</span>
<input id="s-autobackup-keep" type="number" min="1" max="50" />
</label>
</div> </div>
<label class="checkbox-field"> <div class="s-zeile">
<input id="s-autobackup-launch" type="checkbox" /> <span>Zusätzlich vor jedem Spielstart sichern</span> <div class="s-text"><b>Wie viele behalten</b><small>Ältere automatische Sicherungen werden gelöscht.</small></div>
</label> <input id="s-autobackup-keep" class="s-wert s-zahl" type="number" min="1" max="50" />
<small class="hint">Eine Sicherung umfasst <em>alle</em> Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.</small> </div>
<small id="s-autobackup-last" class="hint">Noch keine automatische Sicherung.</small> <div class="s-zeile">
<div class="s-text"><b>Zusätzlich vor jedem Start</b><small>Der Start wartet, bis die Sicherung fertig ist – bei vielen Instanzen kann das einige Minuten dauern.</small></div>
<label class="schalter"><input id="s-autobackup-launch" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Welt nach dem Spielen sichern</b><small>Sichert nach jeder Sitzung die zuletzt gespielte Welt im Hintergrund. Je Welt bleiben die letzten 5 Stände (Dokumente\AeroMC Launcher\Welten-Sicherungen).</small></div>
<label class="schalter"><input id="s-auto-world" type="checkbox" /><i></i></label>
</div>
</section> </section>
<section class="settings-card"> <section class="settings-card">
<h5>Welten</h5>
<label class="checkbox-field">
<input id="s-auto-world" type="checkbox" /> <span>Nach jeder Spielsitzung die zuletzt gespielte Welt sichern</span>
</label>
<small class="hint">Läuft im Hintergrund, sobald Minecraft beendet ist. Pro Welt bleiben die letzten 5 Stände erhalten – unter Dokumente\AeroMC Launcher\Welten-Sicherungen\&lt;Instanz&gt;\Automatisch.</small>
</section>
<section class="settings-card settings-card-wide">
<h5>Wiederherstellen</h5> <h5>Wiederherstellen</h5>
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small> <small class="hint s-vorab s-warnung">Achtung: Alle vorhandenen Instanzen und die Launcher-Einstellungen werden mit dem Inhalt des Backups überschrieben. Konten bleiben unverändert.</small>
<label class="field backup-select-field"> <div class="s-zeile s-voll">
<span>Verfügbares Backup</span> <div class="s-text"><b>Verfügbares Backup</b></div>
<div class="path-row">
<select id="s-restore-backup"></select> <select id="s-restore-backup"></select>
</label> <button id="s-restore-global" class="btn">Wiederherstellen</button>
<div class="path-row" style="margin-top:14px">
<button id="s-restore-global" class="btn">Backup wiederherstellen</button>
<span id="s-restore-status" class="hint">Noch kein Backup wiederhergestellt.</span>
</div> </div>
</div>
<small id="s-restore-status" class="hint s-status">Noch kein Backup wiederhergestellt.</small>
<div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true"> <div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div> <div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div> <div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
@@ -649,7 +628,6 @@
</section> </section>
</div> </div>
</div> </div>
<div id="pane-info" class="spane hidden"> <div id="pane-info" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Info</h4> <h4>Info</h4>
@@ -799,6 +777,7 @@
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button id="m-open-folder" class="btn" data-ic="folder">mods-Ordner</button> <button id="m-open-folder" class="btn" data-ic="folder">mods-Ordner</button>
<button id="m-leistung" class="btn" title="Bewährte Performance-Mods passend zu Version und Loader installieren (Sodium, Lithium, FerriteCore …)">⚡ Leistungs-Paket</button>
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn" data-close>Schließen</button> <button class="btn" data-close>Schließen</button>
</div> </div>
@@ -1093,11 +1072,20 @@
<button class="modal-close" data-close>✕</button> <button class="modal-close" data-close>✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mods-tabs stats-tabs">
<button class="mtab on" data-stats-reiter="spielzeit">Spielzeit</button>
<button class="mtab" data-stats-reiter="erfolge">Erfolge <span id="erfolge-zahl"></span></button>
</div>
<div id="stats-reiter-spielzeit">
<div id="stats-tiles" class="stats-tiles"></div> <div id="stats-tiles" class="stats-tiles"></div>
<div class="friends-section-title">Spielzeit pro Instanz</div> <div class="friends-section-title">Spielzeit pro Instanz</div>
<div id="stats-list" class="stats-list" role="list"></div> <div id="stats-list" class="stats-list" role="list"></div>
<p id="stats-unplayed" class="hint"></p> <p id="stats-unplayed" class="hint"></p>
</div> </div>
<div id="stats-reiter-erfolge" class="hidden">
<div id="erfolge-liste" class="erfolge-liste"></div>
</div>
</div>
<div class="modal-foot"> <div class="modal-foot">
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn" data-close>Schließen</button> <button class="btn" data-close>Schließen</button>
@@ -1114,7 +1102,8 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p id="friends-disabled-hint" class="hint hidden">Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.</p> <p id="friends-disabled-hint" class="hint hidden">Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.</p>
<div id="friends-content"> <div id="friends-content" class="freunde-raster">
<div class="freunde-spalte">
<div class="field-row"> <div class="field-row">
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" /> <input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button> <button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
@@ -1122,6 +1111,12 @@
<div id="friends-requests-box"></div> <div id="friends-requests-box"></div>
<div id="friends-list-box"></div> <div id="friends-list-box"></div>
</div> </div>
<!-- 3D-Figur des ausgewählten Freundes (Skin + Umhang), neben der Liste -->
<div id="freund-buehne" class="freund-buehne hidden">
<canvas id="freund-figur"></canvas>
<div class="freund-buehne-text"><b id="freund-buehne-name"></b><span id="freund-buehne-status"></span></div>
</div>
</div>
<!-- Unterhaltung mit einem Freund --> <!-- Unterhaltung mit einem Freund -->
<div id="friends-chat" class="friends-chat hidden"> <div id="friends-chat" class="friends-chat hidden">
<div class="chat-head"> <div class="chat-head">
@@ -1220,6 +1215,10 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p id="skin-konto" class="hint">–</p> <p id="skin-konto" class="hint">–</p>
<div id="skin-armform-hinweis" class="skin-armform-hinweis hidden">
<span id="skin-armform-text"></span>
<button id="skin-armform-fix" class="btn btn-mini btn-primary"></button>
</div>
<div class="skin-vorschau"> <div class="skin-vorschau">
<div class="skin-spalte"> <div class="skin-spalte">
<div class="skin-label">Aktuell</div> <div class="skin-label">Aktuell</div>
@@ -1249,6 +1248,38 @@
</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 class="skin-garderobe">
<div class="garderobe-kopf">
<div class="skin-label">Garderobe <small class="opt">(gespeicherte Skins und Umhänge – mit einem Klick wieder anziehen)</small></div>
<div class="garderobe-knoepfe">
<button id="gard-skin-speichern" class="btn btn-mini" title="Den Skin, den du gerade trägst, in der Garderobe ablegen">Aktuellen Skin speichern</button>
<button id="gard-umhang-speichern" class="btn btn-mini" title="Deinen eigenen AeroMC-Umhang in der Garderobe ablegen">Aktuellen Umhang speichern</button>
</div>
</div>
<div id="garderobe-liste" class="garderobe-liste"></div>
<small class="hint">Vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch hier gesichert – Mojang selbst merkt sich keine alten Skins.</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 +1299,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 +1322,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 +1332,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 +1375,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 +1386,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 +1550,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 +1840,12 @@
</div> </div>
</div> </div>
<!-- Erfolg-Meldungen im Minecraft-Stil (oben rechts) -->
<div id="erfolg-meldungen" class="erfolg-meldungen"></div>
<!-- Konto-Menü (Klick auf den Kontoknopf oben rechts) -->
<div id="konto-menu" class="ctx konto-menu hidden" role="menu"></div>
<div id="ctx-menu" class="ctx hidden"> <div id="ctx-menu" class="ctx hidden">
<button class="ctx-item" data-act="p-edit">Symbol ändern</button> <button class="ctx-item" data-act="p-edit">Symbol ändern</button>
<button class="ctx-item" data-act="p-edit">Umbenennen</button> <button class="ctx-item" data-act="p-edit">Umbenennen</button>
+182
View File
@@ -0,0 +1,182 @@
/*
* Große Ordner kopieren, ohne den Launcher einzufrieren.
*
* Früher liefen Importe mit fs.cpSync – synchron im Hauptprozess. Bei
* Spielständen mit vielen Gigabyte stand dann der komplette Launcher
* minutenlang still (keine Anzeige, kein Abbrechen, „Keine Rückmeldung“).
*
* Hier wird asynchron gearbeitet: erst Dateien und Größe zählen, dann mit
* mehreren Dateien gleichzeitig kopieren. Große Dateien gehen stückweise, damit
* der Fortschritt auch bei einer einzelnen 4-GB-Datei weiterläuft. Gesperrte
* Einzeldateien werden übersprungen und gemeldet statt alles abzubrechen.
*/
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { pipeline } = require('stream/promises');
const GLEICHZEITIG = 8; // Dateien parallel
const STUECKWEISE_AB = 64 * 1024 * 1024; // ab hier mit Zwischenständen kopieren
const MAX_TIEFE = 64; // Schutz vor Verknüpfungs-Schleifen
const MELDE_ALLE_MS = 200;
class Abgebrochen extends Error {
constructor() { super('Vom Nutzer abgebrochen.'); this.abgebrochen = true; }
}
// Ordner-/Dateieintrag auflösen (Verknüpfungen werden wie ihr Ziel behandelt –
// die Modrinth App verlinkt z. B. Dateien aus ihrem gemeinsamen Speicher)
async function art(voll, eintrag) {
if (!eintrag.isSymbolicLink()) return { ordner: eintrag.isDirectory(), datei: eintrag.isFile() };
const st = await fsp.stat(voll).catch(() => null);
return st ? { ordner: st.isDirectory(), datei: st.isFile() } : { ordner: false, datei: false };
}
/** Zählt Dateien und Bytes unter allen Quellen (Dateien oder Ordner). */
async function zaehle(quellen, job, melde) {
const summe = { dateien: 0, bytes: 0 };
let zuletzt = 0;
async function lauf(voll, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(voll).catch(() => null);
if (!st) return;
if (st.isFile()) { summe.dateien++; summe.bytes += st.size; }
else if (st.isDirectory() && tiefe < MAX_TIEFE) {
const eintraege = await fsp.readdir(voll, { withFileTypes: true }).catch(() => []);
for (const e of eintraege) {
const p = path.join(voll, e.name);
const a = await art(p, e);
if (a.ordner) await lauf(p, tiefe + 1);
else if (a.datei) {
const s = await fsp.stat(p).catch(() => null);
if (s) { summe.dateien++; summe.bytes += s.size; }
}
}
}
if (melde && Date.now() - zuletzt > MELDE_ALLE_MS) { zuletzt = Date.now(); melde({ ...summe }); }
}
for (const q of quellen) await lauf(q, 0);
if (melde) melde({ ...summe });
return summe;
}
/** Freier Platz (Bytes) auf dem Laufwerk von `ordner`, null wenn unbekannt. */
async function freierPlatz(ordner) {
try {
let pruefe = ordner;
while (!fs.existsSync(pruefe) && path.dirname(pruefe) !== pruefe) pruefe = path.dirname(pruefe);
const s = await fsp.statfs(pruefe);
return s.bavail * s.bsize;
} catch { return null; }
}
/**
* Fortschritt über mehrere Kopiervorgänge hinweg (z. B. alle Instanzen eines
* Imports). `melde` bekommt höchstens alle 200 ms einen Zwischenstand.
*/
function neuerFortschritt(gesamt, melde) {
const f = {
gesamtBytes: gesamt.bytes || 0, gesamtDateien: gesamt.dateien || 0,
bytes: 0, dateien: 0, datei: '', uebersprungen: [],
beginn: Date.now(), zuletzt: 0, verlauf: [],
};
f.melden = (sofort) => {
const jetzt = Date.now();
if (!melde || (!sofort && jetzt - f.zuletzt < MELDE_ALLE_MS)) return;
f.zuletzt = jetzt;
// Tempo über die letzten ~5 Sekunden glätten
f.verlauf.push([jetzt, f.bytes]);
while (f.verlauf.length > 2 && jetzt - f.verlauf[0][0] > 5000) f.verlauf.shift();
const [t0, b0] = f.verlauf[0];
const tempo = jetzt > t0 ? ((f.bytes - b0) * 1000) / (jetzt - t0) : 0;
// erst nach 1,5 s schätzen – davor gibt es noch kein brauchbares Tempo
const eingeschwungen = jetzt - f.beginn > 1500;
const rest = eingeschwungen && tempo > 0 && f.gesamtBytes > f.bytes ? Math.round((f.gesamtBytes - f.bytes) / tempo) : null;
melde({
bytes: f.bytes, gesamtBytes: f.gesamtBytes, dateien: f.dateien, gesamtDateien: f.gesamtDateien,
datei: f.datei, tempo: eingeschwungen ? Math.round(tempo) : 0, restSekunden: rest, uebersprungen: f.uebersprungen.length,
});
};
return f;
}
async function kopiereDatei(von, nach, groesse, fortschritt, job) {
if (groesse >= STUECKWEISE_AB) {
// große Datei: stückweise, damit der Balken weiterläuft
const lesen = fs.createReadStream(von, { highWaterMark: 4 * 1024 * 1024 });
lesen.on('data', (stueck) => {
if (job && job.abgebrochen) { lesen.destroy(new Abgebrochen()); return; }
if (fortschritt) { fortschritt.bytes += stueck.length; fortschritt.melden(); }
});
await pipeline(lesen, fs.createWriteStream(nach));
} else {
await fsp.copyFile(von, nach);
if (fortschritt) fortschritt.bytes += groesse;
}
}
/**
* Kopiert `von` (Datei oder Ordner) nach `nach`. Läuft asynchron, meldet über
* `fortschritt` (siehe neuerFortschritt) und bricht ab, sobald job.abgebrochen.
* Gesperrte Einzeldateien landen in fortschritt.uebersprungen; volle Platte
* (ENOSPC) und Abbruch beenden den ganzen Vorgang mit Fehler.
*/
async function kopiere(von, nach, fortschritt, job) {
const laufend = new Set();
let schwererFehler = null;
async function platz(aufgabe) {
while (laufend.size >= GLEICHZEITIG) await Promise.race(laufend);
if (schwererFehler) throw schwererFehler;
const p = aufgabe().catch((err) => {
if (err && (err.abgebrochen || err.code === 'ENOSPC')) schwererFehler = schwererFehler || err;
else if (fortschritt) fortschritt.uebersprungen.push({ datei: err && err.path ? err.path : '?', grund: err && err.code ? err.code : String(err && err.message) });
}).finally(() => laufend.delete(p));
laufend.add(p);
}
const basis = von;
async function datei(q, z) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(q);
if (fortschritt) fortschritt.datei = path.relative(path.dirname(basis), q);
await kopiereDatei(q, z, st.size, fortschritt, job);
if (fortschritt) { fortschritt.dateien++; fortschritt.melden(); }
}
async function ordner(q, z, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
if (schwererFehler) throw schwererFehler;
await fsp.mkdir(z, { recursive: true });
if (tiefe >= MAX_TIEFE) return;
const eintraege = await fsp.readdir(q, { withFileTypes: true });
for (const e of eintraege) {
const qq = path.join(q, e.name);
const zz = path.join(z, e.name);
const a = await art(qq, e);
if (a.ordner) await ordner(qq, zz, tiefe + 1);
else if (a.datei) await platz(() => datei(qq, zz));
}
}
let fehler = null;
try {
const st = await fsp.stat(von);
if (st.isDirectory()) await ordner(von, nach, 0);
else {
await fsp.mkdir(path.dirname(nach), { recursive: true });
await platz(() => datei(von, nach));
}
} catch (err) {
fehler = err;
}
// erst zurückkehren, wenn keine Datei mehr geschrieben wird – sonst würde
// das Aufräumen nach einem Abbruch mit noch laufenden Kopien kollidieren
while (laufend.size) await Promise.allSettled([...laufend]);
if (fehler) throw fehler;
if (schwererFehler) throw schwererFehler;
if (job && job.abgebrochen) throw new Abgebrochen();
if (fortschritt) fortschritt.melden(true);
}
module.exports = { zaehle, kopiere, freierPlatz, neuerFortschritt, Abgebrochen };
+11
View File
@@ -301,10 +301,18 @@ function collectArgs(argList, map) {
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente) // führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
function mergeLoader(vj, lp) { function mergeLoader(vj, lp) {
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass }); const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
if (vj.arguments || lp.arguments) {
merged.arguments = { merged.arguments = {
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])], jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])], game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
}; };
} else if (lp.minecraftArguments) {
// altes Format (< 1.13, z. B. Forge 1.12.2): Die Loader-Fassung enthält die
// kompletten Spielargumente samt --tweakClass. Früher entstand hier eine
// leere "arguments"-Liste – dann fehlten Klassenpfad (-cp) und Natives im
// Startbefehl, und Java fand net.minecraft.launchwrapper.Launch nicht.
merged.minecraftArguments = lp.minecraftArguments;
}
return merged; return merged;
} }
@@ -344,6 +352,8 @@ function buildArgs(vj, ctx) {
// RAM // RAM
jvm = ['-Xms' + ctx.minMemMb + 'M', '-Xmx' + ctx.maxMemMb + 'M', ...jvm]; jvm = ['-Xms' + ctx.minMemMb + 'M', '-Xmx' + ctx.maxMemMb + 'M', ...jvm];
if (ctx.extraArgs) jvm = [...ctx.extraArgs.split(' ').filter(Boolean), ...jvm]; if (ctx.extraArgs) jvm = [...ctx.extraArgs.split(' ').filter(Boolean), ...jvm];
// AeroMC-Umhang-Agent (zeigt eigene Umhänge im Spiel, siehe agent/)
if (ctx.javaAgent) jvm = ['-javaagent:' + ctx.javaAgent, ...jvm];
return [...jvm, vj.mainClass, ...game]; return [...jvm, vj.mainClass, ...game];
} }
@@ -528,6 +538,7 @@ async function prepareAndLaunch(opts, onProgress) {
minMemMb: opts.minMemMb || 2048, minMemMb: opts.minMemMb || 2048,
maxMemMb: opts.maxMemMb || 4096, maxMemMb: opts.maxMemMb || 4096,
extraArgs: opts.extraArgs || '', extraArgs: opts.extraArgs || '',
javaAgent: opts.javaAgent || '',
}); });
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente) // Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
+16 -2
View File
@@ -54,6 +54,20 @@ function mavenToUrl(base, name) {
} }
// verfügbare Loader-Versionen (für die Anzeige/Auswahl) // verfügbare Loader-Versionen (für die Anzeige/Auswahl)
// Versionsnummern zahlenweise vergleichen ("36.2.39" > "36.2.4"), neueste zuerst.
// Die Maven-Listen von Forge/NeoForge sind nicht verlässlich sortiert – früher
// landete bei Forge 1.16.5 dadurch die ÄLTESTE Version (36.0.0) vorn.
function neuesteZuerst(a, b) {
const ta = String(a).split(/[.\-+]/), tb = String(b).split(/[.\-+]/);
for (let i = 0; i < Math.max(ta.length, tb.length); i++) {
const x = ta[i] || '0', y = tb[i] || '0';
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
if (nx && ny && Number(x) !== Number(y)) return Number(y) - Number(x);
if (!(nx && ny) && x !== y) return nx ? -1 : ny ? 1 : y.localeCompare(x); // reine Zahl vor "beta"
}
return 0;
}
async function listLoaderVersions(loader, mcVersion) { async function listLoaderVersions(loader, mcVersion) {
if (loader === 'fabric' || loader === 'quilt') { if (loader === 'fabric' || loader === 'quilt') {
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`); const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
@@ -63,14 +77,14 @@ async function listLoaderVersions(loader, mcVersion) {
const xml = await fetchText(FORGE_META); const xml = await fetchText(FORGE_META);
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]); const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
const prefix = mcVersion + '-'; const prefix = mcVersion + '-';
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).reverse(); const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).sort(neuesteZuerst);
return versions.map((v, i) => ({ version: v, stable: i === 0 })); return versions.map((v, i) => ({ version: v, stable: i === 0 }));
} }
if (loader === 'neoforge') { if (loader === 'neoforge') {
const xml = await fetchText(NEOFORGE_META); const xml = await fetchText(NEOFORGE_META);
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]); const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
const prefix = neoforgePrefix(mcVersion); const prefix = neoforgePrefix(mcVersion);
const versions = all.filter((v) => v.startsWith(prefix)).reverse(); const versions = all.filter((v) => v.startsWith(prefix)).sort(neuesteZuerst);
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) })); return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
} }
return []; return [];
+622 -93
View File
@@ -18,6 +18,8 @@ let ffmpegPfad = null;
try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ } try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
const pkg = require('../package.json'); const pkg = require('../package.json');
const store = require('./store'); const store = require('./store');
const startanimation = require('./startanimation');
const fensteraufbau = require('./fensteraufbau');
const services = require('./services'); const services = require('./services');
const launcher = require('./launcher'); const launcher = require('./launcher');
const loaders = require('./loaders'); const loaders = require('./loaders');
@@ -28,6 +30,10 @@ 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 moderkennung = require('./moderkennung');
const garderobe = require('./garderobe');
const erfolge = require('./erfolge');
const cfimport = require('./cfimport'); const cfimport = require('./cfimport');
const mrimport = require('./mrimport'); const mrimport = require('./mrimport');
const mcimport = require('./mcimport'); const mcimport = require('./mcimport');
@@ -118,9 +124,9 @@ const POPOUT_SPEC = {
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 }, login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 }, import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 }, instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 },
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 }, update: { title: 'AeroMC – Update', width: 600, height: 640, minWidth: 440, minHeight: 440 },
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 }, record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 }, friends: { title: 'AeroMC – Freunde', width: 780, height: 620, minWidth: 360, minHeight: 480 },
stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 }, stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 },
}; };
const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 }; const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 };
@@ -154,14 +160,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 +199,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 +222,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) => {
@@ -244,6 +265,10 @@ const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i;
ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null); ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null);
const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses
// Instanzen, deren Spiel gerade läuft (für den pulsierenden Rand der Karte)
const laufendeInstanzen = new Set();
function meldeLaufende() { broadcast('game:laufend', [...laufendeInstanzen]); }
ipcMain.handle('game:laufend', () => [...laufendeInstanzen]);
const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss auf DEM Fenster const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss auf DEM Fenster
// des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-Fenster // des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-Fenster
// (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein // (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein
@@ -752,7 +777,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 +798,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
@@ -928,6 +953,9 @@ app.whenReady().then(() => {
// selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen // selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen
services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java'))); services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java')));
// Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden) // Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden)
moderkennung.init(app.getPath('userData'));
garderobe.init(app.getPath('userData'));
erfolge.init(app.getPath('userData'));
auth.init(app.getPath('userData'), { auth.init(app.getPath('userData'), {
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } }, available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
encrypt: (s) => safeStorage.encryptString(s).toString('base64'), encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
@@ -939,8 +967,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
@@ -1108,7 +1141,14 @@ ipcMain.handle('mods:search', async (_e, opts) => {
} }
}); });
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod')); // Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
async function modsMitErkennung(id, art) {
const liste = store.listMods(id, art);
try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); }
catch { return liste; }
}
ipcMain.handle('mods:list', (_e, id, art) => modsMitErkennung(id, art || 'mod'));
ipcMain.handle('mods:install', (_e, id, project, art) => installiereInhalt(id, project, art)); ipcMain.handle('mods:install', (_e, id, project, art) => installiereInhalt(id, project, art));
@@ -1128,7 +1168,7 @@ async function installiereInhalt(id, project, art) {
} }
const modsDir = store.inhaltsDir(id, art || 'mod'); const modsDir = store.inhaltsDir(id, art || 'mod');
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId)); const done = new Set((await modsMitErkennung(id, art || 'mod')).map((m) => m.projectId));
const added = []; const added = [];
let firstError = null; let firstError = null;
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-'))); const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
@@ -1187,6 +1227,7 @@ async function installiereInhalt(id, project, art) {
await installOne(project, false); await installOne(project, false);
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' }; if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
pruefeErfolge();
return { ok: true, entry: added[0], added, depCount: added.length - 1 }; return { ok: true, entry: added[0], added, depCount: added.length - 1 };
} }
@@ -1545,6 +1586,16 @@ ipcMain.handle('crash:fix', async (_e, id, aktion) => {
broadcast('instances:changed'); broadcast('instances:changed');
return { ok: true, text: 'Beim nächsten Start wählt AeroMC das passende Java und lädt es bei Bedarf herunter.' }; return { ok: true, text: 'Beim nächsten Start wählt AeroMC das passende Java und lädt es bei Bedarf herunter.' };
} }
case 'forge-aktualisieren': {
// neueste Forge-Version für dieselbe Minecraft-Version eintragen
const liste = await loaders.listLoaderVersions(inst.minecraft.loader || 'forge', inst.minecraft.version);
const neueste = liste && liste[0] && liste[0].version;
if (!neueste) return { ok: false, message: 'Keine neuere Forge-Version gefunden.' };
if (neueste === inst.minecraft.loaderVersion) return { ok: false, message: `Forge ${neueste} ist bereits die neueste Version für Minecraft ${inst.minecraft.version}.` };
store.updateInstance(id, { minecraft: { loaderVersion: neueste } });
broadcast('instances:changed');
return { ok: true, text: `Forge auf ${neueste} aktualisiert (vorher ${inst.minecraft.loaderVersion || 'unbekannt'}). Beim nächsten Start wird es eingerichtet.` };
}
case 'mods-ordner': { case 'mods-ordner': {
const dir = store.inhaltsDir(id, 'mod'); const dir = store.inhaltsDir(id, 'mod');
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -1559,6 +1610,115 @@ ipcMain.handle('crash:fix', async (_e, id, aktion) => {
} }
}); });
// ---------- Erfolge ----------
// Werte aus dem, was der Launcher weiß; mehrere Anlässe kurz hintereinander
// werden zu einer Prüfung zusammengefasst.
let erfolgMerker = {};
let erfolgFreunde = 0;
let erfolgTimer = null;
function pruefeErfolge(merker) {
Object.assign(erfolgMerker, merker || {});
clearTimeout(erfolgTimer);
erfolgTimer = setTimeout(() => {
try {
const alle = store.listInstances();
const gespielt = alle.filter((i) => (i.launchCount || 0) > 0);
let mods = 0;
for (const i of alle) {
if (!i.minecraft || !i.minecraft.loader || i.minecraft.loader === 'vanilla') continue;
try { mods += store.listMods(i.id, 'mod').filter((m) => !m.fehlt).length; } catch { /* egal */ }
}
const werte = {
starts: alle.reduce((s, i) => s + (i.launchCount || 0), 0),
sekunden: alle.reduce((s, i) => s + (i.totalPlaySeconds || 0), 0),
instanzen: alle.length,
versionen: new Set(gespielt.map((i) => i.minecraft && i.minecraft.version).filter(Boolean)).size,
mods,
modpacks: alle.filter((i) => i.modpack && (i.modpack.projectId || i.modpack.name)).length,
freunde: erfolgFreunde,
};
const merkerJetzt = erfolgMerker;
erfolgMerker = {};
const { neu, ersteAuswertung } = erfolge.pruefe(werte, merkerJetzt);
if (!neu.length) return;
broadcast('erfolge:neu', { liste: neu, ersteAuswertung });
if (neu.length === 1) notify('Erfolg erzielt!', `${neu[0].titel} – ${neu[0].text}`);
else notify(`${neu.length} Erfolge erzielt!`, neu.map((e) => e.titel).join(', '));
} catch { /* Erfolge sind Zierde */ }
}, 1500);
}
ipcMain.handle('erfolge:liste', () => erfolge.liste());
// Hauptfenster bereit -> einmal prüfen (u. a. für bestehende Nutzer nach dem Update)
hauptfensterBereit.then(() => setTimeout(async () => {
// Freunde einmal zählen (sonst erst beim Öffnen des Freunde-Fensters)
try {
const konto = store.getSettings().friendsEnabled !== false ? aktivesFreundeKonto() : null;
if (konto) {
const res = await friends.getFriends(konto.uuid);
if (res.ok && Array.isArray(res.data)) erfolgFreunde = res.data.length;
}
} catch { /* offline */ }
pruefeErfolge();
}, 4000));
// ---------- Leistungs-Paket: bewährte Performance-Mods mit einem Klick ----------
// Je Platz die erste Mod, die es für Version + Loader gibt (z. B. Sodium, sonst
// Embeddium). Kurznamen werden erst in Modrinth-IDs umgewandelt – installierte
// Mods (auch von Hand kopierte, siehe moderkennung) tragen diese IDs.
const LEISTUNGS_PAKET = [
{ zweck: 'Grafik', mods: ['sodium', 'embeddium', 'rubidium'], grafik: true },
{ zweck: 'Spiellogik', mods: ['lithium', 'radium'] },
{ zweck: 'Arbeitsspeicher', mods: ['ferrite-core'] },
{ zweck: 'Unsichtbares nicht zeichnen', mods: ['entityculling'] },
{ zweck: 'Oberfläche/Text', mods: ['immediatelyfast'] },
{ zweck: 'Ladezeit', mods: ['modernfix'] },
{ zweck: 'Weniger Last im Hintergrund', mods: ['dynamic-fps'] },
];
ipcMain.handle('mods:leistungsPaket', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const loader = inst.minecraft.loader;
if (!loader || loader === 'vanilla' || loader === 'optifine') {
return { ok: false, message: 'Das Leistungs-Paket braucht einen Mod-Loader (Fabric, Quilt, Forge oder NeoForge).' };
}
// Kurznamen -> Modrinth-IDs (ein Abruf für alle)
const slugs = [...new Set(LEISTUNGS_PAKET.flatMap((p) => p.mods))];
let ids = {};
try {
const res = await fetch(`https://api.modrinth.com/v2/projects?ids=${encodeURIComponent(JSON.stringify(slugs))}`, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
for (const p of await res.json()) ids[p.slug] = { id: p.id, title: p.title };
} catch { return { ok: false, message: 'Modrinth ist gerade nicht erreichbar.' }; }
const vorhanden = await modsMitErkennung(id, 'mod');
const hatOptifine = vorhanden.some((m) => m.enabled !== false && /optifine|optifabric/i.test(String(m.filename) + ' ' + String(m.title)));
const installiert = new Set(vorhanden.map((m) => m.projectId).filter(Boolean));
const ergebnis = { neu: [], schonDa: [], nichtVerfuegbar: [], uebersprungen: [] };
let nr = 0;
for (const platz of LEISTUNGS_PAKET) {
nr++;
if (platz.grafik && hatOptifine) { ergebnis.uebersprungen.push(`${platz.zweck} (OptiFine ist installiert)`); continue; }
const kandidaten = platz.mods.map((s) => ids[s]).filter(Boolean);
const da = kandidaten.find((k) => installiert.has(k.id));
if (da) { ergebnis.schonDa.push(da.title); continue; }
let geschafft = false;
for (const k of kandidaten) {
broadcast('mods:paketFortschritt', { nr, von: LEISTUNGS_PAKET.length, titel: k.title });
const r = await installiereInhalt(id, { projectId: k.id, title: k.title }, 'mod').catch((err) => ({ ok: false, message: err.message }));
if (r.ok) {
ergebnis.neu.push(k.title);
for (const a of r.added || []) if (a.projectId) installiert.add(a.projectId);
geschafft = true;
break;
}
}
if (!geschafft) ergebnis.nichtVerfuegbar.push(kandidaten.map((k) => k.title).join(' / ') || platz.mods[0]);
}
if (ergebnis.neu.length) broadcast('instances:changed');
if (ergebnis.neu.length || ergebnis.schonDa.length) pruefeErfolge({ leistung: true });
return Object.assign({ ok: true }, ergebnis);
});
// nach einer Lösung gleich erneut starten (Start läuft im Hauptfenster) // nach einer Lösung gleich erneut starten (Start läuft im Hauptfenster)
ipcMain.handle('crash:relaunch', (_e, id) => { ipcMain.handle('crash:relaunch', (_e, id) => {
if (!store.getInstance(id)) return false; if (!store.getInstance(id)) return false;
@@ -1788,10 +1948,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 }; }
}); });
@@ -1818,8 +1993,9 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
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 {
await sichereAktuellenSkin(token);
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 }; }
@@ -1829,13 +2005,114 @@ ipcMain.handle('skin:reset', 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 {
await sichereAktuellenSkin(token);
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 }; }
}); });
// ---------- Passt die Armform zum Skin? ----------
// Steht das Konto auf "schmal", die Skin-Datei ist aber für breite Arme gemalt
// (oder umgekehrt), sieht man im Spiel gequetschte Arme bzw. dunkle Streifen.
async function aktuelleSkinDatei(token) {
const p = await profilMitSpielSkin(token);
if (!p.ok || !p.skinUrl) return null;
const res = await fetch(p.skinUrl);
if (!res.ok) return null;
const daten = Buffer.from(await res.arrayBuffer());
const bild = nativeImage.createFromBuffer(daten);
const { width, height } = bild.getSize();
return { daten, konto: p.variante === 'slim' ? 'slim' : 'classic', datei: skins.armformDerTextur(bild.toBitmap(), width, height) };
}
ipcMain.handle('skin:armformPruefen', async () => {
const token = await aktivesToken();
if (!token) return { ok: false };
try {
const s = await aktuelleSkinDatei(token);
if (!s) return { ok: false };
return { ok: true, konto: s.konto, datei: s.datei, passt: !s.datei || s.datei === s.konto };
} catch { return { ok: false }; }
});
ipcMain.handle('skin:armformKorrigieren', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-armform.png');
try {
const s = await aktuelleSkinDatei(token);
if (!s || !s.datei) return { ok: false, message: 'Die Armform der Skin-Datei ließ sich nicht eindeutig bestimmen.' };
if (s.datei === s.konto) return { ok: true, variante: s.konto, unveraendert: true };
fs.writeFileSync(tmp, s.daten);
// derselbe Skin, nur mit der passenden Armform neu hochgeladen
const res = await skins.setzeSkin(token, tmp, s.datei);
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'armform' }); }
return res && res.ok ? { ok: true, variante: s.datei } : res;
} catch (err) {
return { ok: false, message: 'Umstellen fehlgeschlagen: ' + err.message };
} finally {
try { fs.unlinkSync(tmp); } catch { /* egal */ }
}
});
// ---------- Garderobe (gespeicherte Skins und Umhänge) ----------
// Bisherigen Skin vor einem Wechsel sichern – Mojang merkt sich keine alten Skins.
// Klappt das nicht (offline …), wird trotzdem gewechselt.
async function sichereAktuellenSkin(token, name, automatisch = true) {
try {
const s = await aktuelleSkinDatei(token);
if (!s) return null;
// Armform aus der Datei, wenn eindeutig – sonst die des Kontos (so kommt beim
// Wieder-Anziehen nicht ein falsch eingestellter, gequetschter Zustand zurück)
return garderobe.hinzufuegen({ art: 'skin', daten: s.daten, variante: s.datei || s.konto, automatisch,
name: name || 'Gesichert am ' + new Date().toLocaleDateString('de-DE') });
} catch { return null; }
}
ipcMain.handle('garderobe:liste', () => garderobe.liste());
ipcMain.handle('garderobe:skinSpeichern', async (_e, name) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const r = await sichereAktuellenSkin(token, name, false);
if (!r) return { ok: false, message: 'Dein Konto nutzt den Standard-Skin oder er ist gerade nicht abrufbar.' };
return { ok: true, neu: r.neu, name: r.eintrag.name };
});
ipcMain.handle('garderobe:umhangSpeichern', async (_e, name) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
const r = await umhangFuer(konto.uuid);
if (!r || !r.dataUri || r.quelle !== 'aeromc') return { ok: false, message: 'Du trägst gerade keinen eigenen AeroMC-Umhang.' };
const daten = Buffer.from(r.dataUri.split(',')[1], 'base64');
const e = garderobe.hinzufuegen({ art: 'umhang', daten, name: name || 'Umhang', automatisch: false });
return { ok: true, neu: e.neu, name: e.eintrag.name };
});
ipcMain.handle('garderobe:anziehen', async (_e, id) => {
const eintrag = garderobe.holen(id);
if (!eintrag) return { ok: false, message: 'Eintrag nicht gefunden.' };
if (eintrag.art === 'umhang') {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
const base64 = fs.readFileSync(eintrag.pfad).toString('base64');
const res = await friends.uploadCape(konto.uuid, base64);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? { ok: true } : umhangFehler(res);
}
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
await sichereAktuellenSkin(token);
const res = await skins.setzeSkin(token, eintrag.pfad, eintrag.variante);
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'garderobe' }); }
return res && res.ok ? { ok: true } : (res || { ok: false, message: 'Skin konnte nicht gesetzt werden.' });
});
ipcMain.handle('garderobe:umbenennen', (_e, id, name) => ({ ok: garderobe.umbenennen(id, name) }));
ipcMain.handle('garderobe:loeschen', (_e, id) => ({ ok: garderobe.loeschen(id) }));
// ---------- Skin-Editor ---------- // ---------- Skin-Editor ----------
// Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net) // Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net)
// würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt // würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt
@@ -1852,7 +2129,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 +2141,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' }); pruefeErfolge({ umhang: true }); }
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;
@@ -1892,8 +2339,9 @@ ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png'); const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png');
try { try {
fs.writeFileSync(tmp, daten); fs.writeFileSync(tmp, daten);
await sichereAktuellenSkin(token);
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' }); pruefeErfolge({ 'skin-editor': true }); }
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 +2923,49 @@ 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; if (res.ok && Array.isArray(res.data) && res.data.length > erfolgFreunde) { erfolgFreunde = res.data.length; pruefeErfolge(); }
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 +2979,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 +3052,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);
@@ -3294,6 +3778,22 @@ ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => {
* Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per * Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per
* Drag&Drop aufs Fenster kommt. * Drag&Drop aufs Fenster kommt.
*/ */
// Inhalt eines (eigenen, temporären) Ordners in den Zielordner übernehmen:
// verschieben statt kopieren – auf demselben Laufwerk ist das sofort erledigt,
// sonst (anderes Laufwerk) wird im Hintergrund kopiert. Nichts davon blockiert.
async function verschiebeInhalt(quelle, ziel, nur) {
await fs.promises.mkdir(ziel, { recursive: true });
for (const name of nur || await fs.promises.readdir(quelle)) {
const von = path.join(quelle, name);
const nach = path.join(ziel, name);
try {
await fs.promises.rename(von, nach);
} catch {
await kopieren.kopiere(von, nach, null, null);
}
}
}
async function importInstanceZipFromPath(filePath) { async function importInstanceZipFromPath(filePath) {
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now()); const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
try { try {
@@ -3313,8 +3813,10 @@ async function importInstanceZipFromPath(filePath) {
if (!fs.existsSync(path.join(base, 'instance.json'))) { if (!fs.existsSync(path.join(base, 'instance.json'))) {
const mmc = mmcimport.scanSingle(tmp); const mmc = mmcimport.scanSingle(tmp);
if (mmc.ok) { if (mmc.ok) {
const done = mmcimport.importOne(store, mmc.instance, true); const done = mmcimport.importOne(store, mmc.instance, false);
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: done.copied }; const quellen = mmcimport.kopierQuellen(mmc.instance);
if (quellen.length) await verschiebeInhalt(mmc.instance.gameDir, store.gameDir(done.id), quellen.map((q) => q.name));
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: quellen.length > 0 };
} }
throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).'); throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).');
} }
@@ -3328,7 +3830,7 @@ async function importInstanceZipFromPath(filePath) {
if (imported.java) store.updateInstance(created.id, { java: imported.java }); if (imported.java) store.updateInstance(created.id, { java: imported.java });
const mcSrc = path.join(base, 'minecraft'); const mcSrc = path.join(base, 'minecraft');
if (fs.existsSync(mcSrc)) fs.cpSync(mcSrc, store.gameDir(created.id), { recursive: true }); if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
const modsSrc = path.join(base, 'mods.json'); const modsSrc = path.join(base, 'mods.json');
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json')); if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) { if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) {
@@ -3339,7 +3841,8 @@ async function importInstanceZipFromPath(filePath) {
} catch (err) { } catch (err) {
return { ok: false, message: 'Import fehlgeschlagen: ' + err.message }; return { ok: false, message: 'Import fehlgeschlagen: ' + err.message };
} finally { } finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ } // Reste im Hintergrund löschen (bei großen Archiven sonst minutenlang blockiert)
fs.promises.rm(tmp, { recursive: true, force: true }).catch(() => { /* egal */ });
} }
} }
@@ -3602,6 +4105,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 || '',
@@ -3651,6 +4155,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
const res = await launcher.prepareAndLaunch(opts, (evt) => { const res = await launcher.prepareAndLaunch(opts, (evt) => {
if (evt.phase === 'launch') { if (evt.phase === 'launch') {
launchStart = Date.now(); launchStart = Date.now();
laufendeInstanzen.add(id);
meldeLaufende();
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); } if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
discordpresence.setActivity({ discordpresence.setActivity({
details: 'Spielt Minecraft', details: 'Spielt Minecraft',
@@ -3686,6 +4192,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
herzschlagJetzt = null; herzschlagJetzt = null;
if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId); if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId);
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
laufendeInstanzen.delete(id);
meldeLaufende();
// beim nächsten Start dieses Kontos sollen bereits online befindliche // beim nächsten Start dieses Kontos sollen bereits online befindliche
// Freunde wieder gemeldet werden // Freunde wieder gemeldet werden
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); } if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
@@ -3700,6 +4208,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
} }
const cur = store.getInstance(id); const cur = store.getInstance(id);
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs }); if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
pruefeErfolge();
} }
} }
if (evt.phase === 'log') { if (evt.phase === 'log') {
@@ -3726,12 +4235,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (res.ok) { if (res.ok) {
const aktuell = store.getInstance(id); const aktuell = store.getInstance(id);
store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 }); store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 });
pruefeErfolge();
} }
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length; if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
if (res.ok && res.pid) laufendePid.set(id, res.pid); if (res.ok && res.pid) laufendePid.set(id, res.pid);
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); } if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
return res; return res;
} catch (err) { } catch (err) {
if (laufendeInstanzen.delete(id)) meldeLaufende();
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); } if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; } if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; } if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
@@ -3811,20 +4322,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);
let fortschritt = null;
try { try {
const imported = mmcimport.importOne(store, entry, copyData !== false); // 1) Was wird kopiert und wie viel?
done.push(imported); 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;
} }
catch (err) { failed.push({ name: entry.name, message: err.message }); } // 2) Reicht der Platz? (etwas Luft für Windows lassen)
broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length }); 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.` };
}
}
// 3) Instanz für Instanz – ein gemeinsamer Zähler über alle Instanzen
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 +4421,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 +4483,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 || [];
for (const entry of liste) {
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); } try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
catch { imported.modpack = false; } 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 +4503,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 +4518,7 @@ ipcMain.handle('technic:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('technic:import', (_e, entries, copyData) => { ipcMain.handle('technic:import', (_e, entries, copyData) => fuehreImportAus(technicimport, entries, copyData !== false));
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = technicimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ---- // ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
+20 -5
View File
@@ -173,16 +173,31 @@ function scan(inputPath) {
return { ok: true, rootDir, count: list.length, instances: list }; return { ok: true, rootDir, count: list.length, instances: list };
} }
function kopierNamen(sourceDir, usesDefaultGameDir) {
return fs.readdirSync(sourceDir).filter((name) => {
if (/^launcher_profiles.*\.json$/i.test(name)) return false;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') return false;
return !(usesDefaultGameDir && !shouldCopyRootEntry(name));
});
}
function copyInto(sourceDir, targetDir, usesDefaultGameDir) { function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of kopierNamen(sourceDir, usesDefaultGameDir)) {
if (/^launcher_profiles.*\.json$/i.test(name)) continue;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir, !!entry.usesDefaultGameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
@@ -214,4 +229,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot }; module.exports = { scan, importOne, kopierQuellen, parseLastVersionId, resolveLauncherRoot };
+12 -1
View File
@@ -185,4 +185,15 @@ 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, 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 };
+117
View File
@@ -0,0 +1,117 @@
/*
* Erkennt Mods, die nicht über AeroMC installiert wurden (von Hand kopiert,
* aus MultiMC/CurseForge/Modrinth App übernommen, aus einem Modpack …).
*
* Solche Dateien haben keine Projekt-Zuordnung, deshalb zeigte der Mod-Katalog
* z. B. bei Fabric API „Installieren“, obwohl sie längst im mods-Ordner lag.
* Hier wird je Datei die SHA-1-Prüfsumme gebildet und gesammelt bei Modrinth
* nachgeschlagen (POST /v2/version_files). Ergebnisse werden zwischengespeichert
* (Pfad + Größe + Änderungszeit), damit nicht bei jedem Öffnen neu gerechnet wird.
* Ohne Netz bleibt einfach alles wie bisher.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const API = 'https://api.modrinth.com/v2';
const UA = 'AeroMC-Launcher';
const NICHT_GEFUNDEN_NEU_PRUEFEN_MS = 7 * 24 * 60 * 60 * 1000;
let cacheDatei = null;
let cache = null;
function init(userDataDir) {
cacheDatei = path.join(userDataDir, 'mod-erkennung.json');
}
function ladeCache() {
if (cache) return cache;
try { cache = JSON.parse(fs.readFileSync(cacheDatei, 'utf8')); } catch { cache = {}; }
return cache;
}
function speichereCache() {
try { fs.writeFileSync(cacheDatei, JSON.stringify(cache)); } catch { /* nur Zwischenspeicher */ }
}
function sha1(datei) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha1');
fs.createReadStream(datei).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
});
}
async function holeJson(url, optionen) {
const res = await fetch(url, Object.assign({ headers: { 'User-Agent': UA, 'Content-Type': 'application/json', Accept: 'application/json' } }, optionen));
if (!res.ok) throw new Error('Modrinth ' + res.status);
return res.json();
}
/**
* Ergänzt Einträge ohne projectId um projectId, Titel und Versionsnummer, sofern
* Modrinth die Datei kennt. `dir` ist der Ordner, in dem die Dateien liegen.
*/
async function ergaenze(eintraege, dir) {
const offen = eintraege.filter((e) => e && !e.projectId && e.filename && !e.fehlt);
if (!offen.length || !cacheDatei) return eintraege;
const c = ladeCache();
const jetzt = Date.now();
const zuFragen = new Map(); // sha1 -> [schlüssel]
const schluesselVon = new Map();
for (const e of offen) {
const voll = path.join(dir, e.enabled === false ? e.filename + '.disabled' : e.filename);
let st;
try { st = fs.statSync(voll); } catch { continue; }
const schluessel = `${voll}|${st.size}|${Math.round(st.mtimeMs)}`;
schluesselVon.set(e, schluessel);
const bekannt = c[schluessel];
if (bekannt && (bekannt.projectId || jetzt - bekannt.geprueft < NICHT_GEFUNDEN_NEU_PRUEFEN_MS)) continue;
try {
const hash = (bekannt && bekannt.sha1) || await sha1(voll);
c[schluessel] = { sha1: hash, projectId: null, geprueft: 0 };
if (!zuFragen.has(hash)) zuFragen.set(hash, []);
zuFragen.get(hash).push(schluessel);
} catch { /* Datei gerade gesperrt – beim nächsten Mal */ }
}
if (zuFragen.size) {
try {
const versionen = await holeJson(`${API}/version_files`, {
method: 'POST', body: JSON.stringify({ hashes: [...zuFragen.keys()], algorithm: 'sha1' }),
});
const projektIds = [...new Set(Object.values(versionen).map((v) => v.project_id))];
const titel = {};
for (let i = 0; i < projektIds.length; i += 100) {
const teil = projektIds.slice(i, i + 100);
const projekte = await holeJson(`${API}/projects?ids=${encodeURIComponent(JSON.stringify(teil))}`);
for (const p of projekte) titel[p.id] = p.title;
}
for (const [hash, schluessel] of zuFragen) {
const v = versionen[hash];
for (const s of schluessel) {
c[s] = v
? { sha1: hash, projectId: v.project_id, versionId: v.id, versionNumber: v.version_number, title: titel[v.project_id] || null, geprueft: jetzt }
: { sha1: hash, projectId: null, geprueft: jetzt };
}
}
speichereCache();
} catch {
return eintraege.map((e) => anwenden(e, c, schluesselVon));
}
}
return eintraege.map((e) => anwenden(e, c, schluesselVon));
}
function anwenden(e, c, schluesselVon) {
const s = schluesselVon.get(e);
const t = s && c[s];
if (!t || !t.projectId) return e;
return Object.assign({}, e, {
projectId: t.projectId,
title: t.title || e.title,
versionNumber: e.versionNumber === 'Importiert' && t.versionNumber ? t.versionNumber : e.versionNumber,
erkannt: true, // per Prüfsumme bei Modrinth erkannt, nicht über AeroMC installiert
});
}
module.exports = { init, ergaenze };
+12 -1
View File
@@ -395,6 +395,17 @@ async function scan(eingabe) {
// 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']);
/*
* 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).filter((name) => !NICHT_KOPIEREN.has(name.toLowerCase()))
.map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function copyInto(quelle, ziel) { function copyInto(quelle, ziel) {
fs.mkdirSync(ziel, { recursive: true }); fs.mkdirSync(ziel, { recursive: true });
for (const name of fs.readdirSync(quelle)) { for (const name of fs.readdirSync(quelle)) {
@@ -434,4 +445,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, standardOrdner }; module.exports = { scan, importOne, kopierQuellen, standardOrdner };
+57 -1
View File
@@ -16,6 +16,21 @@ window.addEventListener('unhandledrejection', (e) => {
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason)); ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
}); });
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
let letzterKlick = null;
window.addEventListener('pointerdown', (e) => {
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
if (!t || !t.getBoundingClientRect) return;
const r = t.getBoundingClientRect();
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
}, true);
function frischerKlick() {
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
const { x, y, width, height } = letzterKlick;
return { x, y, width, height };
}
contextBridge.exposeInMainWorld('api', { contextBridge.exposeInMainWorld('api', {
// Einstellungen // Einstellungen
getSettings: () => ipcRenderer.invoke('settings:get'), getSettings: () => ipcRenderer.invoke('settings:get'),
@@ -151,6 +166,32 @@ contextBridge.exposeInMainWorld('api', {
authAccounts: () => ipcRenderer.invoke('auth:accounts'), authAccounts: () => ipcRenderer.invoke('auth:accounts'),
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id), authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
skinProfile: () => ipcRenderer.invoke('skin:profile'), skinProfile: () => ipcRenderer.invoke('skin:profile'),
skinArmformPruefen: () => ipcRenderer.invoke('skin:armformPruefen'),
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
onLaufendeInstanzen: (cb) => {
const l = (_e, d) => cb(d || []);
ipcRenderer.on('game:laufend', l);
return () => ipcRenderer.removeListener('game:laufend', l);
},
onErfolgeNeu: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('erfolge:neu', l);
return () => ipcRenderer.removeListener('erfolge:neu', l);
},
modsLeistungsPaket: (id) => ipcRenderer.invoke('mods:leistungsPaket', id),
onPaketFortschritt: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('mods:paketFortschritt', l);
return () => ipcRenderer.removeListener('mods:paketFortschritt', l);
},
garderobeSkinSpeichern: (name) => ipcRenderer.invoke('garderobe:skinSpeichern', name),
garderobeUmhangSpeichern: (name) => ipcRenderer.invoke('garderobe:umhangSpeichern', name),
garderobeAnziehen: (id) => ipcRenderer.invoke('garderobe:anziehen', id),
garderobeUmbenennen: (id, name) => ipcRenderer.invoke('garderobe:umbenennen', id, name),
garderobeLoeschen: (id) => ipcRenderer.invoke('garderobe:loeschen', id),
skinArmformKorrigieren: () => ipcRenderer.invoke('skin:armformKorrigieren'),
skinChoose: () => ipcRenderer.invoke('skin:choose'), skinChoose: () => ipcRenderer.invoke('skin:choose'),
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante), skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
skinReset: () => ipcRenderer.invoke('skin:reset'), skinReset: () => ipcRenderer.invoke('skin:reset'),
@@ -159,6 +200,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 +237,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 +336,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) => {
+1203 -62
View File
File diff suppressed because it is too large Load Diff
+681 -132
View File
File diff suppressed because it is too large Load Diff
+87 -1
View File
@@ -115,7 +115,93 @@ async function holeProfil(accessToken) {
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,
name: c.alias || 'Umhang',
url: aufHttps(c.url),
aktiv: c.state === 'ACTIVE',
})),
}; };
} }
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps }; /*
* 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 });
}
/*
* Für welche Armform ist eine Skin-Datei gemalt? Breite Arme (Steve) sind 4
* Pixel breit, schmale (Alex) 3 – bei schmalen bleiben deshalb je Arm zwei
* Pixelspalten der Rückseite leer: rechter Arm x 54–55 / y 20–31, linker Arm
* x 46–47 / y 52–63. bitmap: Rohdaten mit 4 Byte je Pixel (Alpha an Stelle 3).
* -> 'classic' | 'slim' | null (nicht eindeutig, alte 64×32-Skins: classic)
*/
function armformDerTextur(bitmap, breite, hoehe) {
if (!bitmap || !breite || breite % 64 !== 0 || hoehe % 32 !== 0) return null;
const f = breite / 64; // HD-Skins (128×128 …) mitskalieren
if (Math.round(hoehe / f) === 32) return 'classic';
if (Math.round(hoehe / f) !== 64) return null;
let deckend = 0, gesamt = 0;
const pruefe = (x0, y0) => {
for (let y = y0 * f; y < (y0 + 12) * f; y++) {
for (let x = x0 * f; x < (x0 + 2) * f; x++) {
gesamt++;
if (bitmap[(y * breite + x) * 4 + 3] > 0) deckend++;
}
}
};
pruefe(54, 20);
pruefe(46, 52);
if (deckend >= gesamt * 0.85) return 'classic';
if (deckend <= gesamt * 0.1) return 'slim';
return null;
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin, armformDerTextur };
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self'" />
<title>AeroMC</title>
<style>
@font-face { font-family: 'AeroPixel'; src: url('assets/aeropixel.ttf'); }
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; user-select: none; cursor: default; }
body { font-family: 'Segoe UI', system-ui, sans-serif; }
/* Logo + Schriftzug in der Mitte */
.marke {
position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
transition: opacity .28s ease, transform .32s ease;
}
.buehne { perspective: 700px; animation: rein .45s cubic-bezier(.2, .9, .3, 1.2) both; }
.logo {
width: 132px; height: 132px; display: block;
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(90, 160, 255, .35));
animation: drehen 1.3s cubic-bezier(.45, .05, .25, 1) .15s infinite;
}
.name {
font-size: 34px; font-weight: 600; letter-spacing: .06em; color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .75), 0 0 2px rgba(0, 0, 0, .9);
animation: schrift .5s ease .25s both;
}
@keyframes rein { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
@keyframes drehen { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
@keyframes schrift { from { opacity: 0; transform: translateY(8px); letter-spacing: .3em; } to { opacity: 1; transform: none; letter-spacing: .06em; } }
/* Schriftzug je Design */
body[data-design="aero"] .name, body[data-design="lila"] .name {
background: linear-gradient(90deg, #7fe3ff, #6c9cff); -webkit-background-clip: text; background-clip: text; color: transparent;
text-shadow: none; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .8));
}
body[data-design="lila"] .name { background-image: linear-gradient(90deg, #e3b8ff, #9d7bff); }
body[data-design="lila"] .logo { filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(170, 120, 255, .4)); }
body[data-design="minecraft"] .name {
font-family: 'AeroPixel', 'Segoe UI', sans-serif; font-weight: 400; font-size: 30px; letter-spacing: .04em;
text-shadow: 3px 3px 0 #3f3f3f, 0 0 10px rgba(0, 0, 0, .8);
}
/* das Launcher-Fenster als Bild, wächst von der Mitte nach außen */
#fenster { position: fixed; inset: 0; width: 100%; height: 100%; visibility: hidden; }
body.aufbau .marke { opacity: 0; transform: scale(.82); }
</style>
</head>
<body>
<div class="marke">
<div class="buehne"><img class="logo" src="assets/logo.png" alt="" /></div>
<div class="name">AeroMC</div>
</div>
<img id="fenster" alt="" />
<script>
document.body.dataset.design = new URLSearchParams(location.search).get('design') || 'dark';
// vom Hauptprozess aufgerufen: Bild des fertigen Launchers von der Mitte aus aufziehen
window.aufbauen = (bildAdresse) => new Promise((fertig) => {
const bild = document.getElementById('fenster');
bild.onerror = () => fertig(false);
bild.onload = () => {
const v = Math.max(0, innerHeight / 2 - 66);
const h = Math.max(0, innerWidth / 2 - 66);
bild.style.visibility = 'visible';
document.body.classList.add('aufbau');
bild.animate([
{ clipPath: `inset(${v}px ${h}px round 30px)`, opacity: 0.2 },
{ opacity: 1, offset: 0.25 },
{ clipPath: 'inset(0px 0px round 0px)', opacity: 1 },
], { duration: 560, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' })
.finished.then(() => fertig(true), () => fertig(false));
};
bild.src = bildAdresse;
});
</script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
/*
* Startanimation des Hauptfensters (abschaltbar unter Optionen → Darstellung).
*
* Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo der
* Launcher erscheinen wird, und zeigt das sich drehende Logo samt Schriftzug.
* Ist die Oberfläche fertig (renderer:ready), wird das noch unsichtbare
* Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte
* nach außen aufgezogen. Am Ende übernimmt das echte Fenster an exakt
* derselben Stelle – dadurch wirkt es, als baue sich der Launcher auf.
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const MINDESTENS_DREHEN_MS = 1600; // so lange dreht sich das Logo mindestens
const HOECHSTENS_WARTEN_MS = 8000; // spätestens dann wird der Launcher gezeigt
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
// "renderer:ready" kommt, bevor Instanzliste und Kontoname gezeichnet sind –
// erst darauf warten, sonst wächst ein halbleeres Fenster auf
async function warteAufGezeichneteOberflaeche(wc) {
const pruefe = (ausdruck) => wc.executeJavaScript(ausdruck, true).catch(() => true);
// Liste gezeichnet – oder es gibt (noch) keine Instanzen, dann steht der Leer-Hinweis da
for (let i = 0; i < 30 && !(await pruefe("document.querySelectorAll('#grid > *').length > 0 || !document.getElementById('empty').classList.contains('hidden')")); i++) await warte(100);
for (let i = 0; i < 12 && !(await pruefe("(document.getElementById('account-name') || {}).textContent !== 'Konten'")); i++) await warte(100);
await warte(200);
}
async function spielen(hauptfenster, oberflaecheBereit, design) {
const b = hauptfenster.getContentBounds();
const start = new BrowserWindow({
x: b.x, y: b.y, width: b.width, height: b.height,
frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false,
skipTaskbar: true, hasShadow: false, focusable: false, show: false,
backgroundColor: '#00000000', title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
const launcherZeigen = () => {
if (hauptfenster.isDestroyed()) return;
hauptfenster.setOpacity(1);
hauptfenster.setIgnoreMouseEvents(false);
if (!hauptfenster.isVisible()) hauptfenster.show();
hauptfenster.focus();
};
try {
await start.loadFile(path.join(__dirname, 'splash.html'), { query: { design: design || 'dark' } });
start.showInactive();
const beginn = Date.now();
await Promise.race([oberflaecheBereit, warte(HOECHSTENS_WARTEN_MS)]);
const rest = MINDESTENS_DREHEN_MS - (Date.now() - beginn);
if (rest > 0) await warte(rest);
if (hauptfenster.isDestroyed()) return;
// Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
hauptfenster.setOpacity(0);
hauptfenster.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
hauptfenster.show();
await warteAufGezeichneteOberflaeche(hauptfenster.webContents);
if (hauptfenster.isDestroyed()) return;
const bild = await hauptfenster.webContents.capturePage();
if (!bild.isEmpty() && !start.isDestroyed()) {
// höchstens 2,5 s – das Hauptfenster darf nie unsichtbar hängen bleiben
await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(bild.toDataURL())})`), warte(2500)]);
}
} catch {
// Animation ist nur Zierde – im Fehlerfall einfach den Launcher zeigen
} finally {
launcherZeigen();
setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
}
}
module.exports = { spielen };
+39 -6
View File
@@ -78,7 +78,8 @@ const DEFAULT_SETTINGS = {
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
friendsEnabled: true, friendsEnabled: true,
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten") friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
autoModUpdates: false, // vor jedem Start Mods über Modrinth aktualisieren autoModUpdates: false,
aeromcCapes: true, // AeroMC-Umhänge im Spiel anzeigen (Umhang-Agent beim Start) // vor jedem Start Mods über Modrinth aktualisieren
schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … }) schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … })
autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund
@@ -87,6 +88,8 @@ const DEFAULT_SETTINGS = {
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist) bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
startAnimation: true, // drehendes Logo + Fensteraufbau beim Öffnen des Launchers
uiAnimationen: true, // bewegte Oberfläche: lebende Skin-Figur, aufwachsende Fenster, Einblenden, Überblenden
bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex) bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
@@ -423,6 +426,35 @@ function createInstance(data) {
return writeInstance(inst); return writeInstance(inst);
} }
/*
* Große Instanzordner nicht synchron löschen (das fror den Launcher bei vielen
* GB minutenlang ein): sofort in einen versteckten Papierkorb im Instanzordner
* verschieben – das ist nur eine Umbenennung – und dort im Hintergrund löschen.
* Reste (z. B. nach einem Absturz) räumt raeumePapierkorbAuf() beim Start weg.
*/
const PAPIERKORB = '.aeromc-papierkorb';
function entsorgeOrdner(p) {
if (!fs.existsSync(p)) return;
const korb = path.join(path.dirname(p), PAPIERKORB);
const ziel = path.join(korb, path.basename(p) + '-' + Date.now());
try {
ensureDir(korb);
fs.renameSync(p, ziel);
} catch {
// Umbenennen klappt nicht (z. B. Datei gerade geöffnet): instance.json sofort
// weg, damit die Instanz aus der Liste verschwindet – den Rest im Hintergrund
try { fs.rmSync(path.join(p, 'instance.json'), { force: true }); } catch { /* egal */ }
fs.promises.rm(p, { recursive: true, force: true }).catch(() => { /* bleibt liegen, stört nicht */ });
return;
}
fs.promises.rm(ziel, { recursive: true, force: true }).catch(() => { /* beim nächsten Start erneut */ });
}
function raeumePapierkorbAuf() {
const korb = path.join(getInstancesDir(), PAPIERKORB);
if (fs.existsSync(korb)) fs.promises.rm(korb, { recursive: true, force: true }).catch(() => {});
}
function createOrReplaceImportedInstance(data) { function createOrReplaceImportedInstance(data) {
const existing = findInstanceByName(data && data.name); const existing = findInstanceByName(data && data.name);
if (!existing) return createInstance(data); if (!existing) return createInstance(data);
@@ -434,8 +466,7 @@ function createOrReplaceImportedInstance(data) {
inst.totalPlaySeconds = existing.totalPlaySeconds || 0; inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
inst.launchCount = existing.launchCount || 0; inst.launchCount = existing.launchCount || 0;
const p = instancePath(existing.id); entsorgeOrdner(instancePath(existing.id));
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return writeInstance(inst); return writeInstance(inst);
} }
@@ -451,12 +482,11 @@ function updateInstance(id, patch) {
} }
function deleteInstance(id) { function deleteInstance(id) {
const p = instancePath(id); entsorgeOrdner(instancePath(id));
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return true; return true;
} }
function duplicateInstance(id) { function duplicateInstance(id, ohneSpieldaten) {
const src = getInstance(id); const src = getInstance(id);
if (!src) throw new Error('Instanz nicht gefunden: ' + id); if (!src) throw new Error('Instanz nicht gefunden: ' + id);
const copy = JSON.parse(JSON.stringify(src)); const copy = JSON.parse(JSON.stringify(src));
@@ -468,9 +498,11 @@ function duplicateInstance(id) {
copy.launchCount = 0; copy.launchCount = 0;
ensureDir(gameDir(copy.id)); ensureDir(gameDir(copy.id));
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer // Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
if (!ohneSpieldaten) {
try { try {
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true }); fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
} catch { /* Quelle evtl. leer */ } } 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,
}; };
+214 -3
View File
@@ -32,7 +32,7 @@ body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:mi
:root[data-theme="dark"] .field select, :root[data-theme="dark"] .field select,
:root[data-theme="dark"] .field textarea, :root[data-theme="dark"] .field textarea,
:root[data-theme="dark"] .mods-search input, :root[data-theme="dark"] .mods-search input,
:root[data-theme="dark"] .path-row input, :root[data-theme="dark"] .path-row input,:root[data-theme="dark"] .path-row select,:root[data-theme="dark"] select.s-wert,:root[data-theme="dark"] input.s-wert,:root[data-theme="dark"] .s-paar input,:root[data-theme="dark"] .s-einheit input,
:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)} :root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)}
:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618} :root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618}
:root[data-theme="dark"] .modal-head{background:#333337} :root[data-theme="dark"] .modal-head{background:#333337}
@@ -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,38 @@ 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 */
/* … und kippt dabei leicht zur Maus (--kx/--ky setzt kippeZurMaus) */
.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:perspective(220px) translateY(-4px) rotateX(calc(var(--ky,0) * -26deg)) rotateY(calc(var(--kx,0) * 30deg)) rotate(-4deg) scale(1.07);
filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
/* Zahnrad dreht sich beim Darüberfahren */
.ui-animiert #btn-settings svg{transition:transform .6s cubic-bezier(.3,.7,.3,1)}
.ui-animiert #btn-settings:hover svg{transform:rotate(120deg)}
/* Karte einer laufenden Instanz: pulsierender Rand */
.card.laeuft::after{content:'';position:absolute;inset:-1px;border:2px solid var(--accent);border-radius:inherit;pointer-events:none;opacity:.85}
.ui-animiert .card.laeuft::after{animation:laeuft-puls 1.9s ease-in-out infinite}
@keyframes laeuft-puls{0%,100%{opacity:.3;box-shadow:0 0 0 0 transparent}50%{opacity:1;box-shadow:0 0 12px 0 var(--accent)}}
/* weggefallene Karte schrumpft an ihrer alten Stelle weg */
.flip-geist{position:fixed!important;margin:0!important;pointer-events:none;z-index:50}
/* Abbau-Risse auf dem Block (nur im Minecraft-Design sichtbar) */
.card-riss{display:none;position:absolute;left:50%;top:14px;width:44px;height:44px;margin-left:-22px;background-size:100% 100%;image-rendering:pixelated;pointer-events:none;z-index:1}
/* Lichtpunkte im Instanzbereich (nur AeroMC/Nachtlila mit bewegter Oberfläche) */
.lichtpunkte{display:none;position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
.lichtpunkte i{position:absolute;left:var(--x);top:var(--y);width:var(--g);height:var(--g);border-radius:50%;opacity:0;will-change:transform,opacity;animation:lichtpunkt var(--d) ease-in-out var(--v) infinite}
@keyframes lichtpunkt{0%{opacity:0;transform:translate(0,0)}25%{opacity:var(--o)}75%{opacity:var(--o)}100%{opacity:0;transform:translate(var(--dx),var(--dy))}}
/* 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}
@@ -128,6 +168,12 @@ body.is-popout>.titelleiste{display:none}
.sbtn:disabled{opacity:.5;cursor:default} .sbtn:disabled{opacity:.5;cursor:default}
.sbtn:disabled:hover{background:none;border-color:transparent} .sbtn:disabled:hover{background:none;border-color:transparent}
.sbtn.play{font-weight:600} .sbtn.play{font-weight:600}
/* Starten-Knopf füllt sich während Download/Vorbereitung */
.sbtn.play{position:relative;overflow:hidden;isolation:isolate}
.sbtn.play .play-text{position:relative;z-index:1}
.sbtn.play::before{content:'';position:absolute;left:0;top:0;bottom:0;width:0;background:var(--accent);opacity:.35;z-index:0;transition:width .35s ease}
.sbtn.play.laedt::before{width:var(--fortschritt,0%)}
.sbtn.play.laedt .play-text{font-weight:600;font-size:11.5px}
.sbtn.del:hover{color:var(--danger)} .sbtn.del:hover{color:var(--danger)}
/* ---------- Instanzliste / Gruppen / Kacheln ---------- */ /* ---------- Instanzliste / Gruppen / Kacheln ---------- */
@@ -139,6 +185,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 +387,32 @@ 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-armform-hinweis{display:flex;align-items:center;gap:10px;margin:0 0 10px;padding:8px 10px;border:1px solid #c9a227;border-radius:6px;background:rgba(201,162,39,.12);font-size:12px;line-height:1.45}
.skin-armform-hinweis span{flex:1}
.skin-armform-hinweis.hidden{display:none}
/* Garderobe im Skin-Fenster */
.skin-garderobe{margin-top:14px;padding-top:12px;border-top:1px solid var(--border)}
.garderobe-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px}
.garderobe-knoepfe{display:flex;gap:6px}
.garderobe-liste{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px;margin-bottom:6px}
.garderobe-leer{grid-column:1/-1;padding:14px;text-align:center;color:var(--dim);font-size:12px;border:1px dashed var(--border);border-radius:6px}
.gard-karte{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 6px 6px;border:1px solid var(--border);border-radius:6px;background:rgba(127,127,127,.06)}
.gard-bild{width:64px;height:96px;object-fit:contain;image-rendering:auto}
.gard-bild.umhang{width:96px;height:48px;image-rendering:pixelated;object-fit:contain}
.gard-name{width:100%;font-size:11.5px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.gard-merk{font-size:10px;color:var(--dim)}
.gard-aktionen{display:flex;gap:4px;width:100%}
.gard-aktionen .btn{flex:1;padding:3px 0;font-size:11px}
.gard-mini{position:absolute;top:3px;border:none;background:none;color:var(--dim);cursor:pointer;font-size:12px;padding:2px 4px;border-radius:3px}
.gard-mini:hover{background:rgba(127,127,127,.2);color:var(--text)}
.gard-mini.links{left:3px}
.gard-mini.rechts{right:3px}
.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 +422,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 */
@@ -495,8 +581,35 @@ img.account-av{object-fit:cover;image-rendering:auto}
.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px} .friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px}
.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px} .friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px}
.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))} .friend-row:hover{background:var(--sel,rgba(0,0,0,.04))}
.friend-row.gezeigt{box-shadow:inset 3px 0 0 var(--accent)}
/* Bühne mit der 3D-Figur des Freundes */
.freund-buehne{position:relative;height:190px;margin:10px 0 6px;border:1px solid var(--border);border-radius:8px;overflow:hidden;
background:radial-gradient(ellipse at 50% 85%,rgba(127,127,127,.18),transparent 60%)}
.freund-buehne.hidden{display:none}
.freund-buehne canvas{display:block;width:100%;height:100%}
.freund-buehne-text{position:absolute;left:10px;bottom:8px;display:flex;flex-direction:column;gap:1px;pointer-events:none;text-shadow:0 1px 3px rgba(0,0,0,.6)}
.freund-buehne-text b{font-size:13px}
.freund-buehne-text span{font-size:11px;color:var(--dim)}
/* Popout: Liste links, Figur rechts daneben (füllt die Höhe); schmal -> untereinander */
#modal-friends.popout-host .modal-body{display:flex;flex-direction:column}
#modal-friends.popout-host .freunde-raster{flex:1 1 auto;min-height:0}
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)){display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,42%);gap:14px}
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freunde-spalte{min-height:0;overflow:auto;padding-right:2px}
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne{height:auto;margin:0}
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne-text{left:14px;bottom:12px}
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne-text b{font-size:15px}
@media (max-width:620px){
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)){display:flex;flex-direction:column-reverse;justify-content:flex-end}
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne{flex:none;height:190px;margin:10px 0 6px}
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freunde-spalte{overflow:visible}
}
.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto} .friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto}
.friend-dot.online{background:#3bb54a} .friend-dot.online{background:#3bb54a}
/* Freund kommt online: Kopf hüpft, Punkt pulsiert einmal */
.ui-animiert .friend-row.kommt-online .friend-avatar{animation:kopf-huepfen .75s cubic-bezier(.3,.7,.4,1) 2}
@keyframes kopf-huepfen{0%,100%{transform:none}30%{transform:translateY(-7px) scale(1.08)}55%{transform:translateY(0) scale(1.05,.94)}75%{transform:translateY(-2px)}}
.ui-animiert .friend-row.kommt-online .friend-dot{animation:punkt-puls 1.1s ease-out 2}
@keyframes punkt-puls{0%{box-shadow:0 0 0 0 rgba(59,181,74,.85)}100%{box-shadow:0 0 0 10px rgba(59,181,74,0)}}
.friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain} .friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain}
.friend-info{flex:1;min-width:0} .friend-info{flex:1;min-width:0}
.friend-name{font-size:13px} .friend-name{font-size:13px}
@@ -535,6 +648,45 @@ img.account-av{object-fit:cover;image-rendering:auto}
.settings-hero h4{margin:0 0 6px;font-size:20px;font-weight:700} .settings-hero h4{margin:0 0 6px;font-size:20px;font-weight:700}
.settings-hero p{margin:0;color:var(--dim);font-size:13px;line-height:1.5} .settings-hero p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start} .settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start}
/* Optionen: eine Spalte, jede Einstellung eine Zeile (Text links, Bedienelement rechts) */
#modal-settings .settings-grid{grid-template-columns:minmax(0,1fr);max-width:760px}
#modal-settings .settings-card-wide{grid-column:auto}
#modal-settings .settings-card{padding:14px 18px 6px}
#modal-settings .settings-card h5{margin:0 0 4px}
.s-zeile{display:flex;align-items:center;gap:18px;padding:11px 0;border-top:1px solid var(--border)}
.settings-card h5+.s-zeile,.s-vorab+.s-zeile{border-top:none}
.s-zeile.s-voll{flex-direction:column;align-items:stretch;gap:8px}
.s-text{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px}
.s-text b{font-size:13px;font-weight:600}
.s-text small{font-size:12px;color:var(--dim);line-height:1.45}
.s-wert{flex:0 0 auto;width:230px}
.s-wert.s-breit{width:min(320px,48%)}
.s-wert.s-zahl{width:110px}
select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
.s-paar,.s-einheit,.s-regler{display:flex;align-items:center;gap:8px}
.s-paar input,.s-einheit input{width:0;flex:1 1 0;min-width:0;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
.s-paar span,.s-einheit span,.s-regler>span{color:var(--dim);font-size:12px;white-space:nowrap}
.s-regler input{flex:1;min-width:0}
.s-regler>span{min-width:42px;text-align:right}
.s-vorab{display:block;margin:2px 0 6px;color:var(--dim)}
.s-warnung{color:#d9822b}
#s-backup-status,#s-restore-status{display:block;margin:-4px 0 10px}
.s-felder{padding:0 0 6px}
.s-zeile .field-row .field{margin-bottom:0}
.s-zeile .path-row select{flex:1;min-width:0;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
.s-zeile>.btn{flex:none;white-space:nowrap}
.settings-card .backup-progress{margin:0 0 12px}
/* Schalter (statt Häkchen) */
.schalter{position:relative;flex:none;width:40px;height:22px;cursor:pointer}
.schalter input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;z-index:1}
.schalter i{position:absolute;inset:0;border-radius:11px;background:rgba(127,127,127,.38);transition:background .18s}
.schalter i::after{content:'';position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
.schalter input:checked+i{background:var(--accent)}
.schalter input:checked+i::after{transform:translateX(18px)}
.schalter input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
.schalter input:disabled{cursor:default}
.schalter input:disabled+i{opacity:.45}
@media (max-width:640px){.s-zeile{flex-wrap:wrap}.s-wert,.s-wert.s-breit{width:100%}}
.settings-grid-stack{grid-template-columns:repeat(2,minmax(0,1fr))} .settings-grid-stack{grid-template-columns:repeat(2,minmax(0,1fr))}
.settings-card{background:rgba(255,255,255,.7);border:1px solid var(--border);border-radius:12px;padding:16px 16px 12px;box-shadow:0 1px 0 rgba(255,255,255,.5)} .settings-card{background:rgba(255,255,255,.7);border:1px solid var(--border);border-radius:12px;padding:16px 16px 12px;box-shadow:0 1px 0 rgba(255,255,255,.5)}
.settings-card h5{margin:0 0 14px;font-size:13px;font-weight:700;color:var(--text)} .settings-card h5{margin:0 0 14px;font-size:13px;font-weight:700;color:var(--text)}
@@ -610,7 +762,27 @@ img.account-av{object-fit:cover;image-rendering:auto}
.upd-arrow{color:var(--dim)} .upd-arrow{color:var(--dim)}
.upd-newv{font-weight:700;color:var(--accent)} .upd-newv{font-weight:700;color:var(--accent)}
.upd-channel{margin:-6px 0 12px;text-align:center} .upd-channel{margin:-6px 0 12px;text-align:center}
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px} .upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 14px;font-size:12.5px;line-height:1.5;margin-bottom:12px;overflow-wrap:anywhere}
/* Markdown in den Update-Hinweisen */
.upd-notes>:first-child{margin-top:0}
.upd-notes>:last-child{margin-bottom:0}
.upd-notes h4{margin:14px 0 6px;font-size:15px;font-weight:700}
.upd-notes h5{margin:12px 0 4px;font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.05em}
.upd-notes h6{margin:10px 0 4px;font-size:12.5px;font-weight:700}
.upd-notes p{margin:6px 0}
.upd-notes ul,.upd-notes ol{margin:4px 0 8px;padding-left:20px}
.upd-notes ul ul,.upd-notes ol ul,.upd-notes ul ol{margin:3px 0 2px}
.upd-notes li{margin:3px 0}
.upd-notes li::marker{color:var(--accent)}
.upd-notes code{padding:1px 5px;border-radius:4px;background:rgba(127,127,127,.18);font-family:Consolas,"Cascadia Mono",monospace;font-size:11.5px}
.upd-notes pre{margin:8px 0;padding:8px 10px;border-radius:6px;background:rgba(127,127,127,.14);overflow:auto}
.upd-notes pre code{padding:0;background:none}
.upd-notes blockquote{margin:8px 0;padding:2px 0 2px 10px;border-left:3px solid var(--accent);color:var(--dim)}
.upd-notes hr{border:none;border-top:1px solid var(--border);margin:12px 0}
.upd-notes a{color:var(--link,var(--accent));text-decoration:underline;cursor:pointer}
/* als eigenes Fenster: Hinweise füllen den freien Platz */
#modal-update.popout-host .modal-body{display:flex;flex-direction:column}
#modal-update.popout-host .upd-notes{flex:1 1 auto;max-height:none;min-height:120px}
.upd-progress{margin:10px 0} .upd-progress{margin:10px 0}
.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden} .upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden}
.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s} .upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s}
@@ -641,6 +813,42 @@ 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}
/* Erfolg-Meldung wie im Spiel („Erfolg erzielt!“) */
.erfolg-meldungen{position:fixed;top:92px;right:14px;z-index:300;display:flex;flex-direction:column;gap:8px}
/* Grundform (Klassisch): Karte in den Farben des Designs */
.erfolg{position:relative;display:flex;align-items:center;gap:10px;min-width:250px;max-width:320px;padding:9px 14px 9px 9px;cursor:pointer;
background:var(--win);border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:6px;
box-shadow:0 10px 26px rgba(0,0,0,.35);color:var(--text)}
.erfolg-symbol{width:36px;height:36px;flex:none;display:grid;place-items:center;border-radius:5px;background:rgba(127,127,127,.15)}
.erfolg-symbol svg,.erfolg-symbol img{width:28px;height:28px}
.erfolg-kopf{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;line-height:1.3}
.erfolg-name{font-size:13px;font-weight:600;line-height:1.3}
/* Funken (alle Designs außer Minecraft) und XP-Kugeln (Minecraft) */
.erfolg .funke{position:absolute;z-index:2;width:6px;height:6px;border-radius:50%;background:#fff;box-shadow:0 0 5px 2px var(--accent),0 0 12px 3px var(--accent);pointer-events:none}
.erfolg .xp-kugel{position:absolute;width:8px;height:8px;margin-top:-4px;border-radius:50%;pointer-events:none;
background:radial-gradient(circle at 35% 35%,#fbff9c 0 25%,#b8f531 45%,#4f9a12 80%);box-shadow:0 0 6px 1px rgba(182,255,58,.75)}
.ui-animiert .erfolg{animation:erfolg-rein .45s cubic-bezier(.2,.8,.2,1) both}
.ui-animiert .erfolg.weg{animation:erfolg-raus .35s ease-in both}
@keyframes erfolg-rein{from{transform:translateX(120%);opacity:0}}
@keyframes erfolg-raus{to{transform:translateX(120%);opacity:0}}
/* Übersicht in der Statistik */
.erfolge-liste{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:6px;margin-bottom:12px}
.erfolg-karte{display:flex;align-items:center;gap:9px;padding:6px 8px;border:1px solid var(--border);border-radius:6px}
.erfolg-karte .erfolg-symbol{width:32px;height:32px}
.erfolg-karte b{display:block;font-size:12px}
.erfolg-karte small{display:block;font-size:11px;color:var(--dim);line-height:1.3}
.erfolg-karte.offen{opacity:.45;filter:grayscale(1)}
/* 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 +910,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}
+12 -1
View File
@@ -149,6 +149,17 @@ function copyInto(sourceDir, targetDir) {
} }
} }
/*
* 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).filter((name) => !SKIP_DIRS.has(name))
.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,
@@ -169,4 +180,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, resolveModpacksDir, technicRoot, detectVersion }; module.exports = { scan, importOne, kopierQuellen, resolveModpacksDir, technicRoot, detectVersion };
@@ -1,8 +1,8 @@
<?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
*/ */
@@ -10,7 +10,7 @@ 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');
@@ -18,6 +18,7 @@ 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 // Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben // nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
@@ -143,6 +144,22 @@ function aeromc_friends_install() {
) $charset_collate;"; ) $charset_collate;";
dbDelta($sql_reports); dbDelta($sql_reports);
// eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
$capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
$sql_capes = "CREATE TABLE $capes_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
datei varchar(64) NOT NULL DEFAULT '',
breite smallint(5) unsigned NOT NULL DEFAULT 0,
hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
hash varchar(40) NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'aktiv',
updated_at datetime NOT NULL,
PRIMARY KEY (uuid),
KEY status (status)
) $charset_collate;";
dbDelta($sql_capes);
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION); update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
} }
register_activation_hook(__FILE__, 'aeromc_friends_install'); register_activation_hook(__FILE__, 'aeromc_friends_install');
@@ -157,6 +174,7 @@ add_action('plugins_loaded', function () {
}); });
require_once plugin_dir_path(__FILE__) . 'includes/routes.php'; require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
if (is_admin()) { if (is_admin()) {
require_once plugin_dir_path(__FILE__) . 'includes/admin.php'; 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>';
}
@@ -461,6 +461,20 @@ function aeromc_friends_route_request($request) {
$presence_table = aeromc_friends_presence_table(); $presence_table = aeromc_friends_presence_table();
$friends_table = aeromc_friends_friends_table(); $friends_table = aeromc_friends_friends_table();
// Namen des Absenders merken: Bisher kannte der Server ihn erst nach dem
// ersten Spielen über AeroMC (Herzschlag) – vorher sah der Empfänger nur
// die UUID. Bestehende Einträge behalten ihren Status, neue gelten als
// offline (last_seen in der Vergangenheit), damit niemand fälschlich
// "online" erscheint.
$eigener_name = ($sitzung && !empty($sitzung->username)) ? $sitzung->username : $request->get_param('username');
if (aeromc_friends_valid_username($eigener_name)) {
$wpdb->query($wpdb->prepare(
"INSERT INTO $presence_table (uuid, username, last_seen) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE username = %s",
$uuid, $eigener_name, '2000-01-01 00:00:00', $eigener_name
));
}
$ziel = $wpdb->get_row($wpdb->prepare( $ziel = $wpdb->get_row($wpdb->prepare(
"SELECT uuid FROM $presence_table WHERE username = %s", $targetUsername "SELECT uuid FROM $presence_table WHERE username = %s", $targetUsername
)); ));
@@ -708,6 +722,15 @@ function aeromc_friends_route_auth_verify($request) {
'created_at' => $jetzt, 'created_at' => $jetzt,
'last_used' => $jetzt, 'last_used' => $jetzt,
]); ]);
// Bestätigten Namen gleich merken – so ist ein Spieler per Name auffindbar,
// sobald er AeroMC mit Freundesystem nutzt, nicht erst nach dem ersten
// Spielen. Neue Einträge gelten als offline (last_seen in der Vergangenheit).
$presence_table = aeromc_friends_presence_table();
$wpdb->query($wpdb->prepare(
"INSERT INTO $presence_table (uuid, username, last_seen) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE username = %s",
$uuid, $profil['name'], '2000-01-01 00:00:00', $profil['name']
));
return ['token' => $token, 'username' => $profil['name']]; return ['token' => $token, 'username' => $profil['name']];
} }