Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de81575bcf | ||
|
|
76762ab567 | ||
|
|
6a0e92eb0f | ||
|
|
7e1fb616aa | ||
|
|
642d229d7e | ||
|
|
e782cc3058 | ||
|
|
972a3e73da | ||
|
|
e83c951256 | ||
|
|
67fe4cba48 | ||
|
|
e5ccf0b4cc | ||
|
|
10ee45cbc2 | ||
|
|
bc68395437 |
@@ -1,136 +1,208 @@
|
||||
# Changelog
|
||||
|
||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||
|
||||
## [1.1.5] - 2026-10-02
|
||||
|
||||
### Neu
|
||||
- Instanz teilen per Code: „Instanz teilen“ in der Seitenleiste erzeugt einen Code (30 Tage gültig) – Freunde lösen ihn unter Importieren → „Per Code“ ein und bekommen Minecraft-Version, Loader sowie alle Mods, Ressourcenpakete und Shader in denselben Versionen
|
||||
- Absturz-Assistent: erklärt die Ursache eines Absturzes in einfachen Worten – fehlende Abhängigkeiten, Mods für eine andere Minecraft-Version, unverträgliche oder doppelte Mods, falsches Java, zu viel oder zu wenig Arbeitsspeicher, Grafiktreiber – und behebt sie auf Wunsch mit einem Klick
|
||||
- Der Absturz-Assistent meldet sich auch, wenn Minecraft schon beim Start scheitert und keinen Absturzbericht schreibt
|
||||
- Nach einer Lösung lässt sich die Instanz direkt aus dem Absturz-Fenster erneut starten
|
||||
- Fehlerberichte per Klick: „An M_Viper senden“ im Absturz-Assistenten schickt Bericht und Mod-Liste an den Entwickler – Windows-Benutzername und Anmeldedaten werden vorher entfernt, ohne Häkchen wird anonym gesendet
|
||||
- Kurznachrichten an Freunde: kleiner Chat im Freunde-Fenster mit Schnellantworten wie „Bin in 10 Min. online“ – neue Nachrichten erscheinen im Launcher und als Meldung im Spiel
|
||||
- Screenshots verschicken: mit 📤 in der Screenshot-Galerie ein Bild direkt an einen Freund schicken, er sieht es im Chat
|
||||
- Schnellstart-Tasten: Lieblings-Instanzen per Rechtsklick auf Strg+1 bis Strg+5 legen und mit der Taste direkt starten
|
||||
- Rechtsklick auf das AeroMC-Symbol neben der Uhr startet Schnellstart- und zuletzt gespielte Instanzen, ohne das Fenster zu öffnen
|
||||
- Welten kopieren: „Kopieren nach …“ übernimmt eine Welt mit einem Klick in eine andere Instanz
|
||||
- Automatische Welt-Sicherung: nach jeder Spielsitzung wird die zuletzt gespielte Welt gesichert, die letzten 5 Stände bleiben erhalten (abschaltbar unter Optionen → Backup)
|
||||
- Rechtsklick-Menü in allen Textfeldern (Ausschneiden, Kopieren, Einfügen, Alles auswählen)
|
||||
|
||||
### Geändert
|
||||
- Freundesystem abgesichert: Anmeldung über Mojangs Sitzungsprüfung wie bei Minecraft-Servern – niemand kann sich mehr als ein anderer Spieler ausgeben, ganz ohne eigenes Passwort
|
||||
- Das Symbol im Infobereich neben der Uhr ist jetzt immer da, nicht nur bei Autostart
|
||||
- Der Zähler am „Freunde“-Knopf zählt jetzt offene Anfragen und ungelesene Nachrichten
|
||||
- Installer: Lizenztext übersichtlich mit Überschriften und Aufzählungen, Rückfragen im Setup-Look statt Windows-Meldungsfenster, der nicht abwählbare Startmenü-Eintrag wird nicht mehr angezeigt
|
||||
- Benötigt das Plugin „AeroMC Freunde“ 1.4.0 auf dem Server (Kurznachrichten, Screenshots, Fehlerberichte, Instanz teilen)
|
||||
|
||||
### Behoben
|
||||
- Windows-Benachrichtigungen zeigten „electron.app.AeroMC“ ohne Logo statt „AeroMC“
|
||||
- Im Import-Fenster standen „Wählen…“, „Suchen“ und „Prüfen“ unter statt neben dem Eingabefeld
|
||||
|
||||
## [1.1.4] - 2026-10-02
|
||||
|
||||
### Neu
|
||||
- Neuer Installer im modernen Look: Schritt-für-Schritt-Assistent mit Lizenz, Ordnerwahl und Verknüpfungen, eigene Deinstallation (Instanzen und Welten bleiben auf Wunsch erhalten) – eine vorhandene Installation wird automatisch übernommen
|
||||
- Optionaler Import im Installer: erkennt Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic und bietet deren Instanzen beim ersten Start zum Übernehmen an
|
||||
- Updates aus dem Launcher heraus laufen jetzt ohne Rückfragen durch und starten AeroMC danach von selbst neu
|
||||
- Freunde: „Beitreten“ startet eine passende Instanz und verbindet direkt mit dem Server, auf dem ein Freund spielt – gibt es keine passende Instanz, wird eine angelegt
|
||||
- Freunde: „Einladen“ schickt einem Freund deinen aktuellen Server – er bekommt die Einladung im Launcher und als Meldung im Spiel (Strg+Alt+J kopiert dort die Adresse)
|
||||
- Freunde: Der eigene Server lässt sich in den Einstellungen vor Freunden verbergen
|
||||
- Clip-Modus in der Spielaufzeichnung: läuft im Hintergrund mit, Alt+F10 speichert die letzten 30, 60 oder 90 Sekunden als MP4
|
||||
- Performance-Paket beim Anlegen einer Fabric-, Quilt- oder NeoForge-Instanz: Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling und ModernFix per Häkchen mitinstallieren
|
||||
- Automatische Mod-Updates vor dem Spielstart (Optionen → Spiel → Mods)
|
||||
- Spielzeit-Statistik: Gesamtspielzeit, Spielstarts, meistgespielte Instanz und Spielzeit pro Instanz auf einen Blick
|
||||
|
||||
### Geändert
|
||||
- Die Suche nach Updates startet jetzt sofort beim Öffnen des Launchers, die Info-Seite zeigt das Ergebnis direkt an
|
||||
- Direkt einem Server beitreten funktioniert jetzt auch bei Minecraft-Versionen vor 1.20
|
||||
|
||||
### Behoben
|
||||
- Die Info-Seite zeigte „Kein Update-Check ausgeführt“, obwohl der Launcher beim Start bereits gesucht hatte
|
||||
|
||||
## [1.1.3] - 2026-09-29
|
||||
|
||||
### Neu
|
||||
- Freundesystem: Freunde per Minecraft-Name hinzufügen, Anfragen annehmen/ablehnen, gesendete Anfragen zurückziehen, Spitznamen vergeben – mit 3D-Spielerkopf und Online-Status
|
||||
- In-Game-Meldung mit 3D-Spielerkopf, sobald ein Freund online kommt – bei mehreren gleichzeitig laufenden Konten erscheint sie im jeweils richtigen Spielfenster
|
||||
- Tastenkombination Strg+Alt+F zeigt im Spiel an, welche Freunde gerade online sind (inklusive Server)
|
||||
- Zähler für offene Freundschaftsanfragen am "Freunde"-Knopf
|
||||
- Freundesystem in den Einstellungen abschaltbar – man ist dann weder sichtbar noch als Freund hinzufügbar, der "Freunde"-Knopf wird ausgeblendet
|
||||
- Neuer Reiter "Aufzeichnungen" im Screenshot-Fenster mit Vorschaubildern, Abspielen und Löschen
|
||||
|
||||
### Geändert
|
||||
- Aufzeichnungen werden jetzt als MP4 gespeichert und laufen damit in jedem gängigen Player
|
||||
- Der Mauszeiger ist in Aufzeichnungen nicht mehr zu sehen
|
||||
|
||||
### Behoben
|
||||
- Aufzeichnungen ruckelten bzw. hatten eine falsche Bildrate
|
||||
- Wechsel des aktiven Microsoft-Kontos wurde nicht in allen Fenstern übernommen
|
||||
|
||||
## [1.1.2] - 2026-09-27
|
||||
|
||||
### Neu
|
||||
- Auflösung, Qualität und Bildrate der Spielaufzeichnung einstellbar (Nativ/1080p/720p/480p, Niedrig/Mittel/Hoch, 30/60 fps) – inklusive Anzeige der tatsächlich aufgenommenen Pixelgröße
|
||||
- Lautstärke jeder einzelnen Tonspur der Aufzeichnung (Systemton, Nur Minecraft, jedes Mikrofon einzeln) regelbar, auch während eine Aufnahme bereits läuft
|
||||
- Video- und Audioeinstellungen der Spielaufzeichnung bleiben jetzt über einen Neustart des Launchers hinweg erhalten
|
||||
|
||||
### Geändert
|
||||
- Anzeige "Aufnahme läuft" samt Timer sitzt jetzt über dem Start/Stopp-Knopf statt auf dem Videobild
|
||||
- Mikrofonnamen in der Audioquellen-Auswahl zeigen keine technische USB-Kennung mehr (z. B. "(9886:0038)")
|
||||
|
||||
### Behoben
|
||||
- Servername in der Spielaufzeichnung fehlte, wenn man sich schon vor dem Öffnen des Aufzeichnungsfensters mit dem Server verbunden hatte – wird jetzt zentral erkannt und trägt sich auch nachträglich in den Titel ein
|
||||
- Vorschaubild der Aufzeichnung blieb beim ersten Öffnen manchmal schwarz – wird jetzt automatisch erkannt und neu erfasst
|
||||
|
||||
## [1.0.9] - 2026-08-09
|
||||
|
||||
### Behoben
|
||||
- Update-Installation brach mit "EPERM: operation not permitted, lstat …" ab, wenn vom vorherigen Versuch noch eine gesperrte Installer-Datei im Temp-Ordner lag (z. B. durch einen Virenscanner) – der Download weicht jetzt automatisch auf einen neuen Dateinamen aus
|
||||
- Autostart mit Windows öffnete im Entwicklungsmodus ein leeres Electron-Fenster statt AeroMC (gleiche Ursache wie zuvor bei den Desktop-Verknüpfungen), zusätzlich zeigte der Autostart-Eintrag bei der Portable-Fassung potenziell auf einen flüchtigen Temp-Pfad
|
||||
- Wiederherstellen aus einem ungültigen oder beschädigten Backup-Archiv löschte die aktuellen, unveränderten Instanzen ersatzlos, obwohl der Vorgang mit einer Fehlermeldung abgebrochen wurde – der Rollback greift jetzt nur noch, wenn am Originalbestand überhaupt schon etwas verändert wurde
|
||||
|
||||
## [1.0.8] - 2026-08-08
|
||||
|
||||
### Neu
|
||||
- Datenpakete lassen sich jetzt wie Mods, Ressourcenpakete und Shader direkt aus dem Modrinth-Katalog durchsuchen, installieren, aktivieren/deaktivieren und aktualisieren
|
||||
- Modpacks lassen sich jetzt auch aus dem CurseForge-Katalog durchsuchen und mit einem Klick installieren (bisher nur Modrinth und Technic; CurseForge ging nur per manuellem ZIP-Import) – braucht wie die bestehende CurseForge-Suche einen eigenen API-Key
|
||||
- OptiFine als eigenständige Loader-Option: Installer-Datei auswählen, AeroMC bereitet die passende Minecraft-Version vor und startet den offiziellen OptiFine-Installer sichtbar; nach Abschluss wird die neue Version automatisch übernommen
|
||||
- Von Hand hinzugefügte OptiFine-Dateien im Mods-Dialog werden jetzt erkannt und eigens gekennzeichnet (funktioniert bei jedem Loader, u. a. für Fabric + OptiFabric)
|
||||
- Lizenztext direkt in den Einstellungen unter Info einsehbar
|
||||
|
||||
### Geändert
|
||||
- Lizenzangabe von MIT auf die tatsächlich geltenden Nutzungsbedingungen umgestellt (nur privater, nicht-kommerzieller Gebrauch – siehe LICENSE)
|
||||
|
||||
## [1.0.7] - 2026-08-06
|
||||
|
||||
### Neu
|
||||
- Desktop-Verknüpfung pro Server: startet die Instanz und verbindet direkt mit dem hinterlegten Server (Quick Play, ab MC 1.20)
|
||||
- Name der Desktop-Verknüpfung (Instanz oder Server) lässt sich beim Erstellen frei selbst vergeben
|
||||
|
||||
### Behoben
|
||||
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus einen leeren Electron-Startbildschirm statt AeroMC zu öffnen
|
||||
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus das Electron-Symbol statt des AeroMC-Logos
|
||||
- Desktop-Verknüpfungen (Instanz und Server) starteten bei kaltem Start unzuverlässig gar keine Instanz – der Startwunsch traf manchmal ein, bevor die Oberfläche bereit war, ihn zu empfangen, und ging dann verloren
|
||||
|
||||
## [1.0.6] - 2026-08-05
|
||||
|
||||
### Neu
|
||||
- Technic-Modpacks direkt durchsuchen und installieren (Katalog), oder eine vorhandene Technic-Installation importieren
|
||||
- Mods lassen sich jetzt auch als eigene Datei hinzufügen, nicht nur über die Suche
|
||||
- Dateien (Mods, Modpacks, Instanzen) per Drag & Drop aufs Fenster ziehen
|
||||
- Tastenkürzel: Strg+F (Suche), Strg+N (neue Instanz), Entf (Instanz löschen)
|
||||
- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird
|
||||
- Serverliste zeigt jetzt Live-Status (Spieleranzahl, MOTD, Version)
|
||||
- Ein-Klick-Knopf für empfohlene GC-Flags bei den JVM-Argumenten
|
||||
- Einzelne Welten lassen sich separat schnellsichern
|
||||
- Warnung, wenn ein Mod entfernt wird, den ein anderer installierter Mod noch braucht
|
||||
- Skin-Hintergrund kann automatisch zwischen mehreren angemeldeten Konten durchwechseln
|
||||
- Optionaler Autostart mit Windows (im Infobereich statt als Fenster)
|
||||
|
||||
### Geändert
|
||||
- Update-Suche läuft automatisch beim Start, Anzeige wird grün wenn alles aktuell ist
|
||||
|
||||
### Behoben
|
||||
- Bedrock-Start repariert (ging nach einem Windows-Update nicht mehr) und startete zwischenzeitlich doppelt
|
||||
- Absturz beim Schließen des Spiels durch hängende Mod-Threads (z. B. JourneyMap-Webmap) behoben
|
||||
# Changelog
|
||||
|
||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||
|
||||
## [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
|
||||
- Skin-Editor: Skins direkt im Launcher pixeln – erreichbar im Skin-Fenster über „Im Editor bearbeiten…“
|
||||
- Zwei Schichten: Grundschicht und 2. Schicht getrennt bemalen – die 2. Schicht liegt wie eine Hülle über der Figur und lässt Haare, Kapuzen oder Ärmel plastisch wirken
|
||||
- Wahlweise auf der aufgeklappten 2D-Vorlage oder direkt auf der drehbaren 3D-Figur malen – die andere Ansicht läuft als Live-Vorschau mit und ist ebenfalls bemalbar
|
||||
- Werkzeuge: Stift, Radierer (nur 2. Schicht), Pipette (auch Alt+Klick), Füllen, Farbwähler mit den zuletzt benutzten Farben
|
||||
- Rückgängig/Wiederholen mit Strg+Z und Strg+Y
|
||||
- Spiegeln: malt gleichzeitig auf der anderen Körperseite (linker ↔ rechter Arm/Bein)
|
||||
- Ebenen-Werkzeuge: 2. Schicht ein-/ausblenden, Grundschicht durchscheinen lassen, 2. Schicht leeren
|
||||
- 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“
|
||||
- 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 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
|
||||
- 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
|
||||
|
||||
### Geändert
|
||||
- Die Design-Auswahl (bisher „Design: Dunkel/Hell“ unter Optionen → Allgemein) steht jetzt unter Optionen → Darstellung
|
||||
|
||||
### Behoben
|
||||
- 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
|
||||
|
||||
## [1.1.5] - 2026-10-02
|
||||
|
||||
### Neu
|
||||
- Instanz teilen per Code: „Instanz teilen“ in der Seitenleiste erzeugt einen Code (30 Tage gültig) – Freunde lösen ihn unter Importieren → „Per Code“ ein und bekommen Minecraft-Version, Loader sowie alle Mods, Ressourcenpakete und Shader in denselben Versionen
|
||||
- Absturz-Assistent: erklärt die Ursache eines Absturzes in einfachen Worten – fehlende Abhängigkeiten, Mods für eine andere Minecraft-Version, unverträgliche oder doppelte Mods, falsches Java, zu viel oder zu wenig Arbeitsspeicher, Grafiktreiber – und behebt sie auf Wunsch mit einem Klick
|
||||
- Der Absturz-Assistent meldet sich auch, wenn Minecraft schon beim Start scheitert und keinen Absturzbericht schreibt
|
||||
- Nach einer Lösung lässt sich die Instanz direkt aus dem Absturz-Fenster erneut starten
|
||||
- Fehlerberichte per Klick: „An M_Viper senden“ im Absturz-Assistenten schickt Bericht und Mod-Liste an den Entwickler – Windows-Benutzername und Anmeldedaten werden vorher entfernt, ohne Häkchen wird anonym gesendet
|
||||
- Kurznachrichten an Freunde: kleiner Chat im Freunde-Fenster mit Schnellantworten wie „Bin in 10 Min. online“ – neue Nachrichten erscheinen im Launcher und als Meldung im Spiel
|
||||
- Screenshots verschicken: mit 📤 in der Screenshot-Galerie ein Bild direkt an einen Freund schicken, er sieht es im Chat
|
||||
- Schnellstart-Tasten: Lieblings-Instanzen per Rechtsklick auf Strg+1 bis Strg+5 legen und mit der Taste direkt starten
|
||||
- Rechtsklick auf das AeroMC-Symbol neben der Uhr startet Schnellstart- und zuletzt gespielte Instanzen, ohne das Fenster zu öffnen
|
||||
- Welten kopieren: „Kopieren nach …“ übernimmt eine Welt mit einem Klick in eine andere Instanz
|
||||
- Automatische Welt-Sicherung: nach jeder Spielsitzung wird die zuletzt gespielte Welt gesichert, die letzten 5 Stände bleiben erhalten (abschaltbar unter Optionen → Backup)
|
||||
- Rechtsklick-Menü in allen Textfeldern (Ausschneiden, Kopieren, Einfügen, Alles auswählen)
|
||||
|
||||
### Geändert
|
||||
- Freundesystem abgesichert: Anmeldung über Mojangs Sitzungsprüfung wie bei Minecraft-Servern – niemand kann sich mehr als ein anderer Spieler ausgeben, ganz ohne eigenes Passwort
|
||||
- Das Symbol im Infobereich neben der Uhr ist jetzt immer da, nicht nur bei Autostart
|
||||
- Der Zähler am „Freunde“-Knopf zählt jetzt offene Anfragen und ungelesene Nachrichten
|
||||
- Installer: Lizenztext übersichtlich mit Überschriften und Aufzählungen, Rückfragen im Setup-Look statt Windows-Meldungsfenster, der nicht abwählbare Startmenü-Eintrag wird nicht mehr angezeigt
|
||||
- Benötigt das Plugin „AeroMC Freunde“ 1.4.0 auf dem Server (Kurznachrichten, Screenshots, Fehlerberichte, Instanz teilen)
|
||||
|
||||
### Behoben
|
||||
- Windows-Benachrichtigungen zeigten „electron.app.AeroMC“ ohne Logo statt „AeroMC“
|
||||
- Im Import-Fenster standen „Wählen…“, „Suchen“ und „Prüfen“ unter statt neben dem Eingabefeld
|
||||
|
||||
## [1.1.4] - 2026-10-02
|
||||
|
||||
### Neu
|
||||
- Neuer Installer im modernen Look: Schritt-für-Schritt-Assistent mit Lizenz, Ordnerwahl und Verknüpfungen, eigene Deinstallation (Instanzen und Welten bleiben auf Wunsch erhalten) – eine vorhandene Installation wird automatisch übernommen
|
||||
- Optionaler Import im Installer: erkennt Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic und bietet deren Instanzen beim ersten Start zum Übernehmen an
|
||||
- Updates aus dem Launcher heraus laufen jetzt ohne Rückfragen durch und starten AeroMC danach von selbst neu
|
||||
- Freunde: „Beitreten“ startet eine passende Instanz und verbindet direkt mit dem Server, auf dem ein Freund spielt – gibt es keine passende Instanz, wird eine angelegt
|
||||
- Freunde: „Einladen“ schickt einem Freund deinen aktuellen Server – er bekommt die Einladung im Launcher und als Meldung im Spiel (Strg+Alt+J kopiert dort die Adresse)
|
||||
- Freunde: Der eigene Server lässt sich in den Einstellungen vor Freunden verbergen
|
||||
- Clip-Modus in der Spielaufzeichnung: läuft im Hintergrund mit, Alt+F10 speichert die letzten 30, 60 oder 90 Sekunden als MP4
|
||||
- Performance-Paket beim Anlegen einer Fabric-, Quilt- oder NeoForge-Instanz: Sodium, Lithium, FerriteCore, ImmediatelyFast, Entity Culling und ModernFix per Häkchen mitinstallieren
|
||||
- Automatische Mod-Updates vor dem Spielstart (Optionen → Spiel → Mods)
|
||||
- Spielzeit-Statistik: Gesamtspielzeit, Spielstarts, meistgespielte Instanz und Spielzeit pro Instanz auf einen Blick
|
||||
|
||||
### Geändert
|
||||
- Die Suche nach Updates startet jetzt sofort beim Öffnen des Launchers, die Info-Seite zeigt das Ergebnis direkt an
|
||||
- Direkt einem Server beitreten funktioniert jetzt auch bei Minecraft-Versionen vor 1.20
|
||||
|
||||
### Behoben
|
||||
- Die Info-Seite zeigte „Kein Update-Check ausgeführt“, obwohl der Launcher beim Start bereits gesucht hatte
|
||||
|
||||
## [1.1.3] - 2026-09-29
|
||||
|
||||
### Neu
|
||||
- Freundesystem: Freunde per Minecraft-Name hinzufügen, Anfragen annehmen/ablehnen, gesendete Anfragen zurückziehen, Spitznamen vergeben – mit 3D-Spielerkopf und Online-Status
|
||||
- In-Game-Meldung mit 3D-Spielerkopf, sobald ein Freund online kommt – bei mehreren gleichzeitig laufenden Konten erscheint sie im jeweils richtigen Spielfenster
|
||||
- Tastenkombination Strg+Alt+F zeigt im Spiel an, welche Freunde gerade online sind (inklusive Server)
|
||||
- Zähler für offene Freundschaftsanfragen am "Freunde"-Knopf
|
||||
- Freundesystem in den Einstellungen abschaltbar – man ist dann weder sichtbar noch als Freund hinzufügbar, der "Freunde"-Knopf wird ausgeblendet
|
||||
- Neuer Reiter "Aufzeichnungen" im Screenshot-Fenster mit Vorschaubildern, Abspielen und Löschen
|
||||
|
||||
### Geändert
|
||||
- Aufzeichnungen werden jetzt als MP4 gespeichert und laufen damit in jedem gängigen Player
|
||||
- Der Mauszeiger ist in Aufzeichnungen nicht mehr zu sehen
|
||||
|
||||
### Behoben
|
||||
- Aufzeichnungen ruckelten bzw. hatten eine falsche Bildrate
|
||||
- Wechsel des aktiven Microsoft-Kontos wurde nicht in allen Fenstern übernommen
|
||||
|
||||
## [1.1.2] - 2026-09-27
|
||||
|
||||
### Neu
|
||||
- Auflösung, Qualität und Bildrate der Spielaufzeichnung einstellbar (Nativ/1080p/720p/480p, Niedrig/Mittel/Hoch, 30/60 fps) – inklusive Anzeige der tatsächlich aufgenommenen Pixelgröße
|
||||
- Lautstärke jeder einzelnen Tonspur der Aufzeichnung (Systemton, Nur Minecraft, jedes Mikrofon einzeln) regelbar, auch während eine Aufnahme bereits läuft
|
||||
- Video- und Audioeinstellungen der Spielaufzeichnung bleiben jetzt über einen Neustart des Launchers hinweg erhalten
|
||||
|
||||
### Geändert
|
||||
- Anzeige "Aufnahme läuft" samt Timer sitzt jetzt über dem Start/Stopp-Knopf statt auf dem Videobild
|
||||
- Mikrofonnamen in der Audioquellen-Auswahl zeigen keine technische USB-Kennung mehr (z. B. "(9886:0038)")
|
||||
|
||||
### Behoben
|
||||
- Servername in der Spielaufzeichnung fehlte, wenn man sich schon vor dem Öffnen des Aufzeichnungsfensters mit dem Server verbunden hatte – wird jetzt zentral erkannt und trägt sich auch nachträglich in den Titel ein
|
||||
- Vorschaubild der Aufzeichnung blieb beim ersten Öffnen manchmal schwarz – wird jetzt automatisch erkannt und neu erfasst
|
||||
|
||||
## [1.0.9] - 2026-08-09
|
||||
|
||||
### Behoben
|
||||
- Update-Installation brach mit "EPERM: operation not permitted, lstat …" ab, wenn vom vorherigen Versuch noch eine gesperrte Installer-Datei im Temp-Ordner lag (z. B. durch einen Virenscanner) – der Download weicht jetzt automatisch auf einen neuen Dateinamen aus
|
||||
- Autostart mit Windows öffnete im Entwicklungsmodus ein leeres Electron-Fenster statt AeroMC (gleiche Ursache wie zuvor bei den Desktop-Verknüpfungen), zusätzlich zeigte der Autostart-Eintrag bei der Portable-Fassung potenziell auf einen flüchtigen Temp-Pfad
|
||||
- Wiederherstellen aus einem ungültigen oder beschädigten Backup-Archiv löschte die aktuellen, unveränderten Instanzen ersatzlos, obwohl der Vorgang mit einer Fehlermeldung abgebrochen wurde – der Rollback greift jetzt nur noch, wenn am Originalbestand überhaupt schon etwas verändert wurde
|
||||
|
||||
## [1.0.8] - 2026-08-08
|
||||
|
||||
### Neu
|
||||
- Datenpakete lassen sich jetzt wie Mods, Ressourcenpakete und Shader direkt aus dem Modrinth-Katalog durchsuchen, installieren, aktivieren/deaktivieren und aktualisieren
|
||||
- Modpacks lassen sich jetzt auch aus dem CurseForge-Katalog durchsuchen und mit einem Klick installieren (bisher nur Modrinth und Technic; CurseForge ging nur per manuellem ZIP-Import) – braucht wie die bestehende CurseForge-Suche einen eigenen API-Key
|
||||
- OptiFine als eigenständige Loader-Option: Installer-Datei auswählen, AeroMC bereitet die passende Minecraft-Version vor und startet den offiziellen OptiFine-Installer sichtbar; nach Abschluss wird die neue Version automatisch übernommen
|
||||
- Von Hand hinzugefügte OptiFine-Dateien im Mods-Dialog werden jetzt erkannt und eigens gekennzeichnet (funktioniert bei jedem Loader, u. a. für Fabric + OptiFabric)
|
||||
- Lizenztext direkt in den Einstellungen unter Info einsehbar
|
||||
|
||||
### Geändert
|
||||
- Lizenzangabe von MIT auf die tatsächlich geltenden Nutzungsbedingungen umgestellt (nur privater, nicht-kommerzieller Gebrauch – siehe LICENSE)
|
||||
|
||||
## [1.0.7] - 2026-08-06
|
||||
|
||||
### Neu
|
||||
- Desktop-Verknüpfung pro Server: startet die Instanz und verbindet direkt mit dem hinterlegten Server (Quick Play, ab MC 1.20)
|
||||
- Name der Desktop-Verknüpfung (Instanz oder Server) lässt sich beim Erstellen frei selbst vergeben
|
||||
|
||||
### Behoben
|
||||
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus einen leeren Electron-Startbildschirm statt AeroMC zu öffnen
|
||||
- Desktop-Verknüpfungen zeigten im Entwicklungsmodus das Electron-Symbol statt des AeroMC-Logos
|
||||
- Desktop-Verknüpfungen (Instanz und Server) starteten bei kaltem Start unzuverlässig gar keine Instanz – der Startwunsch traf manchmal ein, bevor die Oberfläche bereit war, ihn zu empfangen, und ging dann verloren
|
||||
|
||||
## [1.0.6] - 2026-08-05
|
||||
|
||||
### Neu
|
||||
- Technic-Modpacks direkt durchsuchen und installieren (Katalog), oder eine vorhandene Technic-Installation importieren
|
||||
- Mods lassen sich jetzt auch als eigene Datei hinzufügen, nicht nur über die Suche
|
||||
- Dateien (Mods, Modpacks, Instanzen) per Drag & Drop aufs Fenster ziehen
|
||||
- Tastenkürzel: Strg+F (Suche), Strg+N (neue Instanz), Entf (Instanz löschen)
|
||||
- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird
|
||||
- Serverliste zeigt jetzt Live-Status (Spieleranzahl, MOTD, Version)
|
||||
- Ein-Klick-Knopf für empfohlene GC-Flags bei den JVM-Argumenten
|
||||
- Einzelne Welten lassen sich separat schnellsichern
|
||||
- Warnung, wenn ein Mod entfernt wird, den ein anderer installierter Mod noch braucht
|
||||
- Skin-Hintergrund kann automatisch zwischen mehreren angemeldeten Konten durchwechseln
|
||||
- Optionaler Autostart mit Windows (im Infobereich statt als Fenster)
|
||||
|
||||
### Geändert
|
||||
- Update-Suche läuft automatisch beim Start, Anzeige wird grün wenn alles aktuell ist
|
||||
|
||||
### Behoben
|
||||
- Bedrock-Start repariert (ging nach einem Windows-Update nicht mehr) und startete zwischenzeitlich doppelt
|
||||
- Absturz beim Schließen des Spiels durch hängende Mod-Threads (z. B. JourneyMap-Webmap) behoben
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
|
||||
|
||||
Aktueller Stand: Version 1.1.5
|
||||
Aktueller Stand: Version 1.2.0
|
||||
|
||||
<img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" />
|
||||
|
||||
@@ -41,7 +41,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
|
||||
- Warnung beim Entfernen eines Mods, der noch als Pflicht-Abhängigkeit gebraucht wird
|
||||
|
||||
**Import**
|
||||
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, MultiMC/Prism sowie Technic
|
||||
- Gemeinsamer Import für AeroMC-ZIP, Modrinth-Modpacks (`.mrpack`), Minecraft Launcher, CurseForge, **Modrinth App**, MultiMC/Prism sowie Technic
|
||||
- Schon bei der Installation: der Installer erkennt andere Launcher und AeroMC bietet deren Instanzen beim ersten Start zum Übernehmen an
|
||||
- Von Freunden geteilte Instanzen per Code einlösen
|
||||
|
||||
@@ -62,9 +62,12 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
|
||||
- Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar
|
||||
|
||||
**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
|
||||
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, einstellbare Deckkraft, optional wechselnd)
|
||||
- **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
|
||||
- **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“
|
||||
- Offiziellen Mojang-Umhang direkt im Launcher wechseln oder ablegen
|
||||
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
|
||||
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
|
||||
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
|
||||
- Welten mit einem Klick in eine andere Instanz kopieren
|
||||
@@ -80,7 +83,22 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
|
||||
- Update-Suche sofort beim Start, Updates installieren sich ohne Rückfragen
|
||||
- Optionaler Autostart mit Windows (im Infobereich statt als Fenster)
|
||||
- Rechtsklick-Menü in allen Textfeldern (Ausschneiden, Kopieren, Einfügen)
|
||||
- Design Hell/Dunkel, optionaler HTTP-Proxy und Wrapper-Befehl
|
||||
- **Fünf Designs**: Klassisch hell oder dunkel, „AeroMC“ im Look der Webseite, „Nachtlila“ (dasselbe in dunklem Lila) und „Minecraft“ mit Block-Optik und Pixel-Schrift – samt passender Titelleiste
|
||||
- Optionaler HTTP-Proxy und Wrapper-Befehl
|
||||
|
||||
## Designs
|
||||
|
||||
Unter **Optionen → Darstellung → Design** stehen fünf Designs zur Wahl. Ein Klick auf eine Vorschau-Kachel zeigt es sofort an, mit **Speichern** gilt es in allen Fenstern – inklusive Titelleiste und Fensterknöpfen.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="docs/design-aeromc.png" alt="AeroMC-Design: Nachtblau mit Cyan-Verläufen wie die Webseite" width="270" /><br /><b>AeroMC</b><br /><sub>Nachtblau, Cyan-Verläufe, runde Knöpfe – wie die Webseite</sub></td>
|
||||
<td align="center"><img src="docs/design-nachtlila.png" alt="Nachtlila-Design: wie AeroMC in dunklem Lila" width="270" /><br /><b>Nachtlila</b><br /><sub>wie AeroMC, nur in dunklem Lila</sub></td>
|
||||
<td align="center"><img src="docs/design-minecraft.png" alt="Minecraft-Design: Block-Optik mit Grasnarbe, Erde und Stein-Knöpfen" width="270" /><br /><b>Minecraft</b><br /><sub>Grasnarbe, Erde, Stein-Knöpfe und Pixel-Schrift</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Dazu **Klassisch Dunkel** (Bild ganz oben) und **Klassisch Hell** im bewährten Launcher-Look.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -105,7 +123,7 @@ Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable
|
||||
## Erste Schritte
|
||||
|
||||
1. AeroMC installieren oder die portable Datei starten.
|
||||
2. Über **Konten** ein Microsoft-Konto hinzufügen.
|
||||
2. Über den Konto-Knopf oben rechts (oder **Optionen → Konten**) ein Microsoft-Konto hinzufügen.
|
||||
3. Eine neue Instanz anlegen, Minecraft-Version und optional einen Loader wählen – bei Fabric, Quilt oder NeoForge auf Wunsch gleich mit Performance-Paket.
|
||||
4. Optional Mods/Ressourcenpakete/Shader hinzufügen oder bestehende Setups über **Importieren** übernehmen.
|
||||
5. Instanz starten (online oder offline).
|
||||
@@ -114,7 +132,7 @@ Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable
|
||||
|
||||
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
|
||||
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
|
||||
- eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste
|
||||
@@ -146,7 +164,7 @@ Im Launcher:
|
||||
|
||||
| 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+1 … Strg+5 | Schnellstart-Instanz starten |
|
||||
| Entf | Markierte Instanz(en) löschen |
|
||||
@@ -172,15 +190,20 @@ Im Spiel (solange ein über AeroMC gestartetes Minecraft läuft):
|
||||
| Modpack | Modrinth- oder CurseForge-Katalog direkt durchsuchen und installieren, oder Datei importieren: `.mrpack` sowie CurseForge-ZIP (`manifest.json`) |
|
||||
| Minecraft Launcher | Profile aus dem offiziellen Launcher |
|
||||
| CurseForge | Instanzen aus dem CurseForge-Ordner |
|
||||
| Modrinth App | Instanzen aus der Modrinth App (`%APPDATA%\ModrinthApp`) inkl. Einstellungen, Spielzeit und Modpack-Verknüpfung |
|
||||
| MultiMC / Prism | Vorhandene MultiMC- oder Prism-Instanzen |
|
||||
| Technic | Technic-Katalog direkt durchsuchen und installieren, oder eine vorhandene Technic-Installation importieren (`%APPDATA%\.technic\modpacks`) |
|
||||
| Per Code | Von einem Freund geteilte Instanz einlösen (siehe **Instanzen → Instanz teilen**) |
|
||||
|
||||
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.
|
||||
|
||||
**Schon bei der Installation:** Der Installer sucht nach Minecraft Launcher, CurseForge, MultiMC, Prism Launcher, PolyMC und Technic – ein portables MultiMC auch über seine Verknüpfung auf dem Desktop oder im Startmenü. Was dort angekreuzt wird, öffnet AeroMC beim ersten Start fertig durchsucht im Import; ausgewählt wird dann nur noch, was übernommen werden soll. In den anderen Launchern 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.
|
||||
|
||||
**Schon bei der Installation:** Der Installer sucht nach Minecraft Launcher, CurseForge, Modrinth App, MultiMC, Prism Launcher, PolyMC und Technic – ein portables MultiMC auch über seine Verknüpfung auf dem Desktop oder im Startmenü. Was dort angekreuzt wird, öffnet AeroMC beim ersten Start fertig durchsucht im Import; ausgewählt wird dann nur noch, was übernommen werden soll. In den anderen Launchern wird nichts verändert.
|
||||
|
||||
Alternativ: `.jar`-, `.mrpack`- oder `.zip`-Dateien direkt aufs Launcher-Fenster ziehen. AeroMC erkennt anhand des Inhalts, ob es sich um einen Mod (landet in der gerade ausgewählten Instanz), ein Modpack oder einen Instanz-Export handelt.
|
||||
|
||||
@@ -222,7 +245,6 @@ Ressourcenpakete und Shader funktionieren auch in Vanilla-Instanzen. Mods benöt
|
||||
- Meldung im Spiel mit 3D-Spielerkopf, sobald ein Freund online kommt
|
||||
- **Beitreten** startet eine passende eigene Instanz (gleiche Minecraft-Version, bevorzugt mit dem Server in der Liste) und verbindet direkt; gibt es keine, wird eine Vanilla-Instanz angelegt
|
||||
- **Einladen** schickt Freunden den eigenen aktuellen Server. Im Launcher erscheint die Einladung mit „Beitreten“, im Spiel als Meldung – dort kopiert **Strg+Alt+J** die Adresse für „Direkt verbinden“
|
||||
|
||||
- **Kurznachrichten**: 💬 beim Freund öffnet eine kleine Unterhaltung mit Schnellantworten („Bin in 10 Min. online“, „Komm online!“ …) oder eigenem Text bis 200 Zeichen. Neue Nachrichten erscheinen als Hinweis im Launcher und – beim Spielen – als Meldung im Spiel; der Zähler am „Freunde“-Knopf zählt offene Anfragen und ungelesene Nachrichten. Nachrichten werden nach 7 Tagen gelöscht.
|
||||
- **Screenshots verschicken**: In **Bildschirmfotos verwalten** schickt 📤 ein Bild (verkleinert auf höchstens 1600 Pixel Breite) samt optionalem Text an einen Freund; er sieht es im Chat.
|
||||
|
||||
@@ -234,15 +256,61 @@ Das Freundesystem funktioniert nur zwischen Spielern, die AeroMC nutzen und mind
|
||||
|
||||
## Skin ändern
|
||||
|
||||
Über **Konten → Skin ändern…** lässt sich der Skin des aktiven Microsoft-Kontos direkt im Launcher ändern:
|
||||
Über den **eigenen Kontonamen oben rechts → Skin ändern…** lässt sich der Skin des aktiven Microsoft-Kontos direkt im Launcher ändern (der Knopf **Skin** in der Werkzeugleiste schaltet dagegen nur den Skin-Hintergrund ein/aus):
|
||||
|
||||
- PNG-Datei auswählen, sofortige Prüfung auf gültige Maße
|
||||
- Live-Vorschau am 3D-Modell vor dem Anwenden
|
||||
- Modell wählen: klassisch oder schlank (slim)
|
||||
- Auf einen früheren Standard-Skin zurücksetzen
|
||||
- Auf den Standard-Skin (Steve/Alex) zurücksetzen – einen früheren eigenen Skin holt das nicht zurück; dafür vorher im Skin-Editor „Als PNG speichern…“ nutzen
|
||||
|
||||
Die Änderung wird direkt bei Mojang für das angemeldete Konto hinterlegt und gilt somit auch außerhalb von AeroMC.
|
||||
|
||||
### Skin-Editor
|
||||
|
||||
**Im Editor bearbeiten…** öffnet einen eigenen Pixel-Editor – mit der gewählten PNG-Datei oder dem aktuellen Skin des Kontos:
|
||||
|
||||
- **Zwei Schichten:** Grundschicht und 2. Schicht (die Hülle über der Figur für Haare, Jacken, Ärmel …) getrennt bemalen; 2. Schicht ein-/ausblenden, Grundschicht darunter durchscheinen lassen, 2. Schicht leeren
|
||||
- **2D oder 3D:** auf der aufgeklappten Vorlage oder direkt auf der drehbaren Figur malen – die andere Ansicht läuft als Live-Vorschau mit und ist ebenfalls bemalbar
|
||||
- **Werkzeuge:** Stift, Radierer (nur 2. Schicht), Pipette (auch Alt+Klick), Füllen, Farbwähler mit zuletzt benutzten Farben
|
||||
- **Spiegeln:** malt gleichzeitig auf der anderen Körperseite
|
||||
- Rückgängig/Wiederholen, klassische oder schmale Arme, alte 64×32-Skins werden automatisch umgewandelt
|
||||
- Neu beginnen, aktuellen Skin laden oder eine PNG-Datei öffnen
|
||||
- **Als Skin übernehmen** (direkt hochladen) oder **Als PNG speichern…**
|
||||
|
||||
| Taste | Wirkung |
|
||||
|---|---|
|
||||
| B / E / I / G | Stift / Radierer / Pipette / Füllen |
|
||||
| M | Spiegeln ein/aus |
|
||||
| L | Schicht wechseln |
|
||||
| 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
|
||||
|
||||
Unter **Optionen → Backup**:
|
||||
@@ -261,7 +329,6 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
|
||||
## Einstellungen
|
||||
|
||||
### Allgemein
|
||||
- Design (Dunkel / Hell)
|
||||
- Instanzen sortieren (nach letztem Start / nach Name)
|
||||
- Instanz-Verzeichnis
|
||||
- Bereiche: Bedrock-Bereich anzeigen
|
||||
@@ -270,11 +337,17 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
|
||||
- Autostart: mit Windows starten, im Infobereich statt als Fenster – solange aktiv, versteckt „Schließen“ (X) den Launcher nur. Das Symbol im Infobereich gibt es immer; per Rechtsklick startest du dort Instanzen.
|
||||
|
||||
### Darstellung
|
||||
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
|
||||
- Block-Symbole räumlich (3D)
|
||||
- Skin-Hintergrund: Quelle, Pose, Deckkraft, optional automatischer Wechsel zwischen mehreren Konten
|
||||
- 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
|
||||
|
||||
### Spiel
|
||||
- Standard-RAM (min./max.)
|
||||
- Umhänge: AeroMC-Umhänge im Spiel anzeigen (gilt ab dem nächsten Start)
|
||||
- Fenstergröße bzw. maximiert starten
|
||||
- Mods: vor jedem Start automatisch nach Updates suchen und installieren
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.aeromc.capes;
|
||||
|
||||
import java.lang.instrument.Instrumentation;
|
||||
|
||||
/**
|
||||
* AeroMC-Umhang-Agent.
|
||||
*
|
||||
* Wird vom Launcher per -javaagent an Minecraft angehängt (jede Version, jeder
|
||||
* Loader). Er klinkt sich in Mojangs authlib ein – über sie lädt Minecraft für
|
||||
* jeden Spieler Skin und Umhang – und setzt dort den AeroMC-Umhang des Spielers
|
||||
* ein, falls er einen hat. Alles Weitere (Herunterladen, Zwischenspeichern,
|
||||
* Anzeigen, auch HD) erledigt Minecraft selbst wie bei einem offiziellen Umhang.
|
||||
*
|
||||
* Argument: Basisadresse des Umhang-Servers (…/wp-json/aeromc/v1).
|
||||
*/
|
||||
public final class Agent {
|
||||
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
|
||||
public static final String SCHLUESSEL = "aeromc.capes.hook";
|
||||
|
||||
private Agent() {}
|
||||
|
||||
public static void premain(String argument, Instrumentation inst) {
|
||||
starte(argument, inst);
|
||||
}
|
||||
|
||||
public static void agentmain(String argument, Instrumentation inst) {
|
||||
starte(argument, inst);
|
||||
}
|
||||
|
||||
private static void starte(String argument, Instrumentation inst) {
|
||||
try {
|
||||
String basis = argument != null && argument.startsWith("http") ? argument : Nachschlagen.STANDARD_BASIS;
|
||||
// Die eingefügten Befehle in authlib holen sich den Dienst von hier – nur
|
||||
// über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt.
|
||||
System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis));
|
||||
inst.addTransformer(new Umbau());
|
||||
System.out.println("[AeroMC] Umhang-Agent aktiv");
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] Umhang-Agent konnte nicht starten: " + t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package de.aeromc.capes;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Wird aus authlib heraus mit (ergebnis, argument) aufgerufen und gibt das
|
||||
* Ergebnis zurück – mit eingesetztem AeroMC-Umhang, falls der Spieler einen hat.
|
||||
* Arbeitet nur über Reflexion, damit es zu jeder authlib-Fassung passt, und
|
||||
* fängt jeden Fehler ab: im schlimmsten Fall bleibt alles wie ohne Agent.
|
||||
*/
|
||||
public final class Nachschlagen implements BiFunction<Object, Object, Object> {
|
||||
public static final String STANDARD_BASIS = "https://viper-network.de/wp-json/aeromc/v1";
|
||||
private static final long GEFUNDEN_MS = 5 * 60 * 1000L;
|
||||
private static final long KEINER_MS = 2 * 60 * 1000L;
|
||||
private static final Pattern URL_MUSTER = Pattern.compile("\"url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
|
||||
private static final Pattern ID_MUSTER = Pattern.compile("\"profileId\"\\s*:\\s*\"([0-9a-fA-F-]{32,36})\"");
|
||||
|
||||
private static final class Eintrag {
|
||||
final String url; // null = kein Umhang
|
||||
final long zeit;
|
||||
Eintrag(String url) { this.url = url; this.zeit = System.currentTimeMillis(); }
|
||||
boolean frisch() { return System.currentTimeMillis() - zeit < (url != null ? GEFUNDEN_MS : KEINER_MS); }
|
||||
}
|
||||
|
||||
private final String basis;
|
||||
private final Map<String, Eintrag> speicher = new ConcurrentHashMap<>();
|
||||
private final Map<String, Boolean> unterwegs = new ConcurrentHashMap<>();
|
||||
private final Map<String, Boolean> gemeldet = new ConcurrentHashMap<>();
|
||||
|
||||
public Nachschlagen(String basis) {
|
||||
this.basis = basis.endsWith("/") ? basis.substring(0, basis.length() - 1) : basis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object apply(Object ergebnis, Object argument) {
|
||||
try {
|
||||
String id = spielerId(argument);
|
||||
if (id == null) return ergebnis;
|
||||
String url = umhangUrl(id);
|
||||
if (url == null) return ergebnis;
|
||||
Object neu = mitUmhang(ergebnis, url, argument);
|
||||
if (neu != ergebnis && gemeldet.putIfAbsent(id, Boolean.TRUE) == null) {
|
||||
System.out.println("[AeroMC] Umhang fuer " + id + " eingesetzt");
|
||||
}
|
||||
return neu;
|
||||
} catch (Throwable t) {
|
||||
return ergebnis;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Spieler erkennen ----------
|
||||
private static String spielerId(Object argument) throws Exception {
|
||||
if (argument == null) return null;
|
||||
String klasse = argument.getClass().getName();
|
||||
if (klasse.endsWith("GameProfile")) {
|
||||
Object id = rufe(argument, "getId", "id");
|
||||
return id instanceof UUID ? hex((UUID) id) : null;
|
||||
}
|
||||
if (klasse.endsWith("Property")) {
|
||||
Object wert = rufe(argument, "getValue", "value");
|
||||
if (!(wert instanceof String)) return null;
|
||||
String json = new String(Base64.getDecoder().decode(((String) wert).trim()), StandardCharsets.UTF_8);
|
||||
Matcher m = ID_MUSTER.matcher(json);
|
||||
return m.find() ? m.group(1).replace("-", "").toLowerCase() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String hex(UUID u) {
|
||||
return u.toString().replace("-", "").toLowerCase();
|
||||
}
|
||||
|
||||
private static Object rufe(Object ziel, String... namen) throws Exception {
|
||||
for (String n : namen) {
|
||||
try {
|
||||
Method m = ziel.getClass().getMethod(n);
|
||||
return m.invoke(ziel);
|
||||
} catch (NoSuchMethodException ignoriert) {
|
||||
// nächster Name
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Umhang-Server ----------
|
||||
private String umhangUrl(String id) {
|
||||
Eintrag e = speicher.get(id);
|
||||
if (e != null && e.frisch()) return e.url;
|
||||
// Auf dem Haupt-/Render-Thread nie warten: dort nur im Hintergrund holen;
|
||||
// Minecraft fragt beim nächsten Laden des Spielers erneut
|
||||
String thread = Thread.currentThread().getName();
|
||||
boolean hauptThread = thread.equals("Render thread") || thread.equals("Client thread") || thread.equals("main");
|
||||
if (hauptThread) {
|
||||
if (unterwegs.putIfAbsent(id, Boolean.TRUE) == null) {
|
||||
Thread t = new Thread(() -> { try { hole(id); } finally { unterwegs.remove(id); } }, "AeroMC-Umhang");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
return e != null ? e.url : null;
|
||||
}
|
||||
return hole(id);
|
||||
}
|
||||
|
||||
private String hole(String id) {
|
||||
try {
|
||||
HttpURLConnection c = (HttpURLConnection) new URL(basis + "/capes?uuids=" + id).openConnection();
|
||||
c.setConnectTimeout(2000);
|
||||
c.setReadTimeout(3000);
|
||||
c.setRequestProperty("User-Agent", "AeroMC-Umhang/1.0");
|
||||
c.setRequestProperty("Accept", "application/json");
|
||||
if (c.getResponseCode() != 200) {
|
||||
speicher.put(id, new Eintrag(null));
|
||||
return null;
|
||||
}
|
||||
String json;
|
||||
try (InputStream in = c.getInputStream()) {
|
||||
ByteArrayOutputStream puffer = new ByteArrayOutputStream();
|
||||
byte[] b = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(b)) > 0 && puffer.size() < 65536) puffer.write(b, 0, n);
|
||||
json = new String(puffer.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
String url = null;
|
||||
Matcher m = URL_MUSTER.matcher(json);
|
||||
if (json.contains("\"" + id + "\"") && m.find()) {
|
||||
url = m.group(1).replace("\\/", "/");
|
||||
if (!url.startsWith("https://")) url = null;
|
||||
}
|
||||
speicher.put(id, new Eintrag(url));
|
||||
return url;
|
||||
} catch (Throwable t) {
|
||||
// Server nicht erreichbar: kurz merken, damit nicht jeder Spieler hängt
|
||||
speicher.put(id, new Eintrag(null));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Umhang einsetzen ----------
|
||||
private static Object textur(ClassLoader cl, String url) throws Exception {
|
||||
Class<?> k = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture", false, cl);
|
||||
Constructor<?> ctor = k.getConstructor(String.class, Map.class);
|
||||
return ctor.newInstance(url, null);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static Object mitUmhang(Object ergebnis, String url, Object argument) throws Exception {
|
||||
ClassLoader cl = argument.getClass().getClassLoader();
|
||||
if (ergebnis instanceof Map) {
|
||||
// authlib 1–4: Map<MinecraftProfileTexture.Type, MinecraftProfileTexture>
|
||||
Class<?> typ = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture$Type", false, cl);
|
||||
Object cape = Enum.valueOf((Class<Enum>) typ, "CAPE");
|
||||
Map neu = new HashMap((Map) ergebnis);
|
||||
neu.put(cape, textur(cl, url));
|
||||
return neu;
|
||||
}
|
||||
if (ergebnis == null) return null;
|
||||
// authlib 5+: record MinecraftProfileTextures(skin, cape, elytra, signatureState)
|
||||
Class<?> k = ergebnis.getClass();
|
||||
Object skin = k.getMethod("skin").invoke(ergebnis);
|
||||
Object elytra = k.getMethod("elytra").invoke(ergebnis);
|
||||
Object signatur = k.getMethod("signatureState").invoke(ergebnis);
|
||||
for (Constructor<?> ctor : k.getConstructors()) {
|
||||
if (ctor.getParameterCount() == 4) {
|
||||
return ctor.newInstance(skin, textur(cl, url), elytra, signatur);
|
||||
}
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.aeromc.capes;
|
||||
|
||||
import java.lang.instrument.ClassFileTransformer;
|
||||
import java.security.ProtectionDomain;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassVisitor;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.MethodVisitor;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
|
||||
/**
|
||||
* Baut YggdrasilMinecraftSessionService beim Laden um: vor jedem "return" der
|
||||
* Textur-Methode wird das Ergebnis an den Nachschlage-Dienst gereicht, der den
|
||||
* AeroMC-Umhang einsetzt.
|
||||
*
|
||||
* authlib 5+ (Minecraft 1.20.2 … 26.x):
|
||||
* MinecraftProfileTextures unpackTextures(Property)
|
||||
* authlib 1–4 (Minecraft 1.8 … 1.20.1):
|
||||
* Map<Type, MinecraftProfileTexture> getTextures(GameProfile, boolean)
|
||||
*
|
||||
* Eingefügt wird nur geradliniger Code ohne Sprünge – die vorhandenen
|
||||
* Stack-Map-Frames bleiben gültig, es muss nichts neu berechnet werden.
|
||||
*/
|
||||
final class Umbau implements ClassFileTransformer {
|
||||
static final String ZIEL = "com/mojang/authlib/yggdrasil/YggdrasilMinecraftSessionService";
|
||||
|
||||
@Override
|
||||
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
|
||||
if (!ZIEL.equals(name) || umdefiniert != null) return null;
|
||||
try {
|
||||
ClassReader leser = new ClassReader(klasse);
|
||||
ClassWriter schreiber = new ClassWriter(leser, ClassWriter.COMPUTE_MAXS);
|
||||
final int[] umgebaut = {0};
|
||||
leser.accept(new ClassVisitor(Opcodes.ASM9, schreiber) {
|
||||
@Override
|
||||
public MethodVisitor visitMethod(int zugriff, String mname, String desc, String sig, String[] ausn) {
|
||||
MethodVisitor mv = super.visitMethod(zugriff, mname, desc, sig, ausn);
|
||||
if ((zugriff & Opcodes.ACC_STATIC) != 0) return mv;
|
||||
String rueckgabe = null;
|
||||
if ("unpackTextures".equals(mname) && desc.startsWith("(Lcom/mojang/authlib/properties/Property;)L")) {
|
||||
rueckgabe = desc.substring(desc.indexOf(")L") + 2, desc.length() - 1);
|
||||
} else if ("getTextures".equals(mname) && desc.equals("(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;")) {
|
||||
rueckgabe = "java/util/Map";
|
||||
}
|
||||
if (rueckgabe == null) return mv;
|
||||
umgebaut[0]++;
|
||||
final String typ = rueckgabe;
|
||||
return new MethodVisitor(Opcodes.ASM9, mv) {
|
||||
@Override
|
||||
public void visitInsn(int op) {
|
||||
if (op == Opcodes.ARETURN) {
|
||||
// Stapel: [ergebnis] -> [dienst, ergebnis, argument] -> apply -> [neu]
|
||||
super.visitVarInsn(Opcodes.ALOAD, 1);
|
||||
super.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false);
|
||||
super.visitLdcInsn(Agent.SCHLUESSEL);
|
||||
super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false);
|
||||
super.visitTypeInsn(Opcodes.CHECKCAST, "java/util/function/BiFunction");
|
||||
super.visitInsn(Opcodes.DUP_X2);
|
||||
super.visitInsn(Opcodes.POP);
|
||||
super.visitMethodInsn(Opcodes.INVOKEINTERFACE, "java/util/function/BiFunction", "apply",
|
||||
"(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", true);
|
||||
super.visitTypeInsn(Opcodes.CHECKCAST, typ);
|
||||
}
|
||||
super.visitInsn(op);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, 0);
|
||||
if (umgebaut[0] == 0) return null;
|
||||
System.out.println("[AeroMC] authlib fuer Umhaenge vorbereitet (" + umgebaut[0] + " Methode)");
|
||||
return schreiber.toByteArray();
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] authlib konnte nicht angepasst werden: " + t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Enumeration;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.commons.ClassRemapper;
|
||||
import org.objectweb.asm.commons.Remapper;
|
||||
|
||||
/**
|
||||
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
|
||||
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
|
||||
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
|
||||
*
|
||||
* Aufruf: java Shade <ziel.jar> <klassenordner> <asm.jar>
|
||||
*/
|
||||
public class Shade {
|
||||
static final Remapper UMBENENNEN = new Remapper() {
|
||||
@Override
|
||||
public String map(String name) {
|
||||
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
|
||||
}
|
||||
};
|
||||
|
||||
static byte[] umbenennen(byte[] klasse) {
|
||||
ClassReader r = new ClassReader(klasse);
|
||||
ClassWriter w = new ClassWriter(0);
|
||||
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
|
||||
return w.toByteArray();
|
||||
}
|
||||
|
||||
static byte[] lies(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||
byte[] p = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(p)) > 0) b.write(p, 0, n);
|
||||
return b.toByteArray();
|
||||
}
|
||||
|
||||
public static void main(String[] a) throws Exception {
|
||||
Manifest mf = new Manifest();
|
||||
Attributes at = mf.getMainAttributes();
|
||||
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
||||
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Can-Retransform-Classes", "false");
|
||||
at.putValue("Implementation-Title", "AeroMC Umhang-Agent");
|
||||
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
|
||||
// eigene Klassen
|
||||
File basis = new File(a[1]);
|
||||
for (File f : Files.walk(basis.toPath()).map(p -> p.toFile()).filter(f -> f.getName().endsWith(".class")).toArray(File[]::new)) {
|
||||
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
|
||||
out.putNextEntry(new JarEntry(name));
|
||||
out.write(umbenennen(Files.readAllBytes(f.toPath())));
|
||||
out.closeEntry();
|
||||
}
|
||||
// ASM (umbenannt), ohne Modul-Infos und Signaturen
|
||||
try (JarFile asm = new JarFile(a[2])) {
|
||||
Enumeration<JarEntry> e = asm.entries();
|
||||
while (e.hasMoreElements()) {
|
||||
JarEntry je = e.nextElement();
|
||||
String n = je.getName();
|
||||
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
|
||||
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
|
||||
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
|
||||
out.write(neu);
|
||||
out.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 169 KiB |
@@ -1,176 +1,194 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
namespace AeroMCSetup;
|
||||
|
||||
/// <summary>Ein gefundener anderer Launcher, dessen Instanzen AeroMC übernehmen kann.</summary>
|
||||
/// <param name="kind">Kennung wie im Import-Dialog des Launchers: mc, cf, mmc, technic</param>
|
||||
public sealed class FoundLauncher(string kind, string name, string path, int count, string unit)
|
||||
{
|
||||
public string Kind { get; } = kind;
|
||||
public string Name { get; } = name;
|
||||
public string Path { get; } = path;
|
||||
public int Count { get; } = count;
|
||||
public string Unit { get; } = unit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht andere Launcher an ihren üblichen Orten – nur lesend, es wird nichts verändert.
|
||||
/// Übernommen wird später im Launcher selbst (gleicher Import wie unter „Importieren“):
|
||||
/// das Setup legt dafür nur pending-import.json ab, AeroMC öffnet beim ersten Start
|
||||
/// den Import-Dialog damit fertig durchsucht.
|
||||
/// </summary>
|
||||
public static class LauncherImport
|
||||
{
|
||||
private static string AppData => Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
private static string Home => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
private static string PendingFile => System.IO.Path.Combine(Installer.DataDir, "pending-import.json");
|
||||
|
||||
public static List<FoundLauncher> Detect()
|
||||
{
|
||||
var found = new List<FoundLauncher>();
|
||||
void Add(Func<FoundLauncher?> probe)
|
||||
{
|
||||
try { if (probe() is { Count: > 0 } f) found.Add(f); } catch { /* unlesbar → einfach nicht anbieten */ }
|
||||
}
|
||||
Add(DetectMinecraftLauncher);
|
||||
Add(DetectCurseForge);
|
||||
Add(() => DetectMultiMc("Prism Launcher", System.IO.Path.Combine(AppData, "PrismLauncher")));
|
||||
Add(() => DetectMultiMc("PolyMC", System.IO.Path.Combine(AppData, "PolyMC")));
|
||||
// MultiMC (und portable Prism/PolyMC) liegen irgendwo – über ihre Verknüpfungen finden
|
||||
foreach (var (name, root) in PortableMultiMcFromShortcuts())
|
||||
{
|
||||
if (found.Any(f => f.Kind == "mmc" && SamePath(f.Path, root))) continue;
|
||||
Add(() => DetectMultiMc(name, root));
|
||||
}
|
||||
Add(DetectTechnic);
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---------- offizieller Minecraft Launcher ----------
|
||||
|
||||
private static readonly string[] ProfileFiles =
|
||||
["launcher_profiles.json", "launcher_profiles_microsoft_store.json", "launcher_profiles_microsoft_store_2.json"];
|
||||
|
||||
private static FoundLauncher? DetectMinecraftLauncher()
|
||||
{
|
||||
var root = System.IO.Path.Combine(AppData, ".minecraft");
|
||||
var file = ProfileFiles.Select(f => System.IO.Path.Combine(root, f)).FirstOrDefault(File.Exists);
|
||||
if (file is null) return null;
|
||||
|
||||
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }
|
||||
.Deserialize<Dictionary<string, object>>(File.ReadAllText(file, Encoding.UTF8).TrimStart(''));
|
||||
if (json?.TryGetValue("profiles", out var p) != true || p is not Dictionary<string, object> profiles) return null;
|
||||
|
||||
// wie isImportableProfile in src/mcimport.js: die automatischen "Neueste Version"-Profile zählen nicht
|
||||
int count = profiles.Values.OfType<Dictionary<string, object>>().Count(profile =>
|
||||
{
|
||||
var type = (Get(profile, "type") ?? "").Trim().ToLowerInvariant();
|
||||
if (type is "latest-release" or "latest-snapshot") return false;
|
||||
return !string.IsNullOrWhiteSpace(Get(profile, "name")) || !string.IsNullOrWhiteSpace(Get(profile, "lastVersionId"));
|
||||
});
|
||||
return new FoundLauncher("mc", "Minecraft Launcher", root, count, count == 1 ? "Profil" : "Profile");
|
||||
}
|
||||
|
||||
private static string? Get(Dictionary<string, object> d, string key) => d.TryGetValue(key, out var v) ? v as string : null;
|
||||
|
||||
// ---------- CurseForge ----------
|
||||
|
||||
private static FoundLauncher? DetectCurseForge()
|
||||
{
|
||||
var dir = new[]
|
||||
{
|
||||
System.IO.Path.Combine(Home, "curseforge", "minecraft", "Instances"),
|
||||
System.IO.Path.Combine(Home, "CurseForge", "minecraft", "Instances"),
|
||||
}.FirstOrDefault(Directory.Exists);
|
||||
if (dir is null) return null;
|
||||
|
||||
string[] markers = ["minecraftinstance.json", "instance.json", "mods", "config", "resourcepacks", "shaderpacks", "saves", "options.txt"];
|
||||
int count = Directory.GetDirectories(dir).Count(d => markers.Any(m => Exists(d, m)));
|
||||
return new FoundLauncher("cf", "CurseForge", dir, count, count == 1 ? "Instanz" : "Instanzen");
|
||||
}
|
||||
|
||||
// ---------- Prism Launcher / PolyMC (MultiMC-Format) ----------
|
||||
|
||||
private static FoundLauncher? DetectMultiMc(string name, string root)
|
||||
{
|
||||
var instances = System.IO.Path.Combine(root, "instances");
|
||||
if (!Directory.Exists(instances)) return null;
|
||||
int count = Directory.GetDirectories(instances).Count(d => File.Exists(System.IO.Path.Combine(d, "instance.cfg")));
|
||||
return new FoundLauncher("mmc", name, root, count, count == 1 ? "Instanz" : "Instanzen");
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> MultiMcExes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MultiMC.exe"] = "MultiMC",
|
||||
["prismlauncher.exe"] = "Prism Launcher",
|
||||
["polymc.exe"] = "PolyMC",
|
||||
};
|
||||
|
||||
/// <summary>Ordner von MultiMC-artigen Launchern, auf die eine Verknüpfung (Desktop/Startmenü) zeigt.</summary>
|
||||
private static IEnumerable<(string Name, string Root)> PortableMultiMcFromShortcuts()
|
||||
{
|
||||
var places = new[]
|
||||
{
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), SearchOption.TopDirectoryOnly),
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), SearchOption.TopDirectoryOnly),
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.Programs), SearchOption.AllDirectories),
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), SearchOption.AllDirectories),
|
||||
};
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (dir, option) in places)
|
||||
{
|
||||
string[] links;
|
||||
try { links = string.IsNullOrEmpty(dir) || !Directory.Exists(dir) ? [] : Directory.GetFiles(dir, "*.lnk", option); }
|
||||
catch { continue; }
|
||||
foreach (var link in links)
|
||||
{
|
||||
var target = Shortcut.Target(link);
|
||||
if (string.IsNullOrEmpty(target) || !MultiMcExes.TryGetValue(System.IO.Path.GetFileName(target), out var name)) continue;
|
||||
var root = System.IO.Path.GetDirectoryName(target);
|
||||
if (root is null || !Directory.Exists(System.IO.Path.Combine(root, "instances")) || !seen.Add(root)) continue;
|
||||
yield return (name, root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b) =>
|
||||
string.Equals(System.IO.Path.GetFullPath(a).TrimEnd('\\'), System.IO.Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// ---------- Technic ----------
|
||||
|
||||
private static FoundLauncher? DetectTechnic()
|
||||
{
|
||||
var modpacks = System.IO.Path.Combine(AppData, ".technic", "modpacks");
|
||||
if (!Directory.Exists(modpacks)) return null;
|
||||
string[] markers = ["bin", "mods", "config", "saves", "resourcepacks", "options.txt"];
|
||||
int count = Directory.GetDirectories(modpacks).Count(d => markers.Any(m => Exists(d, m)));
|
||||
return new FoundLauncher("technic", "Technic Launcher", modpacks, count, count == 1 ? "Modpack" : "Modpacks");
|
||||
}
|
||||
|
||||
private static bool Exists(string dir, string name)
|
||||
{
|
||||
var p = System.IO.Path.Combine(dir, name);
|
||||
return File.Exists(p) || Directory.Exists(p);
|
||||
}
|
||||
|
||||
// ---------- Übergabe an den Launcher ----------
|
||||
|
||||
/// <summary>Merkt die ausgewählten Launcher für den ersten Start von AeroMC vor (oder räumt eine alte Vormerkung weg).</summary>
|
||||
public static void WritePending(IReadOnlyCollection<FoundLauncher> selected)
|
||||
{
|
||||
if (selected.Count == 0)
|
||||
{
|
||||
try { if (File.Exists(PendingFile)) File.Delete(PendingFile); } catch { }
|
||||
return;
|
||||
}
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["sources"] = selected.Select(s => new Dictionary<string, string> { ["kind"] = s.Kind, ["path"] = s.Path }).ToList(),
|
||||
};
|
||||
Directory.CreateDirectory(Installer.DataDir);
|
||||
File.WriteAllText(PendingFile, new JavaScriptSerializer().Serialize(data), new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
namespace AeroMCSetup;
|
||||
|
||||
/// <summary>Ein gefundener anderer Launcher, dessen Instanzen AeroMC übernehmen kann.</summary>
|
||||
/// <param name="kind">Kennung wie im Import-Dialog des Launchers: mc, cf, mr, mmc, technic</param>
|
||||
public sealed class FoundLauncher(string kind, string name, string path, int count, string unit)
|
||||
{
|
||||
public string Kind { get; } = kind;
|
||||
public string Name { get; } = name;
|
||||
public string Path { get; } = path;
|
||||
public int Count { get; } = count;
|
||||
public string Unit { get; } = unit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht andere Launcher an ihren üblichen Orten – nur lesend, es wird nichts verändert.
|
||||
/// Übernommen wird später im Launcher selbst (gleicher Import wie unter „Importieren“):
|
||||
/// das Setup legt dafür nur pending-import.json ab, AeroMC öffnet beim ersten Start
|
||||
/// den Import-Dialog damit fertig durchsucht.
|
||||
/// </summary>
|
||||
public static class LauncherImport
|
||||
{
|
||||
private static string AppData => Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
private static string Home => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
private static string PendingFile => System.IO.Path.Combine(Installer.DataDir, "pending-import.json");
|
||||
|
||||
public static List<FoundLauncher> Detect()
|
||||
{
|
||||
var found = new List<FoundLauncher>();
|
||||
void Add(Func<FoundLauncher?> probe)
|
||||
{
|
||||
try { if (probe() is { Count: > 0 } f) found.Add(f); } catch { /* unlesbar → einfach nicht anbieten */ }
|
||||
}
|
||||
Add(DetectMinecraftLauncher);
|
||||
Add(DetectCurseForge);
|
||||
Add(DetectModrinthApp);
|
||||
Add(() => DetectMultiMc("Prism Launcher", System.IO.Path.Combine(AppData, "PrismLauncher")));
|
||||
Add(() => DetectMultiMc("PolyMC", System.IO.Path.Combine(AppData, "PolyMC")));
|
||||
// MultiMC (und portable Prism/PolyMC) liegen irgendwo – über ihre Verknüpfungen finden
|
||||
foreach (var (name, root) in PortableMultiMcFromShortcuts())
|
||||
{
|
||||
if (found.Any(f => f.Kind == "mmc" && SamePath(f.Path, root))) continue;
|
||||
Add(() => DetectMultiMc(name, root));
|
||||
}
|
||||
Add(DetectTechnic);
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---------- offizieller Minecraft Launcher ----------
|
||||
|
||||
private static readonly string[] ProfileFiles =
|
||||
["launcher_profiles.json", "launcher_profiles_microsoft_store.json", "launcher_profiles_microsoft_store_2.json"];
|
||||
|
||||
private static FoundLauncher? DetectMinecraftLauncher()
|
||||
{
|
||||
var root = System.IO.Path.Combine(AppData, ".minecraft");
|
||||
var file = ProfileFiles.Select(f => System.IO.Path.Combine(root, f)).FirstOrDefault(File.Exists);
|
||||
if (file is null) return null;
|
||||
|
||||
var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }
|
||||
.Deserialize<Dictionary<string, object>>(File.ReadAllText(file, Encoding.UTF8).TrimStart(''));
|
||||
if (json?.TryGetValue("profiles", out var p) != true || p is not Dictionary<string, object> profiles) return null;
|
||||
|
||||
// wie isImportableProfile in src/mcimport.js: die automatischen "Neueste Version"-Profile zählen nicht
|
||||
int count = profiles.Values.OfType<Dictionary<string, object>>().Count(profile =>
|
||||
{
|
||||
var type = (Get(profile, "type") ?? "").Trim().ToLowerInvariant();
|
||||
if (type is "latest-release" or "latest-snapshot") return false;
|
||||
return !string.IsNullOrWhiteSpace(Get(profile, "name")) || !string.IsNullOrWhiteSpace(Get(profile, "lastVersionId"));
|
||||
});
|
||||
return new FoundLauncher("mc", "Minecraft Launcher", root, count, count == 1 ? "Profil" : "Profile");
|
||||
}
|
||||
|
||||
private static string? Get(Dictionary<string, object> d, string key) => d.TryGetValue(key, out var v) ? v as string : null;
|
||||
|
||||
// ---------- CurseForge ----------
|
||||
|
||||
private static FoundLauncher? DetectCurseForge()
|
||||
{
|
||||
var dir = new[]
|
||||
{
|
||||
System.IO.Path.Combine(Home, "curseforge", "minecraft", "Instances"),
|
||||
System.IO.Path.Combine(Home, "CurseForge", "minecraft", "Instances"),
|
||||
}.FirstOrDefault(Directory.Exists);
|
||||
if (dir is null) return null;
|
||||
|
||||
string[] markers = ["minecraftinstance.json", "instance.json", "mods", "config", "resourcepacks", "shaderpacks", "saves", "options.txt"];
|
||||
int count = Directory.GetDirectories(dir).Count(d => markers.Any(m => Exists(d, m)));
|
||||
return new FoundLauncher("cf", "CurseForge", dir, count, count == 1 ? "Instanz" : "Instanzen");
|
||||
}
|
||||
|
||||
// ---------- Modrinth App ----------
|
||||
|
||||
private static FoundLauncher? DetectModrinthApp()
|
||||
{
|
||||
// neuere Fassungen: ModrinthApp, sehr alte: com.modrinth.theseus (siehe src/mrimport.js)
|
||||
var root = new[]
|
||||
{
|
||||
System.IO.Path.Combine(AppData, "ModrinthApp"),
|
||||
System.IO.Path.Combine(AppData, "com.modrinth.theseus"),
|
||||
}.FirstOrDefault(d => Directory.Exists(System.IO.Path.Combine(d, "profiles")));
|
||||
if (root is null) return null;
|
||||
|
||||
string[] markers = ["profile.json", "mods", "config", "resourcepacks", "shaderpacks", "saves", "options.txt"];
|
||||
int count = Directory.GetDirectories(System.IO.Path.Combine(root, "profiles")).Count(d => markers.Any(m => Exists(d, m)));
|
||||
return new FoundLauncher("mr", "Modrinth App", root, count, count == 1 ? "Instanz" : "Instanzen");
|
||||
}
|
||||
|
||||
// ---------- Prism Launcher / PolyMC (MultiMC-Format) ----------
|
||||
|
||||
private static FoundLauncher? DetectMultiMc(string name, string root)
|
||||
{
|
||||
var instances = System.IO.Path.Combine(root, "instances");
|
||||
if (!Directory.Exists(instances)) return null;
|
||||
int count = Directory.GetDirectories(instances).Count(d => File.Exists(System.IO.Path.Combine(d, "instance.cfg")));
|
||||
return new FoundLauncher("mmc", name, root, count, count == 1 ? "Instanz" : "Instanzen");
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> MultiMcExes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MultiMC.exe"] = "MultiMC",
|
||||
["prismlauncher.exe"] = "Prism Launcher",
|
||||
["polymc.exe"] = "PolyMC",
|
||||
};
|
||||
|
||||
/// <summary>Ordner von MultiMC-artigen Launchern, auf die eine Verknüpfung (Desktop/Startmenü) zeigt.</summary>
|
||||
private static IEnumerable<(string Name, string Root)> PortableMultiMcFromShortcuts()
|
||||
{
|
||||
var places = new[]
|
||||
{
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), SearchOption.TopDirectoryOnly),
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), SearchOption.TopDirectoryOnly),
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.Programs), SearchOption.AllDirectories),
|
||||
(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), SearchOption.AllDirectories),
|
||||
};
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (dir, option) in places)
|
||||
{
|
||||
string[] links;
|
||||
try { links = string.IsNullOrEmpty(dir) || !Directory.Exists(dir) ? [] : Directory.GetFiles(dir, "*.lnk", option); }
|
||||
catch { continue; }
|
||||
foreach (var link in links)
|
||||
{
|
||||
var target = Shortcut.Target(link);
|
||||
if (string.IsNullOrEmpty(target) || !MultiMcExes.TryGetValue(System.IO.Path.GetFileName(target), out var name)) continue;
|
||||
var root = System.IO.Path.GetDirectoryName(target);
|
||||
if (root is null || !Directory.Exists(System.IO.Path.Combine(root, "instances")) || !seen.Add(root)) continue;
|
||||
yield return (name, root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b) =>
|
||||
string.Equals(System.IO.Path.GetFullPath(a).TrimEnd('\\'), System.IO.Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// ---------- Technic ----------
|
||||
|
||||
private static FoundLauncher? DetectTechnic()
|
||||
{
|
||||
var modpacks = System.IO.Path.Combine(AppData, ".technic", "modpacks");
|
||||
if (!Directory.Exists(modpacks)) return null;
|
||||
string[] markers = ["bin", "mods", "config", "saves", "resourcepacks", "options.txt"];
|
||||
int count = Directory.GetDirectories(modpacks).Count(d => markers.Any(m => Exists(d, m)));
|
||||
return new FoundLauncher("technic", "Technic Launcher", modpacks, count, count == 1 ? "Modpack" : "Modpacks");
|
||||
}
|
||||
|
||||
private static bool Exists(string dir, string name)
|
||||
{
|
||||
var p = System.IO.Path.Combine(dir, name);
|
||||
return File.Exists(p) || Directory.Exists(p);
|
||||
}
|
||||
|
||||
// ---------- Übergabe an den Launcher ----------
|
||||
|
||||
/// <summary>Merkt die ausgewählten Launcher für den ersten Start von AeroMC vor (oder räumt eine alte Vormerkung weg).</summary>
|
||||
public static void WritePending(IReadOnlyCollection<FoundLauncher> selected)
|
||||
{
|
||||
if (selected.Count == 0)
|
||||
{
|
||||
try { if (File.Exists(PendingFile)) File.Delete(PendingFile); } catch { }
|
||||
return;
|
||||
}
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["sources"] = selected.Select(s => new Dictionary<string, string> { ["kind"] = s.Kind, ["path"] = s.Path }).ToList(),
|
||||
};
|
||||
Directory.CreateDirectory(Installer.DataDir);
|
||||
File.WriteAllText(PendingFile, new JavaScriptSerializer().Serialize(data), new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"projects": {
|
||||
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
|
||||
"version": "1.1.5",
|
||||
"version": "1.2.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")]
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
</configuration>
|
||||
@@ -0,0 +1,20 @@
|
||||
AeroMC-Setup
|
||||
0.0.0.0
|
||||
|
||||
winexe
|
||||
C#
|
||||
.cs
|
||||
C:\Users\Viper\Desktop\AeroMC-Launcher\installer\obj\Debug\net48\
|
||||
AeroMCSetup
|
||||
none
|
||||
false
|
||||
TRACE;DEBUG;NETFRAMEWORK;NET48;NET30_OR_GREATER;NET35_OR_GREATER;NET40_OR_GREATER;NET45_OR_GREATER;NET451_OR_GREATER;NET452_OR_GREATER;NET46_OR_GREATER;NET461_OR_GREATER;NET462_OR_GREATER;NET47_OR_GREATER;NET471_OR_GREATER;NET472_OR_GREATER;NET48_OR_GREATER
|
||||
C:\Users\Viper\Desktop\AeroMC-Launcher\installer\App.xaml
|
||||
41549299691
|
||||
|
||||
919373854
|
||||
251587687494
|
||||
MainWindow.xaml;Themes\Colors.Dark.xaml;Themes\Colors.Light.xaml;Themes\Controls.xaml;
|
||||
|
||||
False
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("M_Viper")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("0.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("0.0.0.0")]
|
||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
f169547649332c2f1c7da272087eb95fe510c6a52616eac752d180c8c6fe1d73
|
||||
@@ -0,0 +1,14 @@
|
||||
is_global = true
|
||||
build_property.ApplicationManifest = app.manifest
|
||||
build_property.StartupObject =
|
||||
build_property.ApplicationDefaultFont =
|
||||
build_property.ApplicationHighDpiMode =
|
||||
build_property.ApplicationUseCompatibleTextRendering =
|
||||
build_property.ApplicationVisualStyles =
|
||||
build_property.RootNamespace = AeroMCSetup
|
||||
build_property.ProjectDir = C:\Users\Viper\Desktop\AeroMC-Launcher\installer\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||
build_property.EffectiveAnalysisLevelStyle =
|
||||
build_property.EnableCodeStyleSeverity =
|
||||
@@ -0,0 +1,6 @@
|
||||
// <auto-generated/>
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "85CCB5F529C7F3695DF076BCDBCCA84D8F4ED0DE"
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Automation;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Controls.Ribbon;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Forms.Integration;
|
||||
using System.Windows.Ink;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Markup;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Media.Media3D;
|
||||
using System.Windows.Media.TextFormatting;
|
||||
using System.Windows.Navigation;
|
||||
using System.Windows.Shapes;
|
||||
using System.Windows.Shell;
|
||||
|
||||
|
||||
namespace AeroMCSetup {
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// App
|
||||
/// </summary>
|
||||
public partial class App : System.Windows.Application {
|
||||
|
||||
private bool _contentLoaded;
|
||||
|
||||
/// <summary>
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
}
|
||||
_contentLoaded = true;
|
||||
System.Uri resourceLocater = new System.Uri("/AeroMC-Setup;V0.0.0.0;component/app.xaml", System.UriKind.Relative);
|
||||
|
||||
#line 1 "..\..\..\App.xaml"
|
||||
System.Windows.Application.LoadComponent(this, resourceLocater);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Application Entry Point.
|
||||
/// </summary>
|
||||
[System.STAThreadAttribute()]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
public static void Main() {
|
||||
AeroMCSetup.App app = new AeroMCSetup.App();
|
||||
app.InitializeComponent();
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,842 @@
|
||||
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "8CB5D6EC1BE0A9A19A04D9A92B2B0961370843E0"
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Automation;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Controls.Ribbon;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Forms.Integration;
|
||||
using System.Windows.Ink;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Markup;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Media.Media3D;
|
||||
using System.Windows.Media.TextFormatting;
|
||||
using System.Windows.Navigation;
|
||||
using System.Windows.Shapes;
|
||||
using System.Windows.Shell;
|
||||
|
||||
|
||||
namespace AeroMCSetup {
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// MainWindow
|
||||
/// </summary>
|
||||
public partial class MainWindow : System.Windows.Window, System.Windows.Markup.IComponentConnector {
|
||||
|
||||
|
||||
#line 140 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock VersionText;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 142 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel Steps;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 144 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Dot0;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 144 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Num0;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 145 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Label0;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 147 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Line0;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 149 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Dot1;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 149 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Num1;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 150 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Label1;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 152 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Line1;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 154 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Dot2;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 154 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Num2;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 155 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Label2;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 157 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Line2;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 159 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel ImportStepRow;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 160 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Dot3;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 160 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Num3;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 161 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Label3;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 163 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Line3;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 165 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border Dot4;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 165 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Num4;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 166 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock Label4;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 179 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock WindowTitle;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 185 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Button CloseButton;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 193 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel PageWelcome;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 240 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border UpdateHint;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 245 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock UpdateHintText;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 248 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border RunningHint;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 260 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.DockPanel PageLicense;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 264 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.CheckBox AcceptLicense;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 271 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel LicensePanel;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 277 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel PageOptions;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 286 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBox DirBox;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 295 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.CheckBox DesktopLinkBox;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 301 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel PageImport;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 306 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel ImportList;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 313 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel PageProgress;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 314 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border SuccessIcon;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 316 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Media.ScaleTransform SuccessScale;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 321 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock ProgressTitle;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 323 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel ProgressBarPanel;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 324 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock PercentText;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 326 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border ProgressTrack;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 327 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border ProgressFill;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 330 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock ProgressText;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 332 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel DonePanel;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 333 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.CheckBox LaunchBox;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 338 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.StackPanel PageUninstall;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 349 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock DataInfo;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 350 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.CheckBox RemoveDataBox;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 352 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock RemoveDataWarning;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 362 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Button BackButton;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 363 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Button NextButton;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 364 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Button CancelButton;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 368 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Grid DialogOverlay;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 369 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Border DialogCard;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 372 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Media.ScaleTransform DialogScale;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 376 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock DialogIcon;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 379 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock DialogTitle;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 382 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.TextBlock DialogText;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 385 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Button DialogSecondary;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
|
||||
#line 386 "..\..\..\MainWindow.xaml"
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||
internal System.Windows.Controls.Button DialogPrimary;
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
private bool _contentLoaded;
|
||||
|
||||
/// <summary>
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
}
|
||||
_contentLoaded = true;
|
||||
System.Uri resourceLocater = new System.Uri("/AeroMC-Setup;V0.0.0.0;component/mainwindow.xaml", System.UriKind.Relative);
|
||||
|
||||
#line 1 "..\..\..\MainWindow.xaml"
|
||||
System.Windows.Application.LoadComponent(this, resourceLocater);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
}
|
||||
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
|
||||
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
|
||||
switch (connectionId)
|
||||
{
|
||||
case 1:
|
||||
this.VersionText = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 2:
|
||||
this.Steps = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 3:
|
||||
this.Dot0 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 4:
|
||||
this.Num0 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 5:
|
||||
this.Label0 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 6:
|
||||
this.Line0 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 7:
|
||||
this.Dot1 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 8:
|
||||
this.Num1 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 9:
|
||||
this.Label1 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 10:
|
||||
this.Line1 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 11:
|
||||
this.Dot2 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 12:
|
||||
this.Num2 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 13:
|
||||
this.Label2 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 14:
|
||||
this.Line2 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 15:
|
||||
this.ImportStepRow = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 16:
|
||||
this.Dot3 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 17:
|
||||
this.Num3 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 18:
|
||||
this.Label3 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 19:
|
||||
this.Line3 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 20:
|
||||
this.Dot4 = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 21:
|
||||
this.Num4 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 22:
|
||||
this.Label4 = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 23:
|
||||
this.WindowTitle = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 24:
|
||||
|
||||
#line 183 "..\..\..\MainWindow.xaml"
|
||||
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.Minimize_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 25:
|
||||
this.CloseButton = ((System.Windows.Controls.Button)(target));
|
||||
|
||||
#line 185 "..\..\..\MainWindow.xaml"
|
||||
this.CloseButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 26:
|
||||
this.PageWelcome = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 27:
|
||||
this.UpdateHint = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 28:
|
||||
this.UpdateHintText = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 29:
|
||||
this.RunningHint = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 30:
|
||||
this.PageLicense = ((System.Windows.Controls.DockPanel)(target));
|
||||
return;
|
||||
case 31:
|
||||
this.AcceptLicense = ((System.Windows.Controls.CheckBox)(target));
|
||||
|
||||
#line 265 "..\..\..\MainWindow.xaml"
|
||||
this.AcceptLicense.Checked += new System.Windows.RoutedEventHandler(this.AcceptLicense_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
#line 265 "..\..\..\MainWindow.xaml"
|
||||
this.AcceptLicense.Unchecked += new System.Windows.RoutedEventHandler(this.AcceptLicense_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 32:
|
||||
this.LicensePanel = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 33:
|
||||
this.PageOptions = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 34:
|
||||
|
||||
#line 285 "..\..\..\MainWindow.xaml"
|
||||
((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.ChangeDir_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 35:
|
||||
this.DirBox = ((System.Windows.Controls.TextBox)(target));
|
||||
return;
|
||||
case 36:
|
||||
this.DesktopLinkBox = ((System.Windows.Controls.CheckBox)(target));
|
||||
return;
|
||||
case 37:
|
||||
this.PageImport = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 38:
|
||||
this.ImportList = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 39:
|
||||
this.PageProgress = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 40:
|
||||
this.SuccessIcon = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 41:
|
||||
this.SuccessScale = ((System.Windows.Media.ScaleTransform)(target));
|
||||
return;
|
||||
case 42:
|
||||
this.ProgressTitle = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 43:
|
||||
this.ProgressBarPanel = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 44:
|
||||
this.PercentText = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 45:
|
||||
this.ProgressTrack = ((System.Windows.Controls.Border)(target));
|
||||
|
||||
#line 326 "..\..\..\MainWindow.xaml"
|
||||
this.ProgressTrack.SizeChanged += new System.Windows.SizeChangedEventHandler(this.ProgressTrack_SizeChanged);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 46:
|
||||
this.ProgressFill = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 47:
|
||||
this.ProgressText = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 48:
|
||||
this.DonePanel = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 49:
|
||||
this.LaunchBox = ((System.Windows.Controls.CheckBox)(target));
|
||||
return;
|
||||
case 50:
|
||||
this.PageUninstall = ((System.Windows.Controls.StackPanel)(target));
|
||||
return;
|
||||
case 51:
|
||||
this.DataInfo = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 52:
|
||||
this.RemoveDataBox = ((System.Windows.Controls.CheckBox)(target));
|
||||
|
||||
#line 351 "..\..\..\MainWindow.xaml"
|
||||
this.RemoveDataBox.Checked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
|
||||
#line 351 "..\..\..\MainWindow.xaml"
|
||||
this.RemoveDataBox.Unchecked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 53:
|
||||
this.RemoveDataWarning = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 54:
|
||||
this.BackButton = ((System.Windows.Controls.Button)(target));
|
||||
|
||||
#line 362 "..\..\..\MainWindow.xaml"
|
||||
this.BackButton.Click += new System.Windows.RoutedEventHandler(this.Back_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 55:
|
||||
this.NextButton = ((System.Windows.Controls.Button)(target));
|
||||
|
||||
#line 363 "..\..\..\MainWindow.xaml"
|
||||
this.NextButton.Click += new System.Windows.RoutedEventHandler(this.Next_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 56:
|
||||
this.CancelButton = ((System.Windows.Controls.Button)(target));
|
||||
|
||||
#line 364 "..\..\..\MainWindow.xaml"
|
||||
this.CancelButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 57:
|
||||
this.DialogOverlay = ((System.Windows.Controls.Grid)(target));
|
||||
return;
|
||||
case 58:
|
||||
this.DialogCard = ((System.Windows.Controls.Border)(target));
|
||||
return;
|
||||
case 59:
|
||||
this.DialogScale = ((System.Windows.Media.ScaleTransform)(target));
|
||||
return;
|
||||
case 60:
|
||||
this.DialogIcon = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 61:
|
||||
this.DialogTitle = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 62:
|
||||
this.DialogText = ((System.Windows.Controls.TextBlock)(target));
|
||||
return;
|
||||
case 63:
|
||||
this.DialogSecondary = ((System.Windows.Controls.Button)(target));
|
||||
|
||||
#line 385 "..\..\..\MainWindow.xaml"
|
||||
this.DialogSecondary.Click += new System.Windows.RoutedEventHandler(this.DialogSecondary_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
case 64:
|
||||
this.DialogPrimary = ((System.Windows.Controls.Button)(target));
|
||||
|
||||
#line 386 "..\..\..\MainWindow.xaml"
|
||||
this.DialogPrimary.Click += new System.Windows.RoutedEventHandler(this.DialogPrimary_Click);
|
||||
|
||||
#line default
|
||||
#line hidden
|
||||
return;
|
||||
}
|
||||
this._contentLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.5.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.5")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.0")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.1.5.0")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.0.0")]
|
||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -1 +1 @@
|
||||
6fa3eb63bf9e7c9a77cca67d608dff49330e3a93e70e18548b24192d4ef82422
|
||||
e1b134ff0a2fb2387ee195d0721c9af9546a7821126b2fd5bae3f90099e4d686
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
"C:\\Users\\Viper\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.1.5",
|
||||
"version": "1.2.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "za8GIOA75zI=",
|
||||
"dgSpecHash": "IwFtzT9tvgw=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"expectedPackageFiles": [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# AeroMC Capes
|
||||
|
||||
Shows the **custom capes (including HD up to 1024×512)** of players who designed their cape in the
|
||||
[AeroMC Launcher](https://aeromc.viper.ipv64.net) – right in the game, even if you use a different
|
||||
launcher yourself.
|
||||
|
||||
## How it works
|
||||
- Drop the mod into your `mods` folder – done. No configuration, no Fabric API required.
|
||||
- Whenever Minecraft loads a player's skin, the mod asks the AeroMC server whether that player has
|
||||
an AeroMC cape and shows it instead of the regular cape.
|
||||
- If a player has no AeroMC cape, nothing changes (official Mojang cape or none).
|
||||
- **Client-side only** – servers don't need anything.
|
||||
|
||||
## Compatibility
|
||||
- **Fabric** and **Quilt**
|
||||
- **One jar for all versions**: Minecraft 1.14 to 26.x
|
||||
- Works with old Fabric Loader versions too (0.10 and newer)
|
||||
- Players using the AeroMC Launcher don't need this mod (the launcher already does this on its
|
||||
own) – but having it installed doesn't hurt either.
|
||||
|
||||
## Privacy
|
||||
The mod only sends the (public) UUIDs of players whose skins are currently being loaded to
|
||||
`viper-network.de` to look up their capes. Results are cached for a few minutes.
|
||||
No account or login data is ever transmitted.
|
||||
|
||||
## Create your own cape
|
||||
In the AeroMC Launcher: click your account name (top right) → **Skin ändern…** (Change skin) →
|
||||
in the **Umhang** (Cape) section click **Gestalten…** (Design). You can paint your cape pixel by
|
||||
pixel or insert any image, in sizes from 64×32 up to HD 1024×512.
|
||||
|
||||
---
|
||||
|
||||
### Hinweise zum Hochladen (nicht Teil der Beschreibung)
|
||||
- Name: AeroMC Capes (URL bleibt `aeromc-umhaenge`)
|
||||
- Kurzbeschreibung: Shows custom capes (HD up to 1024×512) made in the AeroMC Launcher – even if you use another launcher. One jar for 1.14–26.x, client-side.
|
||||
- Datei: `dist/aeromc-umhaenge-fabric-1.0.0.jar`
|
||||
- Loader: Fabric, Quilt · Umgebung: Client · Server: nicht nötig
|
||||
- Spielversionen: alle Releases von 1.14 bis zur aktuellen
|
||||
- Kategorien: Sozial (hervorgehoben), Werkzeug („Cosmetic“ gibt es für Mods nicht)
|
||||
- Abhängigkeiten: keine
|
||||
- Lizenz: aktuell „All Rights Reserved“ (in `fabric.mod.json`) – Modrinth akzeptiert das, eine
|
||||
offene Lizenz (z. B. MIT) wirkt aber vertrauenswürdiger.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"required": false,
|
||||
"minVersion": "0.8",
|
||||
"package": "de.aeromc.capes.mixin",
|
||||
"compatibilityLevel": "JAVA_8",
|
||||
"client": ["SessionServiceMixin", "SkinLaderMixin"],
|
||||
"injectors": {
|
||||
"defaultRequire": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "aeromc-capes",
|
||||
"version": "1.0.0",
|
||||
"name": "AeroMC Umhänge",
|
||||
"description": "Zeigt die eigenen Umhänge (auch HD) von AeroMC-Spielern im Spiel – gestaltet im AeroMC Launcher. Nur auf dem Client nötig. Funktioniert mit jeder Minecraft-Version, die Fabric unterstützt.",
|
||||
"authors": ["M_Viper"],
|
||||
"contact": {
|
||||
"homepage": "https://aeromc.viper.ipv64.net"
|
||||
},
|
||||
"license": "LicenseRef-All-Rights-Reserved",
|
||||
"icon": "assets/aeromc-capes/icon.png",
|
||||
"environment": "client",
|
||||
"mixins": ["aeromc-capes.mixins.json"],
|
||||
"depends": {
|
||||
"fabricloader": ">=0.10.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.aeromc.capes.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import com.mojang.authlib.properties.Property;
|
||||
import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService;
|
||||
import de.aeromc.capes.mod.Umhaenge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/**
|
||||
* Hängt sich an die Textur-Methoden von authlib (beide Fassungen, siehe
|
||||
* agent/…/Umbau.java). require = 0: Es greift immer nur die Methode, die es in
|
||||
* der jeweiligen authlib-Fassung gibt, die andere wird still übersprungen.
|
||||
* remap = false: authlib ist nicht verschleiert, Namen bleiben wie sie sind.
|
||||
*/
|
||||
@Mixin(value = YggdrasilMinecraftSessionService.class, remap = false)
|
||||
public abstract class SessionServiceMixin {
|
||||
|
||||
// wird in den statischen Initialisierer von authlib übernommen: zeigt im Spielprotokoll, dass der Mod greift
|
||||
static {
|
||||
Umhaenge.authlibGreift = true;
|
||||
Umhaenge.meldeAktiv("authlib");
|
||||
}
|
||||
|
||||
// authlib 5+ (Minecraft 1.20.2 … 26.x)
|
||||
@Inject(method = "unpackTextures", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
|
||||
private void aeromc$neu(Property eigenschaft, CallbackInfoReturnable<Object> cir) {
|
||||
aeromc$ersetze(cir, eigenschaft);
|
||||
}
|
||||
|
||||
// authlib 1–4 (Minecraft 1.14 … 1.20.1)
|
||||
@Inject(method = "getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
|
||||
private void aeromc$alt(GameProfile profil, boolean sicher, CallbackInfoReturnable<Object> cir) {
|
||||
aeromc$ersetze(cir, profil);
|
||||
}
|
||||
|
||||
private static void aeromc$ersetze(CallbackInfoReturnable<Object> cir, Object argument) {
|
||||
try {
|
||||
Object alt = cir.getReturnValue();
|
||||
Object neu = Umhaenge.mitUmhang(alt, argument);
|
||||
if (neu != alt) cir.setReturnValue(neu);
|
||||
} catch (Throwable ignoriert) {
|
||||
// im Zweifel bleibt alles wie ohne Mod
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.aeromc.capes.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import com.mojang.authlib.minecraft.MinecraftSessionService;
|
||||
import de.aeromc.capes.mod.Umhaenge;
|
||||
import net.minecraft.class_1071;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Zweiter Weg für alte Fabric Loader (vor 0.12): Die lassen Mixins nur in
|
||||
* Minecraft selbst zu, nicht in authlib – SessionServiceMixin greift dort also
|
||||
* nie. Deshalb wird hier der Aufruf von getTextures im Skin-Lader von Minecraft
|
||||
* (PlayerSkinProvider = class_1071, Intermediary-Name, gleich in 1.14 … 1.20.1)
|
||||
* umgeleitet. Bei neuen Loadern erledigt das bereits SessionServiceMixin, dann
|
||||
* reicht diese Umleitung das Ergebnis nur unverändert durch.
|
||||
*/
|
||||
@Mixin(value = class_1071.class, targets = "net.minecraft.class_1071$1", remap = false)
|
||||
public abstract class SkinLaderMixin {
|
||||
|
||||
@Redirect(method = "*", at = @At(value = "INVOKE", remap = false,
|
||||
target = "Lcom/mojang/authlib/minecraft/MinecraftSessionService;getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;"),
|
||||
remap = false, require = 0)
|
||||
private Map aeromc$texturen(MinecraftSessionService dienst, GameProfile profil, boolean sicher) {
|
||||
Map texturen = dienst.getTextures(profil, sicher);
|
||||
if (Umhaenge.authlibGreift) return texturen;
|
||||
Umhaenge.meldeAktiv("Skin-Lader");
|
||||
try {
|
||||
Object neu = Umhaenge.mitUmhang(texturen, profil);
|
||||
return neu instanceof Map ? (Map) neu : texturen;
|
||||
} catch (Throwable ignoriert) {
|
||||
return texturen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.aeromc.capes.mod;
|
||||
|
||||
import de.aeromc.capes.Nachschlagen;
|
||||
|
||||
/**
|
||||
* AeroMC Umhänge – Fabric/Quilt-Mod für Spieler, die NICHT den AeroMC Launcher
|
||||
* nutzen. Er macht dasselbe wie der Umhang-Agent des Launchers: In Mojangs
|
||||
* authlib wird der AeroMC-Umhang eines Spielers eingesetzt, sobald Minecraft
|
||||
* dessen Skin lädt. Da nur authlib angepasst wird (nicht Minecraft selbst),
|
||||
* passt ein und derselbe Mod zu jeder Minecraft-Version, die Fabric kennt.
|
||||
* Alte Fabric Loader (vor 0.12) erlauben keine Mixins in authlib, dort greift
|
||||
* stattdessen SkinLaderMixin im Skin-Lader von Minecraft.
|
||||
*/
|
||||
public final class Umhaenge {
|
||||
private static final Nachschlagen DIENST = new Nachschlagen(Nachschlagen.STANDARD_BASIS);
|
||||
|
||||
/** true, sobald SessionServiceMixin in authlib eingebaut wurde (dann ist SkinLaderMixin überflüssig). */
|
||||
public static volatile boolean authlibGreift;
|
||||
private static volatile boolean gemeldet;
|
||||
|
||||
private Umhaenge() {}
|
||||
|
||||
/** Schreibt einmalig ins Spielprotokoll, dass (und über welchen Weg) der Mod greift. */
|
||||
public static void meldeAktiv(String weg) {
|
||||
if (gemeldet || agentAktiv()) return;
|
||||
gemeldet = true;
|
||||
System.out.println("[AeroMC] Umhang-Mod aktiv (" + weg + ")");
|
||||
}
|
||||
|
||||
/** Läuft das Spiel über den AeroMC Launcher, setzt schon dessen Agent die Umhänge ein. */
|
||||
public static boolean agentAktiv() {
|
||||
return System.getProperties().containsKey("aeromc.capes.hook");
|
||||
}
|
||||
|
||||
public static Object mitUmhang(Object ergebnis, Object argument) {
|
||||
if (agentAktiv()) return ergebnis;
|
||||
return DIENST.apply(ergebnis, argument);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package net.minecraft;
|
||||
|
||||
/** Nur zum Kompilieren: Platzhalter für Minecrafts PlayerSkinProvider (Intermediary-Name). Kommt nicht ins Mod-Jar. */
|
||||
public class class_1071 {}
|
||||
@@ -1,53 +1,54 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.1.5",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/write-build-config.js",
|
||||
"prebuild:portable": "node scripts/write-build-config.js",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.aeromc.launcher",
|
||||
"productName": "AeroMC",
|
||||
"artifactName": "AeroMC.${ext}",
|
||||
"compression": "store",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/loopback-capture/**/*",
|
||||
"node_modules/node-window-manager/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "dir",
|
||||
"icon": "src/assets/icon.ico"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"archiver": "^7.0.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"loopback-capture": "^3.0.2",
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"undici": "^6.28.0",
|
||||
"unzipper": "^0.12.5"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.2.0",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/write-build-config.js",
|
||||
"prebuild:portable": "node scripts/write-build-config.js",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.aeromc.launcher",
|
||||
"productName": "AeroMC",
|
||||
"artifactName": "AeroMC.${ext}",
|
||||
"compression": "store",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/loopback-capture/**/*",
|
||||
"node_modules/node-window-manager/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "dir",
|
||||
"icon": "src/assets/icon.ico"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"archiver": "^7.0.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"loopback-capture": "^3.0.2",
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"sql.js": "^1.14.2",
|
||||
"undici": "^6.28.0",
|
||||
"unzipper": "^0.12.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent)
|
||||
* ------------------------------------------------------------------------------
|
||||
* Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen
|
||||
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
|
||||
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
|
||||
* Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab).
|
||||
* Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version.
|
||||
*
|
||||
* Aufruf: node scripts/build-agent.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const WURZEL = path.join(__dirname, '..');
|
||||
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
|
||||
|
||||
function findeJdk() {
|
||||
const kandidaten = [];
|
||||
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
|
||||
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
|
||||
if (fs.existsSync(adoptium)) {
|
||||
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
|
||||
}
|
||||
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
|
||||
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
|
||||
return jdk;
|
||||
}
|
||||
|
||||
function findeAsm(name) {
|
||||
const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name);
|
||||
if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`);
|
||||
const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v))
|
||||
.sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0));
|
||||
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
|
||||
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
|
||||
return datei;
|
||||
}
|
||||
|
||||
function quellen(ordner) {
|
||||
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(ordner, e.name);
|
||||
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
|
||||
});
|
||||
}
|
||||
|
||||
const jdk = findeJdk();
|
||||
const javac = path.join(jdk, 'bin', 'javac');
|
||||
const java = path.join(jdk, 'bin', 'java');
|
||||
const asm = findeAsm('asm');
|
||||
const asmCommons = findeAsm('asm-commons');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
|
||||
const klassen = path.join(tmp, 'klassen');
|
||||
const werkzeug = path.join(tmp, 'werkzeug');
|
||||
console.log('JDK:', jdk);
|
||||
console.log('ASM:', asm);
|
||||
try {
|
||||
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen,
|
||||
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
|
||||
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
|
||||
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
|
||||
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
|
||||
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' });
|
||||
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* build-mod.js – baut den Fabric/Quilt-Mod "AeroMC Umhänge" (dist/aeromc-umhaenge-fabric-<version>.jar)
|
||||
* -------------------------------------------------------------------------------------------------
|
||||
* Für Spieler, die NICHT den AeroMC Launcher nutzen. Der Mod greift wie der
|
||||
* Umhang-Agent nur in Mojangs authlib ein (Mixin, ohne Namensumsetzung) – darum
|
||||
* braucht es weder Gradle noch Fabric Loom, und eine Datei passt zu allen
|
||||
* Minecraft-Versionen, die Fabric unterstützt. Übersetzt wird für Java 8.
|
||||
*
|
||||
* Gebraucht werden (nur beim Bauen) ein JDK sowie sponge-mixin und authlib aus
|
||||
* dem Bibliotheksordner von AeroMC. Der Nachschlage-Code ist derselbe wie im
|
||||
* Agenten (agent/src/de/aeromc/capes/Nachschlagen.java).
|
||||
*
|
||||
* Aufruf: node scripts/build-mod.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const WURZEL = path.join(__dirname, '..');
|
||||
const LIB = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries');
|
||||
const modInfo = JSON.parse(fs.readFileSync(path.join(WURZEL, 'mod', 'resources', 'fabric.mod.json'), 'utf8'));
|
||||
const ZIEL = path.join(WURZEL, 'dist', `aeromc-umhaenge-fabric-${modInfo.version}.jar`);
|
||||
|
||||
function findeJdk() {
|
||||
const kandidaten = [];
|
||||
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
|
||||
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
|
||||
if (fs.existsSync(adoptium)) {
|
||||
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
|
||||
}
|
||||
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
|
||||
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
|
||||
return jdk;
|
||||
}
|
||||
|
||||
// neueste (oder mit aelteste=true älteste) Fassung einer Bibliothek im AeroMC-Bibliotheksordner
|
||||
function bibliothek(gruppe, name, aelteste = false) {
|
||||
const basis = path.join(LIB, ...gruppe.split('.'), name);
|
||||
if (!fs.existsSync(basis)) throw new Error(`${name} nicht gefunden unter ${basis}`);
|
||||
const fassungen = fs.readdirSync(basis).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const v = aelteste ? fassungen[0] : fassungen.pop();
|
||||
const datei = path.join(basis, v, `${name}-${v}.jar`);
|
||||
if (!fs.existsSync(datei)) throw new Error('Datei fehlt: ' + datei);
|
||||
return datei;
|
||||
}
|
||||
|
||||
function quellen(ordner) {
|
||||
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(ordner, e.name);
|
||||
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
|
||||
});
|
||||
}
|
||||
|
||||
// authlib in der ältesten Fassung: SkinLaderMixin ruft getTextures(GameProfile, boolean) auf, das es nur bis authlib 4 gibt
|
||||
const jdk = findeJdk();
|
||||
const klassenpfad = [bibliothek('net.fabricmc', 'sponge-mixin'), bibliothek('com.mojang', 'authlib', true)].join(path.delimiter);
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-mod-'));
|
||||
const inhalt = path.join(tmp, 'inhalt');
|
||||
const stubs = path.join(tmp, 'stubs');
|
||||
console.log('JDK:', jdk);
|
||||
try {
|
||||
// Platzhalter für Minecraft-Klassen (nur zum Kompilieren, landen nicht im Jar)
|
||||
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-d', stubs,
|
||||
...quellen(path.join(WURZEL, 'mod', 'stubs'))], { stdio: 'inherit' });
|
||||
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-proc:none', '-cp', klassenpfad + path.delimiter + stubs, '-d', inhalt,
|
||||
...quellen(path.join(WURZEL, 'mod', 'src')),
|
||||
path.join(WURZEL, 'agent', 'src', 'de', 'aeromc', 'capes', 'Nachschlagen.java')], { stdio: 'inherit' });
|
||||
for (const datei of fs.readdirSync(path.join(WURZEL, 'mod', 'resources'))) {
|
||||
fs.copyFileSync(path.join(WURZEL, 'mod', 'resources', datei), path.join(inhalt, datei));
|
||||
}
|
||||
fs.mkdirSync(path.join(inhalt, 'assets', 'aeromc-capes'), { recursive: true });
|
||||
fs.copyFileSync(path.join(WURZEL, 'src', 'assets', 'logo.png'), path.join(inhalt, 'assets', 'aeromc-capes', 'icon.png'));
|
||||
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
|
||||
if (fs.existsSync(ZIEL)) fs.rmSync(ZIEL);
|
||||
execFileSync(path.join(jdk, 'bin', 'jar'), ['--create', '--file', ZIEL, '-C', inhalt, '.'], { stdio: 'inherit' });
|
||||
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
make-mc-textures.py – erzeugt die 16×16-Kacheln für das Minecraft-Design
|
||||
(src/assets/design/*.png). Eigene, zufällig erzeugte Muster im Block-Stil –
|
||||
keine Original-Texturen aus dem Spiel.
|
||||
|
||||
Aufruf: python scripts/make-mc-textures.py
|
||||
"""
|
||||
import os
|
||||
import random
|
||||
from PIL import Image
|
||||
|
||||
ZIEL = os.path.join(os.path.dirname(__file__), '..', 'src', 'assets', 'design')
|
||||
os.makedirs(ZIEL, exist_ok=True)
|
||||
|
||||
|
||||
def klemme(v):
|
||||
return max(0, min(255, int(v)))
|
||||
|
||||
|
||||
def rauschen(basis, streuung, seed, flecken=None):
|
||||
"""Grundfarbe mit Pixelrauschen; flecken = (anteil, farbe, streuung)."""
|
||||
rnd = random.Random(seed)
|
||||
img = Image.new('RGB', (16, 16))
|
||||
px = img.load()
|
||||
for y in range(16):
|
||||
for x in range(16):
|
||||
d = rnd.randint(-streuung, streuung)
|
||||
farbe = basis
|
||||
if flecken and rnd.random() < flecken[0]:
|
||||
farbe = flecken[1]
|
||||
d = rnd.randint(-flecken[2], flecken[2])
|
||||
px[x, y] = tuple(klemme(c + d) for c in farbe)
|
||||
return img
|
||||
|
||||
|
||||
def speichere(img, name):
|
||||
img.save(os.path.join(ZIEL, name))
|
||||
print(name)
|
||||
|
||||
|
||||
# Hintergrund der Instanzliste: dunkle Erde (wie hinter Menüs im Spiel)
|
||||
speichere(rauschen((44, 32, 23), 6, 11, (0.16, (31, 22, 15), 4)), 'erde-dunkel.png')
|
||||
|
||||
# Leisten (Werkzeugleiste, Seitenleiste, Statusleiste): dunkler Schiefer
|
||||
schiefer = rauschen((46, 46, 50), 6, 23, (0.12, (34, 34, 38), 3))
|
||||
px = schiefer.load()
|
||||
rnd = random.Random(5)
|
||||
for _ in range(5): # ein paar waagerechte Bruchkanten wie bei Tiefenschiefer
|
||||
y = rnd.randrange(16)
|
||||
x0 = rnd.randrange(16)
|
||||
for x in range(x0, min(16, x0 + rnd.randint(3, 7))):
|
||||
px[x, y] = (30, 30, 33)
|
||||
speichere(schiefer, 'schiefer.png')
|
||||
|
||||
# Dialoge: Stein
|
||||
stein = rauschen((78, 78, 80), 8, 37, (0.10, (60, 60, 62), 4))
|
||||
speichere(stein, 'stein.png')
|
||||
|
||||
# Holzbretter (z. B. Kopf der Dialoge): vier Bretter zu je 4 Pixeln
|
||||
bretter = Image.new('RGB', (16, 16))
|
||||
px = bretter.load()
|
||||
rnd = random.Random(41)
|
||||
farben = [(122, 92, 56), (114, 86, 52), (128, 97, 60), (118, 89, 54)]
|
||||
fugen = [3, 11, 7, 14]
|
||||
for brett in range(4):
|
||||
for y in range(brett * 4, brett * 4 + 4):
|
||||
for x in range(16):
|
||||
c = farben[brett]
|
||||
d = rnd.randint(-5, 5)
|
||||
if y == brett * 4 + 3: # dunkle Fuge unter jedem Brett
|
||||
c, d = (74, 55, 33), rnd.randint(-2, 2)
|
||||
elif x == fugen[brett]: # Stoßfuge
|
||||
c, d = (88, 66, 40), 0
|
||||
px[x, y] = tuple(klemme(v + d) for v in c)
|
||||
speichere(bretter, 'bretter.png')
|
||||
|
||||
# Grasnarbe: oberer Rand der Werkzeugleiste (16×4, grün mit "Tropfen")
|
||||
gras = Image.new('RGBA', (16, 4), (0, 0, 0, 0))
|
||||
px = gras.load()
|
||||
rnd = random.Random(53)
|
||||
tiefe = [2, 3, 2, 2, 4, 3, 2, 3, 2, 2, 3, 4, 2, 3, 2, 3]
|
||||
for x in range(16):
|
||||
for y in range(tiefe[x]):
|
||||
g = rnd.randint(-10, 10)
|
||||
px[x, y] = (klemme(84 + g), klemme(140 + g), klemme(52 + g // 2), 255)
|
||||
speichere(gras, 'gras.png')
|
||||
@@ -0,0 +1,270 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* make-pixelfont.js – erzeugt src/assets/aeropixel.ttf
|
||||
* -----------------------------------------------------
|
||||
* Eigene Pixel-Schrift für das Minecraft-Design (keine fremde Schrift, also
|
||||
* keine Lizenzfragen). Jede Glyphe ist ein Raster aus '#' (Pixel) und ' ':
|
||||
* Zeile 0 ist die Oberkante der Großbuchstaben, Zeile 6 sitzt auf der
|
||||
* Grundlinie, Zeilen 7–8 sind Unterlängen (g, j, p, q, y, Komma).
|
||||
* Fehlende Zeichen fallen im Launcher automatisch auf Segoe UI zurück.
|
||||
*
|
||||
* Aufruf: node scripts/make-pixelfont.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const P = 125; // ein Pixel in Font-Einheiten (8 Pixel = 1 em)
|
||||
const EM = 1000;
|
||||
const ASC = 1000; // 7 Zeilen Großbuchstaben + 1 Zeile Luft
|
||||
const DESC = 250; // 2 Zeilen Unterlänge
|
||||
|
||||
const G = {
|
||||
A: [' ### ', '# #', '# #', '#####', '# #', '# #', '# #'],
|
||||
B: ['#### ', '# #', '# #', '#### ', '# #', '# #', '#### '],
|
||||
C: [' ### ', '# #', '# ', '# ', '# ', '# #', ' ### '],
|
||||
D: ['#### ', '# #', '# #', '# #', '# #', '# #', '#### '],
|
||||
E: ['#####', '# ', '# ', '#### ', '# ', '# ', '#####'],
|
||||
F: ['#####', '# ', '# ', '#### ', '# ', '# ', '# '],
|
||||
G: [' ####', '# ', '# ', '# ##', '# #', '# #', ' ####'],
|
||||
H: ['# #', '# #', '# #', '#####', '# #', '# #', '# #'],
|
||||
I: ['###', ' # ', ' # ', ' # ', ' # ', ' # ', '###'],
|
||||
J: [' #', ' #', ' #', ' #', ' #', '# #', ' ### '],
|
||||
K: ['# #', '# # ', '# # ', '## ', '# # ', '# # ', '# #'],
|
||||
L: ['# ', '# ', '# ', '# ', '# ', '# ', '#####'],
|
||||
M: ['# #', '## ##', '# # #', '# #', '# #', '# #', '# #'],
|
||||
N: ['# #', '## #', '# # #', '# ##', '# #', '# #', '# #'],
|
||||
O: [' ### ', '# #', '# #', '# #', '# #', '# #', ' ### '],
|
||||
P: ['#### ', '# #', '# #', '#### ', '# ', '# ', '# '],
|
||||
Q: [' ### ', '# #', '# #', '# #', '# #', '# # ', ' ## #'],
|
||||
R: ['#### ', '# #', '# #', '#### ', '# # ', '# #', '# #'],
|
||||
S: [' ####', '# ', '# ', ' ### ', ' #', ' #', '#### '],
|
||||
T: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
|
||||
U: ['# #', '# #', '# #', '# #', '# #', '# #', ' ### '],
|
||||
V: ['# #', '# #', '# #', '# #', '# #', ' # # ', ' # '],
|
||||
W: ['# #', '# #', '# #', '# #', '# # #', '## ##', '# #'],
|
||||
X: ['# #', '# #', ' # # ', ' # ', ' # # ', '# #', '# #'],
|
||||
Y: ['# #', '# #', ' # # ', ' # ', ' # ', ' # ', ' # '],
|
||||
Z: ['#####', ' #', ' # ', ' # ', ' # ', '# ', '#####'],
|
||||
|
||||
a: [' ', ' ', ' ### ', ' #', ' ####', '# #', ' ####'],
|
||||
b: ['# ', '# ', '# ## ', '## #', '# #', '# #', '#### '],
|
||||
c: [' ', ' ', ' ### ', '# #', '# ', '# #', ' ### '],
|
||||
d: [' #', ' #', ' ## #', '# ##', '# #', '# #', ' ####'],
|
||||
e: [' ', ' ', ' ### ', '# #', '#####', '# ', ' ####'],
|
||||
f: [' ##', ' # ', '####', ' # ', ' # ', ' # ', ' # '],
|
||||
g: [' ', ' ', ' ####', '# #', '# #', '# #', ' ####', ' #', ' ### '],
|
||||
h: ['# ', '# ', '# ## ', '## #', '# #', '# #', '# #'],
|
||||
i: ['#', ' ', '#', '#', '#', '#', '#'],
|
||||
j: [' #', ' ', ' #', ' #', ' #', ' #', ' #', '# #', ' ## '],
|
||||
k: ['# ', '# ', '# #', '# # ', '## ', '# # ', '# #'],
|
||||
l: ['# ', '# ', '# ', '# ', '# ', '# ', ' #'],
|
||||
m: [' ', ' ', '## # ', '# # #', '# # #', '# #', '# #'],
|
||||
n: [' ', ' ', '#### ', '# #', '# #', '# #', '# #'],
|
||||
o: [' ', ' ', ' ### ', '# #', '# #', '# #', ' ### '],
|
||||
p: [' ', ' ', '# ## ', '## #', '# #', '# #', '#### ', '# ', '# '],
|
||||
q: [' ', ' ', ' ## #', '# ##', '# #', '# #', ' ####', ' #', ' #'],
|
||||
r: [' ', ' ', '# ## ', '## #', '# ', '# ', '# '],
|
||||
s: [' ', ' ', ' ####', '# ', ' ### ', ' #', '#### '],
|
||||
t: [' # ', ' # ', '###', ' # ', ' # ', ' # ', ' #'],
|
||||
u: [' ', ' ', '# #', '# #', '# #', '# #', ' ####'],
|
||||
v: [' ', ' ', '# #', '# #', '# #', ' # # ', ' # '],
|
||||
w: [' ', ' ', '# #', '# #', '# # #', '# # #', ' ####'],
|
||||
x: [' ', ' ', '# #', ' # # ', ' # ', ' # # ', '# #'],
|
||||
y: [' ', ' ', '# #', '# #', '# #', '# #', ' ####', ' #', '#### '],
|
||||
z: [' ', ' ', '#####', ' # ', ' # ', ' # ', '#####'],
|
||||
|
||||
0: [' ### ', '# #', '# ##', '# # #', '## #', '# #', ' ### '],
|
||||
1: [' # ', ' ## ', ' # ', ' # ', ' # ', ' # ', '#####'],
|
||||
2: [' ### ', '# #', ' #', ' ## ', ' # ', '# ', '#####'],
|
||||
3: [' ### ', '# #', ' #', ' ## ', ' #', '# #', ' ### '],
|
||||
4: [' ##', ' # #', ' # #', '# #', '#####', ' #', ' #'],
|
||||
5: ['#####', '# ', '#### ', ' #', ' #', '# #', ' ### '],
|
||||
6: [' ## ', ' # ', '# ', '#### ', '# #', '# #', ' ### '],
|
||||
7: ['#####', '# #', ' #', ' # ', ' # ', ' # ', ' # '],
|
||||
8: [' ### ', '# #', '# #', ' ### ', '# #', '# #', ' ### '],
|
||||
9: [' ### ', '# #', '# #', ' ####', ' #', ' # ', ' ## '],
|
||||
|
||||
'Ä': ['# #', ' ### ', '# #', '# #', '#####', '# #', '# #'],
|
||||
'Ö': ['# #', ' ### ', '# #', '# #', '# #', '# #', ' ### '],
|
||||
'Ü': ['# #', ' ', '# #', '# #', '# #', '# #', ' ### '],
|
||||
'ä': [' # # ', ' ', ' ### ', ' #', ' ####', '# #', ' ####'],
|
||||
'ö': [' # # ', ' ', ' ### ', '# #', '# #', '# #', ' ### '],
|
||||
'ü': [' # # ', ' ', '# #', '# #', '# #', '# #', ' ####'],
|
||||
'ß': [' ### ', '# #', '# # ', '# # ', '# # ', '# #', '# ## '],
|
||||
'é': [' # ', ' # ', ' ### ', '# #', '#####', '# ', ' ####'],
|
||||
|
||||
'.': [' ', ' ', ' ', ' ', ' ', ' ', '#'],
|
||||
',': [' ', ' ', ' ', ' ', ' ', ' ', '#', '#'],
|
||||
':': [' ', ' ', '#', ' ', ' ', ' ', '#'],
|
||||
';': [' ', ' ', '#', ' ', ' ', ' ', '#', '#'],
|
||||
'!': ['#', '#', '#', '#', '#', ' ', '#'],
|
||||
'?': [' ### ', '# #', ' #', ' # ', ' # ', ' ', ' # '],
|
||||
'-': [' ', ' ', ' ', '####', ' ', ' ', ' '],
|
||||
'–': [' ', ' ', ' ', '#####', ' ', ' ', ' '],
|
||||
'_': [' ', ' ', ' ', ' ', ' ', ' ', ' ', '#####'],
|
||||
'(': [' #', ' # ', '# ', '# ', '# ', ' # ', ' #'],
|
||||
')': ['# ', ' # ', ' #', ' #', ' #', ' # ', '# '],
|
||||
'[': ['##', '# ', '# ', '# ', '# ', '# ', '##'],
|
||||
']': ['##', ' #', ' #', ' #', ' #', ' #', '##'],
|
||||
'/': [' #', ' # ', ' # ', ' # ', ' # ', ' # ', '# '],
|
||||
'\\': ['# ', ' # ', ' # ', ' # ', ' # ', ' # ', ' #'],
|
||||
'+': [' ', ' # ', ' # ', '#####', ' # ', ' # ', ' '],
|
||||
'=': [' ', ' ', '#####', ' ', '#####', ' ', ' '],
|
||||
'*': [' ', ' ', '# # #', ' ### ', '# # #', ' ', ' '],
|
||||
'%': ['# #', ' #', ' # ', ' # ', ' # ', '# ', '# #'],
|
||||
'&': [' ## ', '# # ', ' ## ', ' ## #', '# # ', '# # ', ' ## #'],
|
||||
'#': [' # # ', '#####', ' # # ', ' # # ', ' # # ', '#####', ' # # '],
|
||||
'<': [' #', ' # ', ' # ', '# ', ' # ', ' # ', ' #'],
|
||||
'>': ['# ', ' # ', ' # ', ' #', ' # ', ' # ', '# '],
|
||||
"'": ['#', '#'],
|
||||
'"': ['# #', '# #'],
|
||||
'|': ['#', '#', '#', '#', '#', '#', '#', '#', '#'],
|
||||
'·': [' ', ' ', ' ', '#', ' ', ' ', ' '],
|
||||
'…': [' ', ' ', ' ', ' ', ' ', ' ', '# # #'],
|
||||
'@': [' ### ', '# #', '# ###', '# # #', '# ###', '# ', ' ####'],
|
||||
};
|
||||
|
||||
// ---------- Glyphen in Umrisse umwandeln (waagerechte Pixel-Läufe als Rechtecke) ----------
|
||||
function umrisse(zeilen) {
|
||||
const konturen = [];
|
||||
zeilen.forEach((zeile, r) => {
|
||||
const yOben = (7 - r) * P;
|
||||
const yUnten = yOben - P;
|
||||
let x = 0;
|
||||
while (x < zeile.length) {
|
||||
if (zeile[x] !== '#') { x++; continue; }
|
||||
let ende = x;
|
||||
while (ende < zeile.length && zeile[ende] === '#') ende++;
|
||||
// im Uhrzeigersinn (TrueType-Außenkontur)
|
||||
konturen.push([[x * P, yUnten], [x * P, yOben], [ende * P, yOben], [ende * P, yUnten]]);
|
||||
x = ende;
|
||||
}
|
||||
});
|
||||
return konturen;
|
||||
}
|
||||
|
||||
const glyphen = [{ name: '.notdef', code: null, breite: 4, konturen: [[[0, 0], [0, 875], [500, 875], [500, 0]], [[125, 125], [375, 125], [375, 750], [125, 750]]] },
|
||||
{ name: 'space', code: 32, breite: 3, konturen: [] }];
|
||||
for (const [zeichen, zeilen] of Object.entries(G)) {
|
||||
const breite = Math.max(...zeilen.map((z) => z.length));
|
||||
glyphen.push({ code: zeichen.codePointAt(0), breite, konturen: umrisse(zeilen) });
|
||||
}
|
||||
const sortiert = [glyphen[0], glyphen[1], ...glyphen.slice(2).sort((a, b) => a.code - b.code)];
|
||||
|
||||
// ---------- Hilfen zum Schreiben ----------
|
||||
class Puffer {
|
||||
constructor() { this.teile = []; this.laenge = 0; }
|
||||
u8(v) { const b = Buffer.alloc(1); b.writeUInt8(v); return this.roh(b); }
|
||||
u16(v) { const b = Buffer.alloc(2); b.writeUInt16BE(v & 0xffff); return this.roh(b); }
|
||||
i16(v) { const b = Buffer.alloc(2); b.writeInt16BE(v); return this.roh(b); }
|
||||
u32(v) { const b = Buffer.alloc(4); b.writeUInt32BE(v >>> 0); return this.roh(b); }
|
||||
i64(v) { const b = Buffer.alloc(8); b.writeBigInt64BE(BigInt(v)); return this.roh(b); }
|
||||
roh(b) { this.teile.push(b); this.laenge += b.length; return this; }
|
||||
fertig() { return Buffer.concat(this.teile); }
|
||||
}
|
||||
const auf4 = (b) => (b.length % 4 ? Buffer.concat([b, Buffer.alloc(4 - (b.length % 4))]) : b);
|
||||
function pruefsumme(b) {
|
||||
const p = auf4(b);
|
||||
let s = 0;
|
||||
for (let i = 0; i < p.length; i += 4) s = (s + p.readUInt32BE(i)) >>> 0;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- glyf + loca + hmtx ----------
|
||||
const glyf = new Puffer();
|
||||
const loca = [];
|
||||
const hmtx = new Puffer();
|
||||
let maxPunkte = 0, maxKonturen = 0;
|
||||
let gxMin = 0, gyMin = 0, gxMax = 0, gyMax = 0;
|
||||
let maxVorschub = 0, minLsb = 1e9, minRsb = 1e9, maxXAusdehnung = 0;
|
||||
for (const g of sortiert) {
|
||||
loca.push(glyf.laenge);
|
||||
const vorschub = (g.breite + 1) * P;
|
||||
maxVorschub = Math.max(maxVorschub, vorschub);
|
||||
if (!g.konturen.length) { hmtx.u16(vorschub).i16(0); continue; }
|
||||
const punkte = g.konturen.flat();
|
||||
const xs = punkte.map((p) => p[0]), ys = punkte.map((p) => p[1]);
|
||||
const xMin = Math.min(...xs), xMax = Math.max(...xs), yMin = Math.min(...ys), yMax = Math.max(...ys);
|
||||
gxMin = Math.min(gxMin, xMin); gyMin = Math.min(gyMin, yMin); gxMax = Math.max(gxMax, xMax); gyMax = Math.max(gyMax, yMax);
|
||||
minLsb = Math.min(minLsb, xMin); minRsb = Math.min(minRsb, vorschub - xMax); maxXAusdehnung = Math.max(maxXAusdehnung, xMax);
|
||||
maxPunkte = Math.max(maxPunkte, punkte.length); maxKonturen = Math.max(maxKonturen, g.konturen.length);
|
||||
const b = new Puffer();
|
||||
b.i16(g.konturen.length).i16(xMin).i16(yMin).i16(xMax).i16(yMax);
|
||||
let ende = -1;
|
||||
for (const k of g.konturen) { ende += k.length; b.u16(ende); }
|
||||
b.u16(0); // keine Hinting-Anweisungen
|
||||
for (let i = 0; i < punkte.length; i++) b.u8(0x01); // alle Punkte auf der Kurve, Koordinaten als int16
|
||||
let px = 0, py = 0;
|
||||
for (const [x] of punkte) { b.i16(x - px); px = x; }
|
||||
for (const [, y] of punkte) { b.i16(y - py); py = y; }
|
||||
glyf.roh(auf4(b.fertig()));
|
||||
hmtx.u16(vorschub).i16(xMin);
|
||||
}
|
||||
loca.push(glyf.laenge);
|
||||
const anzahl = sortiert.length;
|
||||
|
||||
const locaB = new Puffer();
|
||||
for (const o of loca) locaB.u32(o);
|
||||
|
||||
// ---------- cmap (Format 4, ein Segment je Zeichen) ----------
|
||||
const abbildung = sortiert.map((g, i) => [g.code, i]).filter(([c]) => c !== null).sort((a, b) => a[0] - b[0]);
|
||||
const segmente = abbildung.map(([c, i]) => ({ start: c, ende: c, delta: (i - c) & 0xffff }));
|
||||
segmente.push({ start: 0xffff, ende: 0xffff, delta: 1 });
|
||||
const segX2 = segmente.length * 2;
|
||||
const suchbereich = 2 * 2 ** Math.floor(Math.log2(segmente.length));
|
||||
const sub = new Puffer();
|
||||
sub.u16(4).u16(16 + segmente.length * 8).u16(0).u16(segX2).u16(suchbereich)
|
||||
.u16(Math.log2(suchbereich / 2)).u16(segX2 - suchbereich);
|
||||
for (const s of segmente) sub.u16(s.ende);
|
||||
sub.u16(0);
|
||||
for (const s of segmente) sub.u16(s.start);
|
||||
for (const s of segmente) sub.u16(s.delta);
|
||||
for (const s of segmente) sub.u16(0);
|
||||
const cmap = new Puffer().u16(0).u16(1).u16(3).u16(1).u32(12).roh(sub.fertig());
|
||||
|
||||
// ---------- head / hhea / maxp / OS/2 / name / post ----------
|
||||
const zeit = Math.floor(Date.UTC(2026, 9, 3) / 1000) + 2082844800; // Sekunden seit 1904
|
||||
const head = new Puffer().u32(0x00010000).u32(0x00010000).u32(0).u32(0x5f0f3cf5).u16(0x000b).u16(EM)
|
||||
.i64(zeit).i64(zeit).i16(gxMin).i16(gyMin).i16(gxMax).i16(gyMax).u16(0).u16(8).i16(2).i16(1).i16(0);
|
||||
const hhea = new Puffer().u32(0x00010000).i16(ASC).i16(-DESC).i16(0).u16(maxVorschub).i16(minLsb).i16(minRsb)
|
||||
.i16(maxXAusdehnung).i16(1).i16(0).i16(0).i16(0).i16(0).i16(0).i16(0).i16(0).u16(anzahl);
|
||||
const maxp = new Puffer().u32(0x00010000).u16(anzahl).u16(maxPunkte).u16(maxKonturen).u16(0).u16(0)
|
||||
.u16(2).u16(0).u16(0).u16(0).u16(0).u16(0).u16(0).u16(0).u16(0);
|
||||
const codes = abbildung.map(([c]) => c);
|
||||
const os2 = new Puffer().u16(4).i16(5 * P).u16(400).u16(5).u16(0)
|
||||
.i16(650).i16(600).i16(0).i16(75).i16(650).i16(600).i16(0).i16(350).i16(P).i16(3 * P)
|
||||
.i16(0).roh(Buffer.alloc(10)).u32(3).u32(0).u32(0).u32(0).roh(Buffer.from('AERO'))
|
||||
.u16(0x40).u16(Math.min(...codes)).u16(Math.min(0xffff, Math.max(...codes)))
|
||||
.i16(ASC).i16(-DESC).i16(0).u16(ASC).u16(DESC).u32(1).u32(0).i16(5 * P).i16(7 * P).u16(0).u16(32).u16(1);
|
||||
const namen = { 1: 'AeroPixel', 2: 'Regular', 3: 'AeroPixel Regular', 4: 'AeroPixel', 5: 'Version 1.0', 6: 'AeroPixel-Regular' };
|
||||
const nameDaten = Object.entries(namen).map(([id, text]) => [Number(id), Buffer.from(text, 'utf16le').swap16()]);
|
||||
const name = new Puffer().u16(0).u16(nameDaten.length).u16(6 + nameDaten.length * 12);
|
||||
let versatz = 0;
|
||||
for (const [id, b] of nameDaten) { name.u16(3).u16(1).u16(0x409).u16(id).u16(b.length).u16(versatz); versatz += b.length; }
|
||||
for (const [, b] of nameDaten) name.roh(b);
|
||||
const post = new Puffer().u32(0x00030000).u32(0).i16(-P).i16(P).u32(0).u32(0).u32(0).u32(0).u32(0);
|
||||
|
||||
// ---------- zusammensetzen ----------
|
||||
const tabellen = [
|
||||
['OS/2', os2], ['cmap', cmap], ['glyf', glyf], ['head', head], ['hhea', hhea],
|
||||
['hmtx', hmtx], ['loca', locaB], ['maxp', maxp], ['name', name], ['post', post],
|
||||
].map(([tag, p]) => [tag, p.fertig()]);
|
||||
const n = tabellen.length;
|
||||
const sb = 16 * 2 ** Math.floor(Math.log2(n));
|
||||
const kopf = new Puffer().u32(0x00010000).u16(n).u16(sb).u16(Math.log2(sb / 16)).u16(n * 16 - sb);
|
||||
let offset = 12 + n * 16;
|
||||
const verzeichnis = new Puffer();
|
||||
for (const [tag, daten] of tabellen) {
|
||||
verzeichnis.roh(Buffer.from(tag, 'latin1')).u32(pruefsumme(daten)).u32(offset).u32(daten.length);
|
||||
offset += auf4(daten).length;
|
||||
}
|
||||
let schrift = Buffer.concat([kopf.fertig(), verzeichnis.fertig(), ...tabellen.map(([, d]) => auf4(d))]);
|
||||
// checkSumAdjustment im head-Eintrag setzen
|
||||
const headOffset = 12 + n * 16 + tabellen.slice(0, 3).reduce((s, [, d]) => s + auf4(d).length, 0);
|
||||
schrift.writeUInt32BE((0xb1b0afba - pruefsumme(schrift)) >>> 0, headOffset + 8);
|
||||
|
||||
const ziel = path.join(__dirname, '..', 'src', 'assets', 'aeropixel.ttf');
|
||||
fs.writeFileSync(ziel, schrift);
|
||||
console.log(`${ziel}: ${anzahl} Glyphen, ${schrift.length} Bytes`);
|
||||
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 542 B |
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 572 B |
@@ -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>
|
||||
@@ -154,14 +154,27 @@ function scan(inputPath) {
|
||||
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) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
if (META_FILES.includes(name)) continue;
|
||||
for (const name of kopierNamen(sourceDir)) {
|
||||
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) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
@@ -193,4 +206,4 @@ function importOne(store, entry, copyData = true) {
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, importOne, resolveInstancesDir };
|
||||
module.exports = { scan, importOne, kopierQuellen, resolveInstancesDir };
|
||||
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* designs.css – wählbare Designs (Optionen → Darstellung → Design)
|
||||
* ---------------------------------------------------------------
|
||||
* Klassisch (hell/dunkel) steht in styles.css. Die Designs hier bauen
|
||||
* auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über
|
||||
* data-design – gleiche Spezifität, deshalb muss diese Datei NACH styles.css
|
||||
* geladen werden.
|
||||
*/
|
||||
|
||||
/* ===================== Design-Auswahl in den Optionen ===================== */
|
||||
.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))}
|
||||
@media (max-width:900px){.settings-card-wide .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: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 small{font-size:12px;font-weight:600;padding:0 2px}
|
||||
.dv{position:relative;display:block;width:100%;align-self:stretch;height:58px;border-radius:5px;overflow:hidden;border:1px solid rgba(0,0,0,.25)}
|
||||
.dv i,.dv b,.dv em{position:absolute;display:block}
|
||||
.dv i{left:0;right:0;top:0;height:11px}
|
||||
.dv em{top:11px;right:0;bottom:0;width:24%}
|
||||
.dv b{left:10px;top:20px;width:22px;height:22px;border-radius:3px}
|
||||
.dv-dark{background:#242426}.dv-dark i{background:#333337}.dv-dark em{background:#2d2d30;border-left:1px solid #3f3f46}.dv-dark b{background:#0e639c;border:1px solid #1177bb}
|
||||
.dv-light{background:#e2e2e2}.dv-light i{background:#f0f0f0;border-bottom:1px solid #c4c7cc}.dv-light em{background:#f0f0f0;border-left:1px solid #c4c7cc}.dv-light b{background:#dce8f6;border:1px solid #3a6ea5}
|
||||
.dv-aero{background:radial-gradient(60px 30px at 35% 10px,rgba(64,126,232,.55),transparent 70%),linear-gradient(to right,rgba(148,175,255,.09) 1px,transparent 1px) 0 0/9px 9px,linear-gradient(to bottom,rgba(148,175,255,.09) 1px,transparent 1px) 0 0/9px 9px,#05070d}
|
||||
.dv-aero i{background:rgba(10,14,26,.95);border-bottom:1px solid rgba(148,175,255,.18)}
|
||||
.dv-aero em{background:#0a0e1a;border-left:1px solid rgba(148,175,255,.18)}
|
||||
.dv-aero b{border-radius:7px;background:linear-gradient(135deg,#8fd8ff,#407ee8 60%,#2658be);box-shadow:0 4px 12px -3px rgba(64,126,232,.9)}
|
||||
.dv-lila{background:radial-gradient(60px 30px at 35% 10px,rgba(139,92,246,.6),transparent 70%),linear-gradient(to right,rgba(196,165,255,.09) 1px,transparent 1px) 0 0/9px 9px,linear-gradient(to bottom,rgba(196,165,255,.09) 1px,transparent 1px) 0 0/9px 9px,#08050f}
|
||||
.dv-lila i{background:rgba(15,10,26,.95);border-bottom:1px solid rgba(196,165,255,.18)}
|
||||
.dv-lila em{background:#0f0a1a;border-left:1px solid rgba(196,165,255,.18)}
|
||||
.dv-lila b{border-radius:7px;background:linear-gradient(135deg,#e8beff,#8b5cf6 60%,#6d28d9);box-shadow:0 4px 12px -3px rgba(139,92,246,.9)}
|
||||
.dv-mc{background:url(assets/design/erde-dunkel.png) 0 0/24px 24px;image-rendering:pixelated}
|
||||
.dv-mc i{height:12px;background:url(assets/design/gras.png) 0 0/16px 4px repeat-x,url(assets/design/schiefer.png) 0 0/24px 24px;border-bottom:1px solid #000}
|
||||
.dv-mc em{background:url(assets/design/schiefer.png) 0 0/24px 24px;border-left:1px solid #000}
|
||||
.dv-mc b{border-radius:0;background:#3c8527;border:1px solid #000;box-shadow:inset 1px 1px 0 #6cc349,inset -1px -1px 0 #1d4d13}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ============ Designs: AeroMC (Blau, wie die Webseite) & Nachtlila ===== */
|
||||
/* ===================================================================== */
|
||||
/* Beide Designs teilen sich alle Regeln unten; sie unterscheiden sich nur in
|
||||
der Palette. --p-* ohne "#" sind RGB-Tripel, damit rgba(var(--p-…),.5) geht. */
|
||||
:root[data-design="aero"]{
|
||||
--p-akzent:64,126,232; --p-tief:38,88,190; --p-akzent2:104,196,250; --p-hell:143,216,255;
|
||||
--p-linie:148,175,255; --p-win:10,14,26; --p-surface:13,20,40; --p-surface2:16,26,53; --p-schatten:2,4,10;
|
||||
--p-akzent-hover:#4f8df0; --p-tief-hover:#2c63cc; --p-aktiv:#0b1226; --p-konsole:#03050a;
|
||||
--win:#0a0e1a; --area:#05070d; --bar:#0d1428; --surface:#0d1428; --surface2:#101a35; --input:#070b16;
|
||||
--text:#e8ecfb; --dim:#93a0c4; --link:#8fd8ff;
|
||||
}
|
||||
:root[data-design="lila"]{
|
||||
--p-akzent:139,92,246; --p-tief:109,40,217; --p-akzent2:192,132,252; --p-hell:232,190,255;
|
||||
--p-linie:196,165,255; --p-win:15,10,26; --p-surface:24,15,41; --p-surface2:32,20,56; --p-schatten:6,3,12;
|
||||
--p-akzent-hover:#9d72ff; --p-tief-hover:#7c3aed; --p-aktiv:#140c24; --p-konsole:#050309;
|
||||
--win:#0f0a1a; --area:#08050f; --bar:#180f29; --surface:#180f29; --surface2:#201438; --input:#0b0715;
|
||||
--text:#f0eafb; --dim:#a99cc6; --link:#e8beff;
|
||||
}
|
||||
:root:is([data-design="aero"],[data-design="lila"]){
|
||||
--border:rgba(var(--p-linie),.14); --border2:rgba(var(--p-linie),.09);
|
||||
--sel:rgba(var(--p-akzent),.20); --sel-br:var(--p-akzent-hover); --accent:rgb(var(--p-akzent));
|
||||
--danger:#ff7a8a; --play:#4ade80;
|
||||
--cyan:rgb(var(--p-hell)); --cyan-soft:rgb(var(--p-akzent2));
|
||||
--stat-bar:rgb(var(--p-akzent2));
|
||||
--titel-bg:var(--win); --titel-fg:var(--text);
|
||||
}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) ::selection{background:rgb(var(--p-akzent));color:#fff}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .titel-text{font-weight:600;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"]) body{background:var(--area)}
|
||||
|
||||
/* Werkzeugleiste */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .toolbar{background:rgba(var(--p-win),.94);border-bottom:1px solid var(--border);padding:7px 8px;gap:2px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .tbtn{border-radius:999px;padding:6px 10px;color:var(--text)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .tbtn:hover{background:var(--surface);border-color:var(--border)}
|
||||
: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"]) #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-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"]) .tbsep{background:var(--border)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px;padding:5px 12px;width:150px}
|
||||
|
||||
/* Instanzliste: Raster und blaues Leuchten wie im Kopf der Webseite */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .workspace{background:var(--area)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .list-area{background:
|
||||
radial-gradient(520px 260px at 32% -60px,rgba(var(--p-akzent),.30),transparent 70%),
|
||||
linear-gradient(to right,rgba(var(--p-linie),.05) 1px,transparent 1px) 0 0/44px 44px,
|
||||
linear-gradient(to bottom,rgba(var(--p-linie),.05) 1px,transparent 1px) 0 0/44px 44px,
|
||||
var(--area)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .group-header{color:var(--text);padding:8px 4px 4px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .group-header .chev{color:var(--cyan-soft)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .group-header::after{background:linear-gradient(to right,rgba(var(--p-linie),.22),rgba(var(--p-linie),.04))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .group-grid{gap:6px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card{border-radius:14px;padding:8px 4px 6px;transition:background .15s,border-color .15s,box-shadow .15s}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card:hover{background:rgba(var(--p-surface),.85);border-color:var(--border)}
|
||||
: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-icon-img{border-radius:10px}
|
||||
: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}
|
||||
|
||||
/* Seitenleiste */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .side{background:var(--win);border-left:1px solid var(--border);padding:10px 6px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .side-ico img{border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .side-grp{color:var(--cyan-soft)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .hr{background:var(--border);margin:8px 4px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .sbtn{border-radius:999px;margin-bottom:1px;padding:5px 2px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .sbtn:hover{background:var(--surface);border-color:var(--border)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play{color:#fff;background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)));border-color:transparent;box-shadow:0 0 0 1px rgba(255,255,255,.08) inset,0 10px 24px -12px rgba(var(--p-akzent),.95);padding:7px 4px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play:hover{background:linear-gradient(180deg,var(--p-akzent-hover),var(--p-tief-hover))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .sbtn.del:hover{color:var(--danger);border-color:rgba(255,122,138,.35)}
|
||||
|
||||
/* Statusleiste */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .statusbar{background:var(--win);border-top:1px solid var(--border)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .st-row{color:var(--dim)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .st-row2{background:var(--input);border-top-color:var(--border2)}
|
||||
|
||||
/* Dialoge */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .modal{background:rgba(var(--p-schatten),.68);backdrop-filter:blur(3px)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .modal-card{background:var(--win);border:1px solid var(--border);border-radius:16px;box-shadow:0 30px 80px -20px rgba(0,0,0,.85),0 0 0 1px rgba(var(--p-hell),.04)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .modal-head{background:linear-gradient(180deg,rgba(var(--p-surface2),.9),rgba(var(--p-win),.9));border-bottom:1px solid var(--border);padding:12px 16px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .modal-body{background:var(--win)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .modal-foot{background:var(--win);border-top:1px solid var(--border);padding:12px 16px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .modal-close{color:var(--dim)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .modal-close:hover{color:var(--cyan)}
|
||||
|
||||
/* Eingabefelder */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .field input,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .field select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .field textarea,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mods-search input,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mods-search 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"]) .clip-length select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,
|
||||
: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"]) .search-wrap input{border-radius:999px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input:focus,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .field input:focus,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .field select:focus,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .field textarea:focus,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mods-search input:focus,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .path-row input:focus{border-color:var(--cyan-soft);box-shadow:0 0 0 3px rgba(var(--p-akzent2),.15)}
|
||||
: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"]) .upd-notes{background:var(--input);border-radius:10px}
|
||||
|
||||
/* 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:hover{background:var(--surface2);border-color:rgba(var(--p-linie),.32)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn:active{background:var(--p-aktiv)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn-primary{background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)));border-color:transparent;color:#fff;box-shadow:0 0 0 1px rgba(255,255,255,.08) inset,0 10px 28px -12px rgba(var(--p-akzent),.9)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn-primary:hover{background:linear-gradient(180deg,var(--p-akzent-hover),var(--p-tief-hover));transform:translateY(-1px)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn-primary:disabled{transform:none}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn-danger{color:var(--danger);border-color:rgba(255,122,138,.35)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn-danger:hover{background:rgba(255,122,138,.10);border-color:rgba(255,122,138,.6)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn-mini{padding:4px 10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .badge{background:var(--surface2);color:var(--dim);border-color:var(--border);border-radius:999px}
|
||||
|
||||
/* Listen, Kacheln, Tabs */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mods-list,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .account-item,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .screen-thumb{background:var(--input);border-color:var(--border);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mod-icon,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .account-av,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .icon-preview{background:var(--surface2);border-radius:8px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mod-item{border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mod-item+.mod-item{border-color:var(--border2)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mod-item:hover{background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mtab{border-radius:10px 10px 0 0}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mtab.on{background:var(--surface);border-color:var(--border);color:var(--cyan)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .advanced{background:var(--surface);border-color:var(--border);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .console-out{background:var(--p-konsole);border-color:var(--border);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .stat-tile,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .crash-fund,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .share-code-box,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .share-preview,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .clip-bar,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .wahl-option{border-radius:12px;background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .chat-verlauf{background:var(--input);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .chat-blase{background:var(--surface2);border-radius:14px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .chat-blase.ich{background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)));border-color:transparent}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .stat-bar{border-radius:0 6px 6px 0;background:linear-gradient(90deg,rgb(var(--p-akzent)),rgb(var(--p-akzent2)))}
|
||||
|
||||
/* Einstellungen */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-body{background:var(--area)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-nav{background:var(--win);border-right-color:var(--border)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .snav{border-radius:10px}
|
||||
: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"]) .settings-card{background:var(--surface);border-color:var(--border);border-radius:16px}
|
||||
: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"]) .appinfo-item{background:rgba(var(--p-linie),.04);border-color:var(--border2);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .info-meta-link{color:var(--cyan)}
|
||||
: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 */
|
||||
: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:hover{background:rgba(var(--p-akzent),.22)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .toast{background:var(--surface2);border:1px solid var(--border);border-radius:999px;padding:10px 18px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) ::-webkit-scrollbar-thumb{background:rgba(var(--p-linie),.18);border-color:var(--area)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) ::-webkit-scrollbar-thumb:hover{background:rgba(var(--p-linie),.3)}
|
||||
|
||||
/* Skin-Editor */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-modus{border-radius:999px;background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-modus-btn.on{background:linear-gradient(180deg,rgb(var(--p-akzent)),rgb(var(--p-tief)))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-tool,:root:is([data-design="aero"],[data-design="lila"]) .se-ebene{background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-tool{border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-ebenen{border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-buehne,:root:is([data-design="aero"],[data-design="lila"]) .se-mini{border-radius:14px;background:var(--input)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .design-wahl{border-radius:12px}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ===================== Design: Minecraft ============================= */
|
||||
/* ===================================================================== */
|
||||
@font-face{font-family:"AeroPixel";src:url(assets/aeropixel.ttf) format("truetype");font-display:block}
|
||||
:root[data-design="minecraft"]{
|
||||
--win:#313233; --area:#1e1e1f; --bar:#48494a;
|
||||
--border:#000; --border2:#1a1a1b;
|
||||
--text:#ffffff; --dim:#b8b9bc;
|
||||
--sel:#2f6b1f; --sel-br:#6cc349; --link:#86d75e; --accent:#3c8527;
|
||||
--danger:#ff5555; --play:#6cc349; --input:#000;
|
||||
--stat-bar:#6cc349;
|
||||
--pixel:"AeroPixel","Segoe UI",sans-serif;
|
||||
--mc-schatten:2px 2px 0 #3f3f3f;
|
||||
}
|
||||
:root[data-design="minecraft"] body{background:var(--area)}
|
||||
:root[data-design="minecraft"] ::selection{background:#3c8527;color:#fff}
|
||||
:root[data-design="minecraft"]{--titel-bg:#1e1e1f;--titel-fg:#ffffff}
|
||||
:root[data-design="minecraft"] .titel-text{font-family:var(--pixel);font-size:16px;text-shadow:var(--mc-schatten);padding-bottom:2px}
|
||||
|
||||
/* Wiederkehrender Block-Knopf: grauer Stein mit hellem/dunklem Rand */
|
||||
:root[data-design="minecraft"] .btn,
|
||||
:root[data-design="minecraft"] .sbtn,
|
||||
:root[data-design="minecraft"] .snav,
|
||||
:root[data-design="minecraft"] .se-tool,
|
||||
:root[data-design="minecraft"] .wahl-option{
|
||||
background:#58595b;color:#fff;border:2px solid #000;border-radius:0;
|
||||
box-shadow:inset 2px 2px 0 #8b8c8f,inset -2px -2px 0 #36373a;
|
||||
text-shadow:1px 1px 0 #2a2a2a}
|
||||
:root[data-design="minecraft"] .btn:hover,
|
||||
:root[data-design="minecraft"] .sbtn:hover,
|
||||
:root[data-design="minecraft"] .snav:hover,
|
||||
:root[data-design="minecraft"] .se-tool:hover:not(:disabled),
|
||||
:root[data-design="minecraft"] .wahl-option:hover{background:#6a6b6f;border-color:#fff;box-shadow:inset 2px 2px 0 #a3a4a8,inset -2px -2px 0 #3e3f42}
|
||||
:root[data-design="minecraft"] .btn:active,
|
||||
:root[data-design="minecraft"] .sbtn:active{background:#48494b;box-shadow:inset -2px -2px 0 #8b8c8f,inset 2px 2px 0 #36373a}
|
||||
:root[data-design="minecraft"] .btn:disabled,
|
||||
:root[data-design="minecraft"] .sbtn:disabled{background:#2b2b2c;color:#8b8b8b;box-shadow:none;border-color:#000;text-shadow:none;opacity:1}
|
||||
:root[data-design="minecraft"] .sbtn:disabled:hover{background:#2b2b2c;border-color:#000}
|
||||
/* grün wie der Spielen-Knopf */
|
||||
:root[data-design="minecraft"] .btn-primary,
|
||||
:root[data-design="minecraft"] .sbtn.play,
|
||||
:root[data-design="minecraft"] .snav.on,
|
||||
:root[data-design="minecraft"] .se-tool.on,
|
||||
:root[data-design="minecraft"] .wahl-option.an{background:#3c8527;color:#fff;box-shadow:inset 2px 2px 0 #6cc349,inset -2px -2px 0 #1d4d13}
|
||||
:root[data-design="minecraft"] .btn-primary:hover,
|
||||
:root[data-design="minecraft"] .sbtn.play:hover{background:#4a9a33;box-shadow:inset 2px 2px 0 #82d65c,inset -2px -2px 0 #24601a;border-color:#fff}
|
||||
:root[data-design="minecraft"] .btn-danger{background:#8c2b2b;box-shadow:inset 2px 2px 0 #c55050,inset -2px -2px 0 #561616}
|
||||
:root[data-design="minecraft"] .btn-danger:hover{background:#a33434;border-color:#fff;box-shadow:inset 2px 2px 0 #d86262,inset -2px -2px 0 #651b1b}
|
||||
:root[data-design="minecraft"] .sbtn.del:hover{background:#8c2b2b;color:#fff;box-shadow:inset 2px 2px 0 #c55050,inset -2px -2px 0 #561616}
|
||||
|
||||
/* Werkzeugleiste: Schiefer mit Grasnarbe */
|
||||
: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;
|
||||
image-rendering:pixelated;border-bottom:2px solid #000;padding:11px 8px 6px}
|
||||
: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:active{background:rgba(0,0,0,.5)}
|
||||
: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-import svg{color:#6db8ff}
|
||||
:root[data-design="minecraft"] #btn-open-dir svg{color:#f2c94c}
|
||||
:root[data-design="minecraft"] #btn-settings svg{color:#d0d0d0}
|
||||
:root[data-design="minecraft"] .tbsep{background:#000;width:2px}
|
||||
:root[data-design="minecraft"] .tb-badge{border-radius:0;background:#ff5555;box-shadow:inset -1px -1px 0 #a32a2a}
|
||||
|
||||
/* Eingabefelder: schwarz mit grauem Rand wie im Spiel */
|
||||
:root[data-design="minecraft"] .search-wrap input,
|
||||
:root[data-design="minecraft"] .field input,
|
||||
:root[data-design="minecraft"] .field select,
|
||||
:root[data-design="minecraft"] .field textarea,
|
||||
:root[data-design="minecraft"] .mods-search input,
|
||||
:root[data-design="minecraft"] .mods-search select,
|
||||
:root[data-design="minecraft"] .mods-filters select,
|
||||
:root[data-design="minecraft"] .mmc-bar select,
|
||||
:root[data-design="minecraft"] .clip-length select,
|
||||
:root[data-design="minecraft"] .path-row input,
|
||||
:root[data-design="minecraft"] .login-usercode,
|
||||
:root[data-design="minecraft"] .upd-notes,
|
||||
:root[data-design="minecraft"] .se-farbzeile input[type=text],
|
||||
:root[data-design="minecraft"] .se-feld select{background:#000;color:#fff;border:1px solid #a0a0a0;border-radius:0}
|
||||
:root[data-design="minecraft"] .search-wrap input:focus,
|
||||
:root[data-design="minecraft"] .field input:focus,
|
||||
:root[data-design="minecraft"] .field select:focus,
|
||||
:root[data-design="minecraft"] .field textarea:focus,
|
||||
:root[data-design="minecraft"] .mods-search input:focus,
|
||||
:root[data-design="minecraft"] .path-row input:focus{border-color:#fff;box-shadow:none}
|
||||
:root[data-design="minecraft"] input[type=checkbox],:root[data-design="minecraft"] input[type=radio],:root[data-design="minecraft"] input[type=range]{accent-color:#3c8527}
|
||||
|
||||
/* Instanzliste auf dunkler Erde */
|
||||
:root[data-design="minecraft"] .workspace{background:var(--area)}
|
||||
:root[data-design="minecraft"] .list-area{background:url(assets/design/erde-dunkel.png) 0 0/64px 64px;image-rendering:pixelated}
|
||||
:root[data-design="minecraft"] .list-area::after,
|
||||
: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-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::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-name{color:#fff;text-shadow:1px 1px 0 #1a1a1a}
|
||||
:root[data-design="minecraft"] .card:hover{background:rgba(0,0,0,.35);border-color:rgba(255,255,255,.35)}
|
||||
:root[data-design="minecraft"] .card.sel{background:rgba(0,0,0,.6);border-color:#fff;outline:1px solid #fff;outline-offset:-2px}
|
||||
: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-icon-img{border-radius:0}
|
||||
: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}
|
||||
|
||||
/* Seitenleiste: Knöpfe gestapelt wie im Pausenmenü */
|
||||
:root[data-design="minecraft"] .side{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-left:2px solid #000;padding:10px 6px}
|
||||
:root[data-design="minecraft"] .side>*{image-rendering:auto}
|
||||
:root[data-design="minecraft"] .side-title{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
||||
:root[data-design="minecraft"] .side-grp{color:#86d75e}
|
||||
:root[data-design="minecraft"] .side-ico img{border-radius:0}
|
||||
:root[data-design="minecraft"] .side-empty{color:#d0d0d0}
|
||||
: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.play{font-family:var(--pixel);font-size:16px;font-weight:400;padding:6px 4px 7px;text-shadow:var(--mc-schatten)}
|
||||
|
||||
/* 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"] .st-row{color:#e6e6e6;text-shadow:1px 1px 0 #1a1a1a}
|
||||
:root[data-design="minecraft"] .st-row2{background:rgba(0,0,0,.4);border-top:2px solid #000}
|
||||
|
||||
/* Dialoge: dunkles Steinfenster mit Holzbalken als Kopf */
|
||||
:root[data-design="minecraft"] .modal{background:rgba(0,0,0,.62)}
|
||||
:root[data-design="minecraft"] .modal-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f,0 10px 30px rgba(0,0,0,.6)}
|
||||
:root[data-design="minecraft"] .modal-head{background:url(assets/design/bretter.png) 0 0/64px 64px;image-rendering:pixelated;border-bottom:2px solid #000;margin:2px 2px 0}
|
||||
:root[data-design="minecraft"] .modal-head h3{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten)}
|
||||
:root[data-design="minecraft"] .modal-head>*{image-rendering:auto}
|
||||
:root[data-design="minecraft"] .modal-body{background:#313233;margin:0 2px}
|
||||
:root[data-design="minecraft"] .modal-foot{background:#262627;border-top:2px solid #000;margin:0 2px 2px}
|
||||
:root[data-design="minecraft"] .modal-close{color:#fff;text-shadow:1px 1px 0 #3f3f3f;font-size:16px}
|
||||
:root[data-design="minecraft"] .modal-close:hover{color:#ffff7f}
|
||||
:root[data-design="minecraft"] .popout-host .modal-card{box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f}
|
||||
|
||||
/* Listen, Kacheln, Tabs */
|
||||
:root[data-design="minecraft"] .mods-list,
|
||||
:root[data-design="minecraft"] .account-item,
|
||||
:root[data-design="minecraft"] .screen-thumb,
|
||||
:root[data-design="minecraft"] .chat-verlauf,
|
||||
:root[data-design="minecraft"] .console-out{background:#000;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #3a3a3c}
|
||||
:root[data-design="minecraft"] .account-item{background:#1e1e1f}
|
||||
:root[data-design="minecraft"] .account-item.active{border-color:#6cc349}
|
||||
:root[data-design="minecraft"] .mod-icon,
|
||||
:root[data-design="minecraft"] .account-av,
|
||||
:root[data-design="minecraft"] .icon-preview{background:#48494a;border-radius:0}
|
||||
:root[data-design="minecraft"] .mod-item{border-radius:0}
|
||||
:root[data-design="minecraft"] .mod-item+.mod-item{border-color:#2a2a2b}
|
||||
:root[data-design="minecraft"] .mod-item:hover{background:#262627;outline:1px solid #8b8b8b;outline-offset:-1px}
|
||||
:root[data-design="minecraft"] .mods-tabs{border-bottom:2px solid #000}
|
||||
:root[data-design="minecraft"] .mtab{border-radius:0;color:#c8c8c8;margin-bottom:-2px;border-width:2px}
|
||||
:root[data-design="minecraft"] .mtab.on{background:#48494a;border-color:#000;color:#fff;text-shadow:1px 1px 0 #2a2a2a}
|
||||
:root[data-design="minecraft"] .advanced,
|
||||
:root[data-design="minecraft"] .stat-tile,
|
||||
:root[data-design="minecraft"] .crash-fund,
|
||||
:root[data-design="minecraft"] .share-code-box,
|
||||
:root[data-design="minecraft"] .share-preview,
|
||||
:root[data-design="minecraft"] .clip-bar,
|
||||
:root[data-design="minecraft"] .crash-unklar{background:#262627;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a}
|
||||
:root[data-design="minecraft"] .crash-fund{border-left:4px solid #3c8527}
|
||||
:root[data-design="minecraft"] .badge{border-radius:0;background:#48494a;color:#e6e6e6;border-color:#000}
|
||||
:root[data-design="minecraft"] .chat-blase{border-radius:0;background:#48494a;border:2px solid #000}
|
||||
:root[data-design="minecraft"] .chat-blase.ich{background:#3c8527;border-color:#000}
|
||||
:root[data-design="minecraft"] .stat-bar{border-radius:0;box-shadow:inset 0 -2px 0 #3c8527}
|
||||
:root[data-design="minecraft"] .mod-new{border-radius:0}
|
||||
:root[data-design="minecraft"] .friend-avatar{border-radius:0}
|
||||
|
||||
/* Einstellungen */
|
||||
:root[data-design="minecraft"] .settings-body{background:url(assets/design/erde-dunkel.png) 0 0/64px 64px;image-rendering:pixelated}
|
||||
:root[data-design="minecraft"] .settings-body>*{image-rendering:auto}
|
||||
:root[data-design="minecraft"] .settings-nav{background:rgba(0,0,0,.45);border-right:2px solid #000;gap:4px}
|
||||
:root[data-design="minecraft"] .settings-nav-label{color:#d0d0d0;text-shadow:1px 1px 0 #1a1a1a}
|
||||
:root[data-design="minecraft"] .snav{padding:8px 12px}
|
||||
: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-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 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"] .about-brand{border-bottom-color:#1a1a1b}
|
||||
:root[data-design="minecraft"] .appinfo-item{background:#262627;border:2px solid #000;border-radius:0}
|
||||
:root[data-design="minecraft"] .info-meta-link{color:#86d75e}
|
||||
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
||||
|
||||
/* Kontextmenü, Hinweis, Bildlaufleisten */
|
||||
: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:hover{background:#3c8527}
|
||||
:root[data-design="minecraft"] .ctx-danger:hover{background:#8c2b2b}
|
||||
:root[data-design="minecraft"] .ctx-sep{background:#000;height:2px}
|
||||
:root[data-design="minecraft"] .toast{background:#212122;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #4a4a4c,inset -2px -2px 0 #111,0 6px 20px rgba(0,0,0,.5);text-shadow:1px 1px 0 #000}
|
||||
:root[data-design="minecraft"] .toast.err{background:#5c1a1a}
|
||||
:root[data-design="minecraft"] ::-webkit-scrollbar-thumb{background:#8b8b8b;border:3px solid var(--area);border-radius:0;box-shadow:inset -2px -2px 0 #555}
|
||||
:root[data-design="minecraft"] ::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
|
||||
|
||||
/* Skin-Editor */
|
||||
:root[data-design="minecraft"] .se-modus{border:2px solid #000;border-radius:0;background:#2b2b2c}
|
||||
:root[data-design="minecraft"] .se-modus-btn{color:#d0d0d0;text-shadow:1px 1px 0 #000}
|
||||
:root[data-design="minecraft"] .se-modus-btn+.se-modus-btn{border-left:2px solid #000}
|
||||
:root[data-design="minecraft"] .se-modus-btn.on{background:#3c8527;color:#fff;box-shadow:inset 2px 2px 0 #6cc349,inset -2px -2px 0 #1d4d13}
|
||||
:root[data-design="minecraft"] .se-ebenen{border:2px solid #000;border-radius:0}
|
||||
:root[data-design="minecraft"] .se-ebene{background:#2b2b2c;color:#d0d0d0}
|
||||
:root[data-design="minecraft"] .se-ebene+.se-ebene{border-left:2px solid #000}
|
||||
:root[data-design="minecraft"] .se-ebene.on{background:#3c8527;color:#fff}
|
||||
:root[data-design="minecraft"] .se-buehne,:root[data-design="minecraft"] .se-mini{border:2px solid #000;border-radius:0;background:#1e1e1f}
|
||||
:root[data-design="minecraft"] .se-farbfeld{border-radius:0;border-color:#000}
|
||||
:root[data-design="minecraft"] .se-titel{color:#d0d0d0}
|
||||
:root[data-design="minecraft"] .design-wahl{border-radius:0;border:2px solid #000;background:#262627}
|
||||
:root[data-design="minecraft"] .design-wahl.on{border-color:#fff;box-shadow:none;background:#2f6b1f}
|
||||
:root[data-design="minecraft"] .dv{border-radius:0}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* "Fenster wachsen aus dem Knopf": Ein Popup-Fenster (Optionen, Mods, Skin,
|
||||
* Freunde …) erscheint nicht schlagartig, sondern wächst aus dem angeklickten
|
||||
* Knopf heraus. Dafür wird das noch unsichtbare Fenster abfotografiert und das
|
||||
* Bild in einem durchsichtigen Hilfsfenster vom Knopf aus groß gezogen; am Ende
|
||||
* übernimmt das echte Fenster an exakt derselben Stelle.
|
||||
*
|
||||
* Das Hilfsfenster entsteht jedes Mal neu und gleich in der richtigen Größe:
|
||||
* Unter Windows färbt sich ein durchsichtiges Fenster schwarz, sobald es
|
||||
* nachträglich vergrößert wird, und ein verstecktes Fenster zeichnet nicht mehr.
|
||||
* Abschaltbar unter Optionen → Darstellung → Animationen („Bewegte Oberfläche“).
|
||||
*/
|
||||
const path = require('path');
|
||||
const { BrowserWindow } = require('electron');
|
||||
|
||||
const DAUER_MS = 280;
|
||||
const HOECHSTENS_MS = 2000; // spätestens dann ist das Fenster in jedem Fall zu sehen
|
||||
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function vereinigung(a, b) {
|
||||
const x = Math.min(a.x, b.x);
|
||||
const y = Math.min(a.y, b.y);
|
||||
return {
|
||||
x, y,
|
||||
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
||||
height: Math.max(a.y + a.height, b.y + b.height) - y,
|
||||
};
|
||||
}
|
||||
|
||||
// Rechteck in Koordinaten des Hilfsfensters umrechnen
|
||||
const relativ = (r, box) => ({ x: r.x - box.x, y: r.y - box.y, width: r.width, height: r.height });
|
||||
|
||||
async function abspielen(ziel, von, merke) {
|
||||
// unsichtbar zeigen, damit Chromium den Inhalt zeichnet, dann abfotografieren
|
||||
ziel.setOpacity(0);
|
||||
ziel.setIgnoreMouseEvents(true);
|
||||
ziel.show();
|
||||
await warte(90);
|
||||
if (ziel.isDestroyed()) return;
|
||||
const bild = await ziel.webContents.capturePage();
|
||||
if (bild.isEmpty() || ziel.isDestroyed()) return;
|
||||
const nach = ziel.getBounds();
|
||||
const box = vereinigung(von, nach);
|
||||
|
||||
const hilfe = new BrowserWindow({
|
||||
x: box.x, y: box.y, width: box.width, height: box.height,
|
||||
show: false, frame: false, transparent: true, resizable: false, movable: false,
|
||||
minimizable: false, maximizable: false, fullscreenable: false, focusable: false,
|
||||
skipTaskbar: true, hasShadow: false, alwaysOnTop: true, backgroundColor: '#00000000',
|
||||
title: 'AeroMC',
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||
});
|
||||
merke(hilfe);
|
||||
hilfe.setIgnoreMouseEvents(true);
|
||||
await hilfe.loadFile(path.join(__dirname, 'aufbau.html'));
|
||||
const ok = await hilfe.webContents.executeJavaScript(
|
||||
`vorbereiten(${JSON.stringify(bild.toDataURL())}, ${JSON.stringify(relativ(nach, box))}, ${JSON.stringify(relativ(von, box))})`);
|
||||
if (!ok || hilfe.isDestroyed()) return;
|
||||
hilfe.showInactive();
|
||||
await hilfe.webContents.executeJavaScript(`abspielen(${DAUER_MS})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt `ziel` (geladen, aber noch nicht sichtbar) mit Wachs-Animation an.
|
||||
* `von` ist das Rechteck des angeklickten Knopfs in Bildschirmkoordinaten.
|
||||
*/
|
||||
async function wachsen(ziel, von) {
|
||||
let hilfe = null;
|
||||
try {
|
||||
await Promise.race([abspielen(ziel, von, (h) => { hilfe = h; }), warte(HOECHSTENS_MS)]);
|
||||
} catch {
|
||||
// Animation ist nur Zierde – im Fehlerfall das Fenster einfach zeigen
|
||||
} finally {
|
||||
if (!ziel.isDestroyed()) {
|
||||
ziel.setOpacity(1);
|
||||
ziel.setIgnoreMouseEvents(false);
|
||||
if (!ziel.isVisible()) ziel.show();
|
||||
ziel.focus();
|
||||
}
|
||||
// Hilfsfenster erst nach dem Umschalten schließen, sonst blitzt der Hintergrund durch
|
||||
setTimeout(() => { if (hilfe && !hilfe.isDestroyed()) hilfe.destroy(); }, 60);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { wachsen };
|
||||
@@ -200,7 +200,32 @@ async function sendReport(daten) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- AeroMC-Umhänge ----------
|
||||
// Hochladen/Entfernen nur angemeldet; Abrufen ist öffentlich (auch für den Mod)
|
||||
function uploadCape(uuid, image) {
|
||||
return anfrage('/cape', { method: 'POST', body: { uuid, image } });
|
||||
}
|
||||
|
||||
function removeCape(uuid) {
|
||||
return anfrage('/cape/remove', { method: 'POST', body: { uuid } });
|
||||
}
|
||||
|
||||
async function getCapes(uuids) {
|
||||
try {
|
||||
const liste = [...new Set((uuids || []).map((u) => String(u).replace(/-/g, '').toLowerCase()))].filter(Boolean);
|
||||
if (!liste.length) return { ok: true, capes: {} };
|
||||
const { res, daten } = await roheAnfrage('/capes', { method: 'GET', body: { uuids: liste.join(',') } });
|
||||
// ältere Plugin-Fassung ohne Umhänge
|
||||
if (res.status === 404) return { ok: false, fehlt: true, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
|
||||
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
|
||||
return { ok: true, capes: (daten && daten.capes) || {} };
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Umhang-Server nicht erreichbar: ' + err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BASIS_URL, uploadCape, removeCape, getCapes,
|
||||
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
|
||||
sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer,
|
||||
sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport,
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self';" />
|
||||
<title>AeroMC</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="stylesheet" href="designs.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Eigene Titelleiste (Fensterknöpfe zeichnet Windows rechts darüber) -->
|
||||
<div class="titelleiste">
|
||||
<img src="assets/logo.png" alt="" class="titel-logo" />
|
||||
<span class="titel-text">AeroMC</span>
|
||||
</div>
|
||||
|
||||
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
|
||||
<header class="toolbar">
|
||||
<button id="btn-new" class="tbtn" data-ic="plus" title="Neue Instanz (Strg+N)">Instanz hinzufügen</button>
|
||||
@@ -20,16 +27,18 @@
|
||||
<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>
|
||||
<span class="tbgrow"></span>
|
||||
<div class="search-wrap">
|
||||
<input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" />
|
||||
<div id="search-wrap" class="search-wrap">
|
||||
<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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
|
||||
<main class="workspace">
|
||||
<div class="list-area">
|
||||
<canvas id="skin-live" class="skin-live hidden"></canvas>
|
||||
<div id="grid" class="list"></div>
|
||||
<section id="empty" class="empty hidden">
|
||||
<h2>Noch keine Instanzen</h2>
|
||||
@@ -98,6 +107,7 @@
|
||||
<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" />
|
||||
<datalist id="group-list"></datalist>
|
||||
<small class="hint">Untergruppe: mit „/“ trennen, z. B. „Modpacks/Survival“.</small>
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -250,14 +260,7 @@
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Oberfläche</h5>
|
||||
<label class="field">
|
||||
<span>Design</span>
|
||||
<select id="s-theme">
|
||||
<option value="dark">Dunkel</option>
|
||||
<option value="light">Hell</option>
|
||||
</select>
|
||||
</label>
|
||||
<h5>Instanzliste</h5>
|
||||
<label class="field">
|
||||
<span>Instanzen sortieren</span>
|
||||
<select id="s-sortby">
|
||||
@@ -323,6 +326,37 @@
|
||||
<p>Optik der Oberfläche und visuelle Extras für die Instanzansicht.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Design</h5>
|
||||
<div class="field">
|
||||
<select id="s-theme" class="hidden">
|
||||
<option value="dark">Klassisch – Dunkel</option>
|
||||
<option value="light">Klassisch – Hell</option>
|
||||
<option value="aero">AeroMC</option>
|
||||
<option value="lila">Nachtlila</option>
|
||||
<option value="minecraft">Minecraft</option>
|
||||
</select>
|
||||
<div class="design-raster">
|
||||
<button type="button" class="design-wahl" data-design="dark" title="Der bewährte Launcher-Look in Dunkelgrau">
|
||||
<span class="dv dv-dark"><i></i><b></b><em></em></span><small>Klassisch Dunkel</small>
|
||||
</button>
|
||||
<button type="button" class="design-wahl" data-design="light" title="Der bewährte Launcher-Look in Hell">
|
||||
<span class="dv dv-light"><i></i><b></b><em></em></span><small>Klassisch Hell</small>
|
||||
</button>
|
||||
<button type="button" class="design-wahl" data-design="aero" title="Nachtblau mit Cyan-Akzenten – wie die AeroMC-Webseite">
|
||||
<span class="dv dv-aero"><i></i><b></b><em></em></span><small>AeroMC</small>
|
||||
</button>
|
||||
<button type="button" class="design-wahl" data-design="lila" title="Wie AeroMC, nur in dunklem Lila">
|
||||
<span class="dv dv-lila"><i></i><b></b><em></em></span><small>Nachtlila</small>
|
||||
</button>
|
||||
<button type="button" class="design-wahl" data-design="minecraft" title="Block-Optik wie im Spiel">
|
||||
<span class="dv dv-mc"><i></i><b></b><em></em></span><small>Minecraft</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Icons</h5>
|
||||
<label class="checkbox-field">
|
||||
@@ -330,6 +364,17 @@
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Animationen</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-start-animation" type="checkbox" /> <span>Startanimation beim Öffnen des Launchers</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-ui-animationen" type="checkbox" /> <span>Bewegte Oberfläche</span>
|
||||
</label>
|
||||
<small class="hint">Startanimation: Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (ab dem nächsten Start, nicht im Infobereich). Bewegte Oberfläche: Die Skin-Figur atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden ein, Designs blenden weich über.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Skin-Hintergrund</h5>
|
||||
<label class="field">
|
||||
@@ -350,6 +395,14 @@
|
||||
<option value="spooky">Unheimlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Armform</span>
|
||||
<select id="s-bgskin-model" title="Bei „Automatisch“ wird die Armform übernommen, die beim Konto eingestellt ist">
|
||||
<option value="auto">Automatisch (vom Konto)</option>
|
||||
<option value="classic">Normal (breite Arme, Steve)</option>
|
||||
<option value="slim">Schmal (Alex)</option>
|
||||
</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" />
|
||||
@@ -406,6 +459,14 @@
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Umhänge</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-aeromc-capes" type="checkbox" /> <span>AeroMC-Umhänge im Spiel anzeigen</span>
|
||||
</label>
|
||||
<small class="hint">Zeigt deinen eigenen Umhang und die anderer AeroMC-Spieler – in jeder Minecraft-Version und mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1210,9 +1271,30 @@
|
||||
</select>
|
||||
</label>
|
||||
<p id="skin-status" class="hint"></p>
|
||||
<div class="skin-umhang">
|
||||
<div class="skin-label">Umhang</div>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Offizieller Umhang <small class="opt">(im Spiel für alle sichtbar)</small></span>
|
||||
<select id="skin-cape-offiziell" title="Nur Umhänge, die dein Konto bei Mojang besitzt">
|
||||
<option value="">Kein Umhang</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field">
|
||||
<span>AeroMC-Umhang <small class="opt">(eigener, auch HD)</small></span>
|
||||
<div class="skin-umhang-aero">
|
||||
<span id="skin-cape-aero" class="hint">–</span>
|
||||
<button id="skin-cape-edit" class="btn btn-mini" title="Im Skin-Editor einen eigenen Umhang gestalten">Gestalten…</button>
|
||||
<button id="skin-cape-remove" class="btn btn-mini btn-danger hidden">Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small id="skin-cape-status" class="hint">Den AeroMC-Umhang sehen im Spiel alle, die AeroMC nutzen oder den Mod „AeroMC Umhänge“ (Fabric/Quilt) installiert haben – dort hat er Vorrang vor dem offiziellen.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
|
||||
<button id="skin-editor" class="btn" title="Skin selbst pixeln – mit Grundschicht und 2. Schicht">Im Editor bearbeiten…</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
<button id="skin-apply" class="btn btn-primary" disabled>Skin setzen</button>
|
||||
@@ -1220,6 +1302,115 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Skin-Editor ===================== -->
|
||||
<div id="modal-skineditor" class="modal hidden">
|
||||
<div class="modal-card se-karte">
|
||||
<div class="modal-head">
|
||||
<h3>Skin-Editor</h3>
|
||||
<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" 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>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body se-body">
|
||||
<aside class="se-werkzeuge">
|
||||
<div class="se-gruppe">
|
||||
<div class="se-titel">Werkzeug</div>
|
||||
<div class="se-tools">
|
||||
<button class="se-tool on" data-se-tool="stift" title="Stift (B)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L18 10l-4-4L4 16v4zM14 6l4 4"/></svg><span>Stift</span></button>
|
||||
<button class="se-tool" data-se-tool="radierer" title="Radierer (E)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 20h12M5.5 14.5l8-8a2 2 0 012.8 0l2.2 2.2a2 2 0 010 2.8L11 19H8.5l-3-3a1.1 1.1 0 010-1.5zM9 11l5 5"/></svg><span>Radierer</span></button>
|
||||
<button class="se-tool" data-se-tool="pipette" title="Pipette (I) – auch Alt+Klick"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 7l3 3M5 19l1-4 8.5-8.5a2.1 2.1 0 013 3L9 18l-4 1zM15.5 5.5l1.5-1.5a2.1 2.1 0 013 3l-1.5 1.5"/></svg><span>Pipette</span></button>
|
||||
<button class="se-tool" data-se-tool="fuellen" title="Füllen (G) – färbt gleichfarbige Pixel einer Seite"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11l7-7 7 7-7 7zM5 11h14M20 16s1.5 2 1.5 3a1.5 1.5 0 01-3 0c0-1 1.5-3 1.5-3z"/></svg><span>Füllen</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="se-gruppe">
|
||||
<div class="se-titel">Farbe</div>
|
||||
<div class="se-farbzeile">
|
||||
<input id="se-farbe" type="color" value="#3a6ea5" title="Farbe wählen" />
|
||||
<input id="se-hex" type="text" maxlength="7" spellcheck="false" value="#3a6ea5" />
|
||||
</div>
|
||||
<div id="se-zuletzt" class="se-zuletzt"></div>
|
||||
</div>
|
||||
<div id="se-gruppe-ebene" class="se-gruppe">
|
||||
<div class="se-titel">Ebene</div>
|
||||
<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" data-se-schicht="1" title="Hülle über der Figur – Haare, Jacke, Ärmel … (L wechselt)">2. Schicht</button>
|
||||
</div>
|
||||
<label class="se-check"><input id="se-aussen" type="checkbox" checked /> 2. Schicht zeigen</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>
|
||||
</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-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-feld"><span>Armform</span>
|
||||
<select id="se-variante">
|
||||
<option value="classic">Klassisch (Steve)</option>
|
||||
<option value="slim">Schmal (Alex)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="se-undo">
|
||||
<button id="se-undo" class="btn btn-mini" title="Rückgängig (Strg+Z)" disabled>↶ Rückgängig</button>
|
||||
<button id="se-redo" class="btn btn-mini" title="Wiederholen (Strg+Y)" disabled>↷ Wiederholen</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="se-mitte">
|
||||
<div id="se-buehne" class="se-buehne">
|
||||
<div id="se-2d" class="se-ansicht se-2d"><canvas id="se-2d-canvas"></canvas></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>
|
||||
</section>
|
||||
<aside class="se-seite">
|
||||
<div id="se-mini-titel" class="se-titel">3D-Vorschau</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-foot se-fuss">
|
||||
<button id="se-neu" class="btn" title="Mit einer leeren Vorlage beginnen">Neu</button>
|
||||
<button id="se-laden" class="btn" title="Den Skin deines angemeldeten Kontos laden">Aktuellen Skin laden</button>
|
||||
<button id="se-oeffnen" class="btn">PNG öffnen…</button>
|
||||
<button id="se-speichern" class="btn">Als PNG speichern…</button>
|
||||
<span id="se-status" class="hint se-status"></span>
|
||||
<button id="se-uebernehmen" class="btn btn-primary">Als Skin übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Logdateien ===================== -->
|
||||
<div id="modal-logs" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
@@ -1364,11 +1555,22 @@
|
||||
<button id="import-tab-modpack" class="mtab" data-kind="modpack">Modpack</button>
|
||||
<button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
|
||||
<button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
|
||||
<button id="import-tab-mr" class="mtab" data-kind="mr">Modrinth App</button>
|
||||
<button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
|
||||
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
|
||||
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
|
||||
</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">
|
||||
<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>
|
||||
@@ -1460,6 +1662,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-pane-mr" class="import-pane hidden">
|
||||
<label class="field">
|
||||
<span>Datenordner der Modrinth App</span>
|
||||
<div class="path-row">
|
||||
<input id="mr-path" type="text" placeholder="leer lassen = Standardort (%APPDATA%\ModrinthApp)" />
|
||||
<button id="mr-pick" class="btn">Wählen…</button>
|
||||
<button id="mr-scan" class="btn btn-primary">Suchen</button>
|
||||
</div>
|
||||
<small id="mr-status" class="hint">Leer lassen nutzt automatisch den Standardort der Modrinth App.</small>
|
||||
</label>
|
||||
|
||||
<div id="mr-result" class="hidden">
|
||||
<div class="mmc-bar">
|
||||
<label class="checkbox-field" style="margin:0">
|
||||
<input id="mr-all" type="checkbox" checked /> <span>Alle auswählen</span>
|
||||
</label>
|
||||
<label class="checkbox-field" style="margin:0">
|
||||
<input id="mr-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="mr-list" class="mods-list"></div>
|
||||
<p class="hint" style="margin-top:8px">Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen und die Spielzeit. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC weiter aktualisieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-pane-mmc" class="import-pane hidden">
|
||||
<label class="field">
|
||||
<span>MultiMC- oder Prism-Ordner</span>
|
||||
@@ -1540,6 +1767,7 @@
|
||||
<div id="mc-progress" class="hint hidden"></div>
|
||||
<div id="mmc-progress" class="hint hidden"></div>
|
||||
<div id="cf-progress" class="hint hidden"></div>
|
||||
<div id="mr-progress" class="hint hidden"></div>
|
||||
<div id="technic-progress" class="hint hidden"></div>
|
||||
<div id="code-progress" class="hint hidden"></div>
|
||||
<div class="spacer"></div>
|
||||
@@ -1548,6 +1776,7 @@
|
||||
<button id="import-modpack-do" class="btn btn-primary hidden">Modpack wählen…</button>
|
||||
<button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
|
||||
<button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
|
||||
<button id="mr-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
|
||||
<button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
|
||||
<button id="technic-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
|
||||
<button id="code-do" class="btn btn-primary hidden" disabled>Instanz anlegen</button>
|
||||
@@ -1622,6 +1851,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Konto-Menü (Klick auf den Kontoknopf oben rechts) -->
|
||||
<div id="konto-menu" class="ctx konto-menu hidden" role="menu"></div>
|
||||
|
||||
<div id="ctx-menu" class="ctx hidden">
|
||||
<button class="ctx-item" data-act="p-edit">Symbol ändern</button>
|
||||
<button class="ctx-item" data-act="p-edit">Umbenennen</button>
|
||||
@@ -1656,5 +1888,6 @@
|
||||
<canvas id="skin-canvas" width="420" height="700" style="display:none"></canvas>
|
||||
<script src="vendor/skinview3d.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
<script src="skineditor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 };
|
||||
@@ -18,6 +18,8 @@ let ffmpegPfad = null;
|
||||
try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
|
||||
const pkg = require('../package.json');
|
||||
const store = require('./store');
|
||||
const startanimation = require('./startanimation');
|
||||
const fensteraufbau = require('./fensteraufbau');
|
||||
const services = require('./services');
|
||||
const launcher = require('./launcher');
|
||||
const loaders = require('./loaders');
|
||||
@@ -28,7 +30,9 @@ const icons = require('./icons');
|
||||
const bedrock = require('./bedrock');
|
||||
const backup = require('./backup');
|
||||
const mmcimport = require('./mmcimport');
|
||||
const kopieren = require('./kopieren');
|
||||
const cfimport = require('./cfimport');
|
||||
const mrimport = require('./mrimport');
|
||||
const mcimport = require('./mcimport');
|
||||
const technicimport = require('./technicimport');
|
||||
const technicdiscover = require('./technicdiscover');
|
||||
@@ -112,6 +116,7 @@ const POPOUT_SPEC = {
|
||||
screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 },
|
||||
servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 },
|
||||
skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 },
|
||||
skineditor: { title: 'AeroMC – Skin-Editor', width: 1180, height: 800, minWidth: 920, minHeight: 600 },
|
||||
bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 },
|
||||
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
|
||||
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
|
||||
@@ -152,14 +157,18 @@ function broadcast(channel, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function openPopout(modalId, params) {
|
||||
// von: Rechteck des angeklickten Knopfs (Bildschirmkoordinaten) – dann wächst
|
||||
// das Fenster daraus hervor (Optionen → Darstellung → Animationen)
|
||||
function openPopout(modalId, params, von) {
|
||||
const wachsen = !!von && store.getSettings().uiAnimationen !== false;
|
||||
let win = popoutWindows.get(modalId);
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
// erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
|
||||
// laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
|
||||
win.webContents.send('popout:reopen', params || {});
|
||||
if (wachsen && !win.isVisible()) { fensteraufbau.wachsen(win, von); return; }
|
||||
win.show();
|
||||
win.focus();
|
||||
return;
|
||||
}
|
||||
const query = { popout: modalId };
|
||||
@@ -171,7 +180,7 @@ function openPopout(modalId, params) {
|
||||
minWidth: spec.minWidth,
|
||||
minHeight: spec.minHeight,
|
||||
title: spec.title || POPOUT_DEFAULT.title,
|
||||
backgroundColor: '#1b1e24',
|
||||
backgroundColor: fensterFarbe(),
|
||||
icon: windowIcon(),
|
||||
frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste
|
||||
show: false,
|
||||
@@ -187,7 +196,7 @@ function openPopout(modalId, params) {
|
||||
});
|
||||
popoutWindows.set(modalId, win);
|
||||
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) => {
|
||||
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
|
||||
});
|
||||
@@ -210,7 +219,24 @@ let recordingGuardActive = false;
|
||||
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
|
||||
|
||||
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,
|
||||
// die Popup-Fenster haben ihren eigenen Kopf)
|
||||
ipcMain.on('window:titleBar', (e, design) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || e.sender !== mainWindow.webContents) return;
|
||||
try { mainWindow.setTitleBarOverlay(titelleistenFarben(design)); } catch { /* ältere Windows-Fassung */ }
|
||||
});
|
||||
ipcMain.on('popout:hide', (e) => {
|
||||
const w = BrowserWindow.fromWebContents(e.sender);
|
||||
if (w && !w.isDestroyed()) w.hide();
|
||||
@@ -714,6 +740,28 @@ function verknuepfungsIcon(ziel) {
|
||||
return windowIcon() || ziel;
|
||||
}
|
||||
|
||||
// Eigene Titelleiste im Hauptfenster: Minimieren/Maximieren/Schließen bleiben
|
||||
// echte Windows-Knöpfe (Andocken, Fensterlayouts …), Windows zeichnet sie aber
|
||||
// in den Farben des Designs. Die Farben müssen zu --titel-bg/--titel-fg in
|
||||
// styles.css bzw. designs.css passen.
|
||||
const TITELLEISTE = {
|
||||
dark: { color: '#2d2d30', symbolColor: '#e4e4e6' },
|
||||
light: { color: '#f0f0f0', symbolColor: '#1c1c1c' },
|
||||
aero: { color: '#0a0e1a', symbolColor: '#e8ecfb' },
|
||||
lila: { color: '#0f0a1a', symbolColor: '#f0eafb' },
|
||||
minecraft: { color: '#1e1e1f', symbolColor: '#ffffff' },
|
||||
};
|
||||
function titelleistenFarben(design) {
|
||||
return Object.assign({ height: 32 }, TITELLEISTE[design] || TITELLEISTE.dark);
|
||||
}
|
||||
|
||||
// Grundfarbe eines neuen Fensters passend zum Design – sonst blitzt beim
|
||||
// Öffnen kurz die falsche Farbe auf, bis die Oberfläche gezeichnet ist
|
||||
function fensterFarbe() {
|
||||
const design = (store.getSettings() || {}).theme;
|
||||
return { light: '#f0f0f0', aero: '#05070d', lila: '#08050f', minecraft: '#313233' }[design] || '#1b1e24';
|
||||
}
|
||||
|
||||
function windowIcon() {
|
||||
const png = path.join(__dirname, 'assets', 'logo.png');
|
||||
const ico = path.join(__dirname, 'assets', 'icon.ico');
|
||||
@@ -722,16 +770,18 @@ function windowIcon() {
|
||||
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
|
||||
}
|
||||
|
||||
function createWindow(startHidden) {
|
||||
function createWindow(startHidden, mitStartanimation) {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 820,
|
||||
minWidth: 940,
|
||||
minHeight: 640,
|
||||
backgroundColor: '#1b1e24',
|
||||
backgroundColor: fensterFarbe(),
|
||||
title: 'AeroMC',
|
||||
icon: windowIcon(),
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: titelleistenFarben((store.getSettings() || {}).theme),
|
||||
show: !startHidden,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
@@ -741,7 +791,7 @@ function createWindow(startHidden) {
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, 'index.html'));
|
||||
mainWindow.loadFile(path.join(__dirname, 'index.html'), mitStartanimation ? { query: { startanimation: '1' } } : undefined);
|
||||
|
||||
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
|
||||
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
|
||||
@@ -800,7 +850,7 @@ function oeffneVorbereitetenImport() {
|
||||
try {
|
||||
const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, ''));
|
||||
quellen = (Array.isArray(daten.sources) ? daten.sources : [])
|
||||
.filter((q) => q && ['mc', 'cf', 'mmc', 'technic'].includes(q.kind))
|
||||
.filter((q) => q && ['mc', 'cf', 'mr', 'mmc', 'technic'].includes(q.kind))
|
||||
.map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' }));
|
||||
} catch { /* beschädigt -> verwerfen */ }
|
||||
try { fs.unlinkSync(datei); } catch { /* egal */ }
|
||||
@@ -907,8 +957,13 @@ app.whenReady().then(() => {
|
||||
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
|
||||
// von Hand an der Registry gedreht wurde oder nach einem Update)
|
||||
applyAutostart(autostartAn);
|
||||
// Reste von im Hintergrund gelöschten Instanzen wegräumen (siehe store.entsorgeOrdner)
|
||||
store.raeumePapierkorbAuf();
|
||||
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:
|
||||
// das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
|
||||
@@ -1028,9 +1083,8 @@ ipcMain.handle('settings:save', (_e, patch) => {
|
||||
// (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das
|
||||
// Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert.
|
||||
const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId');
|
||||
if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('settings:changed', s);
|
||||
}
|
||||
// an alle Fenster: das Design muss auch in offenen Popup-Fenstern wechseln
|
||||
if (!nurLetzteInstanz) broadcast('settings:changed', s);
|
||||
return s;
|
||||
});
|
||||
|
||||
@@ -1733,10 +1787,49 @@ async function aktivesToken() {
|
||||
return acc && acc.mcAccessToken ? acc.mcAccessToken : null;
|
||||
}
|
||||
|
||||
// Armform (breit/schmal) eines beliebigen Kontos – steht in den Textur-Angaben
|
||||
// des öffentlichen Profils bei Mojang (SKIN.metadata.model = "slim").
|
||||
// Ergebnis 10 Minuten merken, nach eigenen Skin-Änderungen sofort verwerfen.
|
||||
const armformCache = new Map(); // uuid -> { modell, zeit }
|
||||
ipcMain.handle('skin:modelFor', async (_e, uuid) => {
|
||||
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(id)) return 'classic';
|
||||
const gemerkt = armformCache.get(id);
|
||||
if (gemerkt && Date.now() - gemerkt.zeit < 10 * 60 * 1000) return gemerkt.modell;
|
||||
try {
|
||||
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||
if (!res.ok) return gemerkt ? gemerkt.modell : 'classic';
|
||||
const profil = await res.json();
|
||||
const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures');
|
||||
const texturen = eigenschaft ? JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')) : {};
|
||||
const skin = texturen.textures && texturen.textures.SKIN;
|
||||
const modell = skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic';
|
||||
armformCache.set(id, { modell, zeit: Date.now() });
|
||||
return modell;
|
||||
} catch {
|
||||
return gemerkt ? gemerkt.modell : 'classic';
|
||||
}
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
const token = await aktivesToken();
|
||||
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 }; }
|
||||
});
|
||||
|
||||
@@ -1764,7 +1857,7 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
try {
|
||||
const res = await skins.setzeSkin(token, dateiPfad, variante);
|
||||
if (res && res.ok) broadcast('skin:changed');
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
|
||||
return res;
|
||||
}
|
||||
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
|
||||
@@ -1775,11 +1868,248 @@ ipcMain.handle('skin:reset', async () => {
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
try {
|
||||
const res = await skins.setzeSkinZurueck(token);
|
||||
if (res && res.ok) broadcast('skin:changed');
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
|
||||
return res;
|
||||
}
|
||||
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
|
||||
});
|
||||
|
||||
// ---------- Skin-Editor ----------
|
||||
// Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net)
|
||||
// würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt
|
||||
// der Hauptprozess die Datei und reicht sie als data:-Adresse weiter.
|
||||
const PNG_KENNUNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
function skinPngAusBase64(base64) {
|
||||
const daten = Buffer.from(String(base64 || ''), 'base64');
|
||||
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
|
||||
if (daten.readUInt32BE(16) !== 64 || daten.readUInt32BE(20) !== 64) return null;
|
||||
return daten;
|
||||
}
|
||||
|
||||
ipcMain.handle('skin:currentData', async () => {
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
try {
|
||||
const p = await profilMitSpielSkin(token);
|
||||
if (!p.ok) return p;
|
||||
if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' };
|
||||
const res = await fetch(p.skinUrl);
|
||||
if (!res.ok) return { ok: false, message: 'Skin-Datei nicht abrufbar (HTTP ' + res.status + ').' };
|
||||
const daten = Buffer.from(await res.arrayBuffer());
|
||||
return { ok: true, name: p.name, variante: p.variante, dataUri: 'data:image/png;base64,' + daten.toString('base64') };
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Skin konnte nicht geladen werden: ' + err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Umhänge (offiziell von Mojang + eigene AeroMC-Umhänge) ----------
|
||||
// Bilder immer als data:-Adresse in die Oberfläche (fremde Server schicken
|
||||
// keine CORS-Kopfzeilen – die 3D-Ansicht dürfte sie sonst nicht laden)
|
||||
async function bildAlsDataUri(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const daten = Buffer.from(await res.arrayBuffer());
|
||||
if (daten.length > 6 * 1024 * 1024) throw new Error('Bild zu groß');
|
||||
return 'data:image/png;base64,' + daten.toString('base64');
|
||||
}
|
||||
|
||||
// PNG mit Seitenverhältnis 2:1 (64×32 … 1024×512) – wie der Server es verlangt
|
||||
function umhangPngAusBase64(base64) {
|
||||
const daten = Buffer.from(String(base64 || ''), 'base64');
|
||||
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
|
||||
const breite = daten.readUInt32BE(16);
|
||||
const hoehe = daten.readUInt32BE(20);
|
||||
if (![64, 128, 256, 512, 1024].includes(breite) || breite !== 2 * hoehe) return null;
|
||||
return daten;
|
||||
}
|
||||
|
||||
// Umhang eines beliebigen Spielers fürs Anzeigen: eigener AeroMC-Umhang hat
|
||||
// Vorrang, sonst der offizielle aus Mojangs Profil. 5 Minuten gemerkt.
|
||||
const umhangCache = new Map(); // uuid -> { dataUri, quelle, zeit }
|
||||
async function umhangFuer(uuid) {
|
||||
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(id)) return null;
|
||||
const gemerkt = umhangCache.get(id);
|
||||
if (gemerkt && Date.now() - gemerkt.zeit < 5 * 60 * 1000) return gemerkt;
|
||||
let ergebnis = { dataUri: null, quelle: null, zeit: Date.now() };
|
||||
try {
|
||||
const aero = await friends.getCapes([id]);
|
||||
const eintrag = aero.ok && aero.capes && aero.capes[id];
|
||||
if (eintrag && eintrag.url) ergebnis = { dataUri: await bildAlsDataUri(eintrag.url), quelle: 'aeromc', zeit: Date.now() };
|
||||
} catch { /* weiter mit Mojang */ }
|
||||
if (!ergebnis.dataUri) {
|
||||
try {
|
||||
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||
if (res.ok) {
|
||||
const profil = await res.json();
|
||||
const eig = (profil.properties || []).find((p) => p.name === 'textures');
|
||||
const tex = eig ? JSON.parse(Buffer.from(eig.value, 'base64').toString('utf8')) : {};
|
||||
const cape = tex.textures && tex.textures.CAPE;
|
||||
if (cape && cape.url) ergebnis = { dataUri: await bildAlsDataUri(skins.aufHttps(cape.url)), quelle: 'mojang', zeit: Date.now() };
|
||||
}
|
||||
} catch { /* kein Umhang */ }
|
||||
}
|
||||
umhangCache.set(id, ergebnis);
|
||||
return ergebnis;
|
||||
}
|
||||
ipcMain.handle('cape:forUuid', async (_e, uuid) => {
|
||||
const r = await umhangFuer(uuid);
|
||||
return r && r.dataUri ? { dataUri: r.dataUri, quelle: r.quelle } : null;
|
||||
});
|
||||
|
||||
// Übersicht fürs Skin-Fenster: offizielle Umhänge + eigener AeroMC-Umhang
|
||||
ipcMain.handle('cape:info', async () => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
const token = await aktivesToken();
|
||||
if (!konto || !token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
const ergebnis = { ok: true, name: konto.username, offiziell: [], aeromc: null, aeromcFehler: '' };
|
||||
try {
|
||||
const p = await skins.holeProfil(token);
|
||||
if (p.ok) ergebnis.offiziell = p.capes || [];
|
||||
} catch { /* ohne offizielle Liste weiter */ }
|
||||
const aero = await friends.getCapes([konto.uuid]);
|
||||
if (aero.ok) ergebnis.aeromc = aero.capes[String(konto.uuid).replace(/-/g, '').toLowerCase()] || null;
|
||||
else { ergebnis.aeromcFehler = aero.message; ergebnis.aeromcFehlt = !!aero.fehlt; }
|
||||
return ergebnis;
|
||||
});
|
||||
|
||||
ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
try {
|
||||
const res = await skins.setzeUmhang(token, capeId || null);
|
||||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||||
return res;
|
||||
} catch (err) { return { ok: false, message: 'Umhang konnte nicht gesetzt werden: ' + err.message }; }
|
||||
});
|
||||
|
||||
// Der Umhang-Agent muss als echte Datei vorliegen (Java liest nicht aus app.asar):
|
||||
// einmal nach userData kopieren, Dateiname mit Größe, damit ein Update ihn ersetzt.
|
||||
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
|
||||
function umhangAgent() {
|
||||
try {
|
||||
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
|
||||
const groesse = fs.statSync(quelle).size;
|
||||
const ordner = path.join(app.getPath('userData'), 'agent');
|
||||
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
|
||||
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
fs.writeFileSync(ziel, fs.readFileSync(quelle));
|
||||
for (const alt of fs.readdirSync(ordner)) {
|
||||
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
|
||||
}
|
||||
}
|
||||
return `${ziel}=${friends.BASIS_URL}`;
|
||||
} catch (err) {
|
||||
console.error('Umhang-Agent nicht verfügbar:', err.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
|
||||
function umhangFehler(res) {
|
||||
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
|
||||
return res;
|
||||
}
|
||||
|
||||
ipcMain.handle('cape:upload', async (_e, base64) => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' };
|
||||
const res = await friends.uploadCape(konto.uuid, base64);
|
||||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||||
return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res);
|
||||
});
|
||||
|
||||
ipcMain.handle('cape:remove', async () => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
const res = await friends.removeCape(konto.uuid);
|
||||
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
|
||||
return res.ok ? res : umhangFehler(res);
|
||||
});
|
||||
|
||||
// für den Editor: eigener Umhang, sonst der getragene offizielle
|
||||
ipcMain.handle('cape:currentData', async () => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
umhangCache.delete(String(konto.uuid).replace(/-/g, '').toLowerCase());
|
||||
const r = await umhangFuer(konto.uuid);
|
||||
if (!r || !r.dataUri) return { ok: false, keiner: true, message: 'Dein Konto hat noch keinen Umhang.' };
|
||||
return { ok: true, dataUri: r.dataUri, quelle: r.quelle };
|
||||
});
|
||||
|
||||
// Bild wählen: art 'umhang' = fertige Umhang-Datei (PNG), 'bild' = beliebiges Motiv zum Einsetzen
|
||||
ipcMain.handle('cape:chooseFile', async (e, art) => {
|
||||
const motiv = art === 'bild';
|
||||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||||
title: motiv ? 'Bild für den Umhang wählen' : 'Umhang-Datei öffnen',
|
||||
properties: ['openFile'],
|
||||
filters: motiv
|
||||
? [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] }]
|
||||
: [{ name: 'PNG-Bild', extensions: ['png'] }],
|
||||
});
|
||||
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
|
||||
const pfad = res.filePaths[0];
|
||||
try {
|
||||
const st = fs.statSync(pfad);
|
||||
if (st.size > 15 * 1024 * 1024) return { ok: false, message: 'Die Datei ist zu groß (über 15 MB).' };
|
||||
const endung = path.extname(pfad).slice(1).toLowerCase().replace('jpg', 'jpeg');
|
||||
return { ok: true, pfad, dataUri: `data:image/${endung || 'png'};base64,` + fs.readFileSync(pfad).toString('base64') };
|
||||
} catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('cape:saveData', async (e, base64) => {
|
||||
const daten = umhangPngAusBase64(base64);
|
||||
if (!daten) return { ok: false, message: 'Ungültige Umhang-Daten.' };
|
||||
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||||
title: 'Umhang speichern',
|
||||
defaultPath: path.join(app.getPath('pictures'), 'umhang.png'),
|
||||
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
|
||||
});
|
||||
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
|
||||
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
|
||||
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('skin:readData', (_e, dateiPfad) => {
|
||||
const geprueft = skins.pruefeDatei(dateiPfad);
|
||||
if (!geprueft.ok) return geprueft;
|
||||
try { return { ok: true, dataUri: 'data:image/png;base64,' + fs.readFileSync(dateiPfad).toString('base64') }; }
|
||||
catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('skin:saveData', async (e, base64) => {
|
||||
const daten = skinPngAusBase64(base64);
|
||||
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
|
||||
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||||
title: 'Skin speichern',
|
||||
defaultPath: path.join(app.getPath('pictures'), 'skin.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:applyData', async (_e, base64, variante) => {
|
||||
const daten = skinPngAusBase64(base64);
|
||||
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
|
||||
const token = await aktivesToken();
|
||||
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
|
||||
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png');
|
||||
try {
|
||||
fs.writeFileSync(tmp, daten);
|
||||
const res = await skins.setzeSkin(token, tmp, variante);
|
||||
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
|
||||
return res;
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp); } catch { /* egal */ }
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
|
||||
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
|
||||
|
||||
@@ -2353,18 +2683,48 @@ function aktivesFreundeKonto() {
|
||||
|
||||
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 () => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||||
const res = await friends.getFriends(konto.uuid);
|
||||
return res.ok ? { ok: true, list: res.data || [] } : res;
|
||||
return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
|
||||
});
|
||||
|
||||
ipcMain.handle('friends:requests', async () => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||||
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) => {
|
||||
@@ -2378,7 +2738,7 @@ ipcMain.handle('friends:sentRequests', async () => {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
|
||||
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) => {
|
||||
@@ -2451,7 +2811,12 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
|
||||
|
||||
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) => {
|
||||
const p = store.instancePath(id);
|
||||
@@ -3480,6 +3845,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||||
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
|
||||
|
||||
const opts = {
|
||||
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(),
|
||||
versionId: inst.minecraft.version,
|
||||
loader: inst.minecraft.loader || 'vanilla',
|
||||
loaderVersion: inst.minecraft.loaderVersion || '',
|
||||
@@ -3689,20 +4055,91 @@ ipcMain.handle('mmc:scan', (_e, folder) => {
|
||||
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 failed = [];
|
||||
for (const entry of entries || []) {
|
||||
try {
|
||||
const imported = mmcimport.importOne(store, entry, copyData !== false);
|
||||
done.push(imported);
|
||||
const melde = (daten) => broadcast('import:fortschritt', daten);
|
||||
let fortschritt = null;
|
||||
try {
|
||||
// 1) Was wird kopiert und wie viel?
|
||||
const plaene = liste.map((entry) => ({ entry, quellen: copyData ? modul.kopierQuellen(entry) : [] }));
|
||||
let gesamt = { bytes: 0, dateien: 0 };
|
||||
if (copyData) {
|
||||
melde({ phase: 'zaehlen', dateien: 0, bytes: 0, instanzen: liste.length });
|
||||
try {
|
||||
gesamt = await kopieren.zaehle(plaene.flatMap((p) => p.quellen.map((q) => q.von)), job,
|
||||
(s) => melde({ phase: 'zaehlen', dateien: s.dateien, bytes: s.bytes, instanzen: liste.length }));
|
||||
} catch (err) {
|
||||
if (err.abgebrochen) return { ok: true, imported: [], failed: [], abgebrochen: true, uebersprungen: [] };
|
||||
throw err;
|
||||
}
|
||||
// 2) Reicht der Platz? (etwas Luft für Windows lassen)
|
||||
const frei = await kopieren.freierPlatz(store.getInstancesDir());
|
||||
if (frei !== null && gesamt.bytes > frei - 512 * 1024 * 1024) {
|
||||
return { ok: false, message: `Nicht genug Speicherplatz: Die Instanzen brauchen ${zuGb(gesamt.bytes)} GB, auf dem Laufwerk sind nur ${zuGb(frei)} GB frei. Weniger Instanzen auswählen oder unter Optionen → Allgemein einen anderen Instanz-Ordner wählen.` };
|
||||
}
|
||||
}
|
||||
catch (err) { failed.push({ name: entry.name, message: err.message }); }
|
||||
broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length });
|
||||
// 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');
|
||||
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) => {
|
||||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||||
@@ -3717,21 +4154,75 @@ ipcMain.handle('cf:scan', (_e, folder) => {
|
||||
catch (err) { return { ok: false, reason: err.message }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('cf:import', (_e, entries, copyData) => {
|
||||
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 };
|
||||
ipcMain.handle('cf:import', (_e, entries, copyData) => fuehreImportAus(cfimport, entries, copyData !== false));
|
||||
|
||||
// ---------- Modrinth App ----------
|
||||
ipcMain.handle('mr:pickFolder', async (e) => {
|
||||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||||
title: 'Datenordner der Modrinth App wählen (meist %APPDATA%\\ModrinthApp)',
|
||||
defaultPath: mrimport.standardOrdner(),
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
return res.canceled ? null : res.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle('mr:scan', async (_e, folder) => {
|
||||
try { return await mrimport.scan(folder); }
|
||||
catch (err) { return { ok: false, reason: err.message }; }
|
||||
});
|
||||
|
||||
/*
|
||||
* War die Instanz in der Modrinth App mit einem Modrinth-Modpack verknüpft,
|
||||
* übernehmen wir die Verknüpfung – samt der Liste der Dateien, die zum Paket
|
||||
* gehören. Ohne diese Liste würde "Modpack aktualisieren" alte Mods liegen
|
||||
* lassen; deshalb wird die Paketdatei einmal geladen und nur ihr Inhaltsverzeichnis
|
||||
* gelesen. Klappt das nicht (offline …), bleibt die Instanz ohne Verknüpfung.
|
||||
*/
|
||||
async function verknuepfeModrinthModpack(instId, link) {
|
||||
const kopf = { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } };
|
||||
const vres = await fetch(`https://api.modrinth.com/v2/version/${encodeURIComponent(link.versionId)}`, kopf);
|
||||
if (!vres.ok) return false;
|
||||
const version = await vres.json();
|
||||
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
|
||||
if (!datei || !datei.url) return false;
|
||||
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||
if (!dl.ok) return false;
|
||||
const buf = Buffer.from(await dl.arrayBuffer());
|
||||
const zip = forge.readZipIndex(buf);
|
||||
if (!zip['modrinth.index.json']) return false;
|
||||
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^\uFEFF/, ''));
|
||||
const dateien = (index.files || [])
|
||||
.filter((f) => f.path && !(f.env && f.env.client === 'unsupported'))
|
||||
.map((f) => String(f.path).replace(/\\/g, '/'));
|
||||
for (const prefix of ['overrides/', 'client-overrides/']) {
|
||||
for (const name of Object.keys(zip)) if (name.startsWith(prefix) && !name.endsWith('/')) dateien.push(name.slice(prefix.length));
|
||||
}
|
||||
let titel = index.name || '';
|
||||
try {
|
||||
const pres = await fetch(`https://api.modrinth.com/v2/project/${encodeURIComponent(link.projectId)}`, kopf);
|
||||
if (pres.ok) titel = (await pres.json()).title || titel;
|
||||
} catch { /* Titel ist nur Zierde */ }
|
||||
store.updateInstance(instId, {
|
||||
modpack: {
|
||||
quelle: 'modrinth',
|
||||
name: index.name || titel,
|
||||
title: titel,
|
||||
projectId: link.projectId,
|
||||
versionId: link.versionId,
|
||||
versionNummer: version.version_number || '',
|
||||
dateien,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
ipcMain.handle('mr:import', (_e, entries, copyData) => fuehreImportAus(mrimport, entries, copyData !== false, async (entry, imported, kopf) => {
|
||||
if (!(entry.modpack && entry.modpack.projectId && entry.modpack.versionId)) return;
|
||||
broadcast('import:fortschritt', Object.assign({}, kopf, { phase: 'modpack' }));
|
||||
try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
|
||||
catch { imported.modpack = false; }
|
||||
}));
|
||||
|
||||
ipcMain.handle('mc:pickFolder', async (e) => {
|
||||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||||
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
|
||||
@@ -3745,20 +4236,7 @@ ipcMain.handle('mc:scan', (_e, folder) => {
|
||||
catch (err) { return { ok: false, reason: err.message }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('mc:import', (_e, entries, copyData) => {
|
||||
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('mc:import', (_e, entries, copyData) => fuehreImportAus(mcimport, entries, copyData !== false));
|
||||
|
||||
ipcMain.handle('technic:pickFolder', async (e) => {
|
||||
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
|
||||
@@ -3773,20 +4251,7 @@ ipcMain.handle('technic:scan', (_e, folder) => {
|
||||
catch (err) { return { ok: false, reason: err.message }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('technic:import', (_e, entries, copyData) => {
|
||||
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 };
|
||||
});
|
||||
ipcMain.handle('technic:import', (_e, entries, copyData) => fuehreImportAus(technicimport, entries, copyData !== false));
|
||||
|
||||
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
|
||||
|
||||
|
||||
@@ -173,16 +173,31 @@ function scan(inputPath) {
|
||||
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) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
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;
|
||||
for (const name of kopierNamen(sourceDir, usesDefaultGameDir)) {
|
||||
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) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
@@ -214,4 +229,4 @@ function importOne(store, entry, copyData = true) {
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
|
||||
module.exports = { scan, importOne, kopierQuellen, parseLastVersionId, resolveLauncherRoot };
|
||||
@@ -1,188 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
|
||||
* ------------------------------------------------------
|
||||
* Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
|
||||
* Ausgelesen werden:
|
||||
* instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
|
||||
* mmc-pack.json – Minecraft-Version und Mod-Loader
|
||||
* .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// uid der Loader in mmc-pack.json -> unsere Bezeichnung
|
||||
const LOADER_UIDS = {
|
||||
'net.fabricmc.fabric-loader': 'fabric',
|
||||
'org.quiltmc.quilt-loader': 'quilt',
|
||||
'net.minecraftforge': 'forge',
|
||||
'net.neoforged': 'neoforge',
|
||||
};
|
||||
|
||||
function parseCfg(file) {
|
||||
const out = {};
|
||||
try {
|
||||
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const i = line.indexOf('=');
|
||||
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
} catch { /* Datei fehlt */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Gruppen stehen zentral in instances/instgroups.json
|
||||
function readGroups(instancesDir) {
|
||||
const map = {};
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
|
||||
for (const [group, data] of Object.entries(raw.groups || {})) {
|
||||
for (const inst of data.instances || []) map[inst] = group;
|
||||
}
|
||||
} catch { /* keine Gruppen hinterlegt */ }
|
||||
return map;
|
||||
}
|
||||
|
||||
function readPack(dir) {
|
||||
const res = { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||
try {
|
||||
const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
|
||||
for (const c of pack.components || []) {
|
||||
if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
|
||||
const loader = LOADER_UIDS[c.uid];
|
||||
if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
|
||||
}
|
||||
} catch { /* alte Instanz ohne mmc-pack.json */ }
|
||||
return res;
|
||||
}
|
||||
|
||||
// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
|
||||
function resolveInstancesDir(inputPath) {
|
||||
if (!inputPath) return null;
|
||||
const direct = path.join(inputPath, 'instances');
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
|
||||
return fs.existsSync(inputPath) ? inputPath : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
|
||||
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
|
||||
*/
|
||||
function readInstance(dir, folderName, group) {
|
||||
const cfgFile = path.join(dir, 'instance.cfg');
|
||||
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
|
||||
|
||||
const cfg = parseCfg(cfgFile);
|
||||
const pack = readPack(dir);
|
||||
// MultiMC nutzt .minecraft, Prism minecraft
|
||||
const gameDir = ['.minecraft', 'minecraft']
|
||||
.map((n) => path.join(dir, n))
|
||||
.find((p) => fs.existsSync(p)) || null;
|
||||
|
||||
return {
|
||||
folder: folderName,
|
||||
dir,
|
||||
name: cfg.name || folderName,
|
||||
group: group || '',
|
||||
notes: cfg.notes || '',
|
||||
version: pack.version || cfg.IntendedVersion || '',
|
||||
loader: pack.loader,
|
||||
loaderVersion: pack.loaderVersion,
|
||||
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
|
||||
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
|
||||
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
|
||||
gameDir,
|
||||
hasGameData: !!gameDir,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
|
||||
*/
|
||||
function scan(inputPath) {
|
||||
const instancesDir = resolveInstancesDir(inputPath);
|
||||
if (!instancesDir) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const groups = readGroups(instancesDir);
|
||||
const list = [];
|
||||
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
|
||||
if (inst) list.push(inst);
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, instancesDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
/*
|
||||
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
|
||||
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
|
||||
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
|
||||
*/
|
||||
function findInstanceDir(root, tiefe = 2) {
|
||||
if (!root || !fs.existsSync(root)) return null;
|
||||
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
|
||||
if (tiefe <= 0) return null;
|
||||
let eintraege = [];
|
||||
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
|
||||
for (const e of eintraege) {
|
||||
if (!e.isDirectory()) continue;
|
||||
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
|
||||
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
|
||||
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
|
||||
if (treffer) return treffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
|
||||
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
|
||||
*/
|
||||
function scanSingle(root) {
|
||||
const dir = findInstanceDir(root);
|
||||
if (!dir) return { ok: false, reason: 'not-found' };
|
||||
const inst = readInstance(dir, path.basename(dir), '');
|
||||
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
|
||||
}
|
||||
|
||||
/*
|
||||
* Übernimmt eine Instanz in den Launcher.
|
||||
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
|
||||
*/
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
const patch = {};
|
||||
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
|
||||
patch.java = {
|
||||
path: entry.javaPath || '',
|
||||
minMemMb: entry.minMemMb || null,
|
||||
maxMemMb: entry.maxMemMb || null,
|
||||
extraArgs: '',
|
||||
};
|
||||
}
|
||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
try {
|
||||
fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
|
||||
copied = true;
|
||||
} catch { /* Spieldaten konnten nicht kopiert werden */ }
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
|
||||
* ------------------------------------------------------
|
||||
* Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
|
||||
* Ausgelesen werden:
|
||||
* instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
|
||||
* mmc-pack.json – Minecraft-Version und Mod-Loader
|
||||
* .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// uid der Loader in mmc-pack.json -> unsere Bezeichnung
|
||||
const LOADER_UIDS = {
|
||||
'net.fabricmc.fabric-loader': 'fabric',
|
||||
'org.quiltmc.quilt-loader': 'quilt',
|
||||
'net.minecraftforge': 'forge',
|
||||
'net.neoforged': 'neoforge',
|
||||
};
|
||||
|
||||
function parseCfg(file) {
|
||||
const out = {};
|
||||
try {
|
||||
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const i = line.indexOf('=');
|
||||
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
} catch { /* Datei fehlt */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Gruppen stehen zentral in instances/instgroups.json
|
||||
function readGroups(instancesDir) {
|
||||
const map = {};
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
|
||||
for (const [group, data] of Object.entries(raw.groups || {})) {
|
||||
for (const inst of data.instances || []) map[inst] = group;
|
||||
}
|
||||
} catch { /* keine Gruppen hinterlegt */ }
|
||||
return map;
|
||||
}
|
||||
|
||||
function readPack(dir) {
|
||||
const res = { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||
try {
|
||||
const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
|
||||
for (const c of pack.components || []) {
|
||||
if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
|
||||
const loader = LOADER_UIDS[c.uid];
|
||||
if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
|
||||
}
|
||||
} catch { /* alte Instanz ohne mmc-pack.json */ }
|
||||
return res;
|
||||
}
|
||||
|
||||
// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
|
||||
function resolveInstancesDir(inputPath) {
|
||||
if (!inputPath) return null;
|
||||
const direct = path.join(inputPath, 'instances');
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
|
||||
return fs.existsSync(inputPath) ? inputPath : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
|
||||
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
|
||||
*/
|
||||
function readInstance(dir, folderName, group) {
|
||||
const cfgFile = path.join(dir, 'instance.cfg');
|
||||
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
|
||||
|
||||
const cfg = parseCfg(cfgFile);
|
||||
const pack = readPack(dir);
|
||||
// MultiMC nutzt .minecraft, Prism minecraft
|
||||
const gameDir = ['.minecraft', 'minecraft']
|
||||
.map((n) => path.join(dir, n))
|
||||
.find((p) => fs.existsSync(p)) || null;
|
||||
|
||||
return {
|
||||
folder: folderName,
|
||||
dir,
|
||||
name: cfg.name || folderName,
|
||||
group: group || '',
|
||||
notes: cfg.notes || '',
|
||||
version: pack.version || cfg.IntendedVersion || '',
|
||||
loader: pack.loader,
|
||||
loaderVersion: pack.loaderVersion,
|
||||
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
|
||||
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
|
||||
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
|
||||
gameDir,
|
||||
hasGameData: !!gameDir,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
|
||||
*/
|
||||
function scan(inputPath) {
|
||||
const instancesDir = resolveInstancesDir(inputPath);
|
||||
if (!instancesDir) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const groups = readGroups(instancesDir);
|
||||
const list = [];
|
||||
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
|
||||
if (inst) list.push(inst);
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, instancesDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
/*
|
||||
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
|
||||
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
|
||||
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
|
||||
*/
|
||||
function findInstanceDir(root, tiefe = 2) {
|
||||
if (!root || !fs.existsSync(root)) return null;
|
||||
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
|
||||
if (tiefe <= 0) return null;
|
||||
let eintraege = [];
|
||||
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
|
||||
for (const e of eintraege) {
|
||||
if (!e.isDirectory()) continue;
|
||||
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
|
||||
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
|
||||
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
|
||||
if (treffer) return treffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
|
||||
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
|
||||
*/
|
||||
function scanSingle(root) {
|
||||
const dir = findInstanceDir(root);
|
||||
if (!dir) return { ok: false, reason: 'not-found' };
|
||||
const inst = readInstance(dir, path.basename(dir), '');
|
||||
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
|
||||
}
|
||||
|
||||
/*
|
||||
* Übernimmt eine Instanz in den Launcher.
|
||||
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
|
||||
*/
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
const patch = {};
|
||||
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
|
||||
patch.java = {
|
||||
path: entry.javaPath || '',
|
||||
minMemMb: entry.minMemMb || null,
|
||||
maxMemMb: entry.maxMemMb || null,
|
||||
extraArgs: '',
|
||||
};
|
||||
}
|
||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
try {
|
||||
fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
|
||||
copied = true;
|
||||
} catch { /* Spieldaten konnten nicht kopiert werden */ }
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* 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 };
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* mrimport.js – Instanzen aus der Modrinth App übernehmen
|
||||
* --------------------------------------------------------
|
||||
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
|
||||
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
|
||||
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
|
||||
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
|
||||
* der App verlegt, steht der neue Ort in settings.custom_dir.
|
||||
*
|
||||
* Unterstützte Datenbank-Fassungen:
|
||||
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
|
||||
* Gruppen in "instance_group_memberships"/"instance_groups",
|
||||
* Starteinstellungen als JSONB in "instance_launch_overrides"
|
||||
* - 2024 bis Mitte 2026: eine Tabelle "profiles"
|
||||
* - vorher (ohne app.db): je Instanz eine profile.json
|
||||
*
|
||||
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
|
||||
* die Datenbank der Modrinth App wird nie verändert.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
|
||||
|
||||
let sqlPromise = null;
|
||||
function sqlJs() {
|
||||
if (!sqlPromise) {
|
||||
const initSqlJs = require('sql.js');
|
||||
const wasmDir = path.dirname(require.resolve('sql.js'));
|
||||
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
|
||||
}
|
||||
return sqlPromise;
|
||||
}
|
||||
|
||||
function appDatenOrdner() {
|
||||
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
|
||||
}
|
||||
|
||||
function standardOrdner() {
|
||||
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
|
||||
}
|
||||
|
||||
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
|
||||
function ermittleOrte(eingabe) {
|
||||
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
|
||||
if (!fs.existsSync(basis)) return null;
|
||||
// direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
|
||||
if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
|
||||
const hoeher = path.dirname(basis);
|
||||
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
|
||||
}
|
||||
const db = path.join(basis, 'app.db');
|
||||
return { basis, db: fs.existsSync(db) ? db : null };
|
||||
}
|
||||
|
||||
// ---------- Datenbank ----------
|
||||
/*
|
||||
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
|
||||
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
|
||||
* Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
|
||||
* dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
|
||||
* mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
|
||||
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
|
||||
*/
|
||||
function spieleWalEin(daten, walPfad) {
|
||||
let wal;
|
||||
try { wal = fs.readFileSync(walPfad); } catch { return null; }
|
||||
if (wal.length < 32) return null;
|
||||
const magie = wal.readUInt32BE(0);
|
||||
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
|
||||
const bigEndian = magie === 0x377f0683;
|
||||
const seite = wal.readUInt32BE(8);
|
||||
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
|
||||
if (!seite || seite !== dbSeite) return null;
|
||||
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
|
||||
|
||||
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
|
||||
let s0 = 0, s1 = 0;
|
||||
const pruefe = (buf, start, ende) => {
|
||||
for (let i = start; i < ende; i += 8) {
|
||||
s0 = (s0 + wort(buf, i) + s1) >>> 0;
|
||||
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
|
||||
}
|
||||
};
|
||||
pruefe(wal, 0, 24);
|
||||
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
|
||||
|
||||
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
|
||||
let offen = new Map();
|
||||
let seiten = Math.floor(daten.length / seite);
|
||||
let rahmen = 0;
|
||||
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
|
||||
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
|
||||
pruefe(wal, pos, pos + 8);
|
||||
pruefe(wal, pos + 24, pos + 24 + seite);
|
||||
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
|
||||
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
|
||||
const nachCommit = wal.readUInt32BE(pos + 4);
|
||||
if (nachCommit) { // Transaktion abgeschlossen
|
||||
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
|
||||
offen = new Map();
|
||||
seiten = nachCommit;
|
||||
rahmen++;
|
||||
}
|
||||
}
|
||||
if (!rahmen) return null;
|
||||
const neu = Buffer.alloc(seiten * seite);
|
||||
daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
|
||||
for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
|
||||
return { daten: neu, rahmen };
|
||||
}
|
||||
|
||||
async function oeffneDb(dbPfad) {
|
||||
const SQL = await sqlJs();
|
||||
let daten = fs.readFileSync(dbPfad);
|
||||
let walEingespielt = false;
|
||||
if (daten.length > 100 && daten[18] === 2) {
|
||||
const mitWal = spieleWalEin(daten, dbPfad + '-wal');
|
||||
if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
|
||||
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
|
||||
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
|
||||
daten[18] = 1; daten[19] = 1;
|
||||
}
|
||||
const db = new SQL.Database(daten);
|
||||
db.walEingespielt = walEingespielt;
|
||||
return db;
|
||||
}
|
||||
|
||||
function zeilen(db, sql, params) {
|
||||
const st = db.prepare(sql);
|
||||
try {
|
||||
if (params) st.bind(params);
|
||||
const out = [];
|
||||
while (st.step()) out.push(st.getAsObject());
|
||||
return out;
|
||||
} finally {
|
||||
st.free();
|
||||
}
|
||||
}
|
||||
|
||||
function tabellen(db) {
|
||||
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
|
||||
}
|
||||
|
||||
function spalten(db, tabelle) {
|
||||
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
|
||||
}
|
||||
|
||||
function jsonFeld(wert) {
|
||||
if (wert === null || wert === undefined || wert === '') return null;
|
||||
try { return JSON.parse(wert); } catch { return null; }
|
||||
}
|
||||
|
||||
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
|
||||
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
|
||||
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
|
||||
}
|
||||
|
||||
function eigenerOrdner(db, t) {
|
||||
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
|
||||
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
|
||||
return r && r.custom_dir ? String(r.custom_dir) : null;
|
||||
}
|
||||
|
||||
function leseNeu(db, t) {
|
||||
const si = spalten(db, 'instances');
|
||||
const liste = zeilen(db, `
|
||||
SELECT i.id AS id, i.path AS path, i.name AS name,
|
||||
${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
|
||||
${sp(si, 'i.last_played', 'last_played')},
|
||||
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
|
||||
${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
|
||||
cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
|
||||
FROM instances i
|
||||
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
|
||||
|
||||
const overrides = new Map();
|
||||
if (t.has('instance_launch_overrides')) {
|
||||
for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
|
||||
overrides.set(r.instance_id, jsonFeld(r.o) || {});
|
||||
}
|
||||
}
|
||||
const gruppen = new Map();
|
||||
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
|
||||
if (t.has('instance_group_memberships') && t.has('instance_groups')) {
|
||||
for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
|
||||
JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
|
||||
} else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
|
||||
for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
|
||||
}
|
||||
const links = new Map();
|
||||
if (t.has('instance_links')) {
|
||||
for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
|
||||
if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
|
||||
}
|
||||
}
|
||||
|
||||
return liste.map((r) => {
|
||||
const o = overrides.get(r.id) || {};
|
||||
const hooks = o.hooks || {};
|
||||
return {
|
||||
path: r.path,
|
||||
name: r.name,
|
||||
iconPath: r.icon_path,
|
||||
installiert: !r.install_stage || r.install_stage === 'installed',
|
||||
lastPlayed: r.last_played,
|
||||
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
||||
gameVersion: r.game_version,
|
||||
loader: r.loader,
|
||||
loaderVersion: r.loader_version,
|
||||
gruppe: gruppen.get(r.id) || '',
|
||||
javaPath: o.java_path || '',
|
||||
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
|
||||
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
|
||||
vorStart: hooks.pre_launch || '',
|
||||
nachEnde: hooks.post_exit || '',
|
||||
modpack: links.get(r.id) || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function leseProfile(db) {
|
||||
const s = spalten(db, 'profiles');
|
||||
const liste = zeilen(db, `SELECT path, name,
|
||||
${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
|
||||
${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
|
||||
${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
|
||||
${sp(s, 'last_played', 'last_played')},
|
||||
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
|
||||
${sp(s, 'recent_time_played', 'recent_time_played', '0')},
|
||||
${sp(s, 'override_java_path', 'override_java_path')},
|
||||
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
|
||||
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
|
||||
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
|
||||
${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
|
||||
${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
|
||||
FROM profiles`);
|
||||
return liste.map((r) => {
|
||||
const gruppen = jsonFeld(r.groups);
|
||||
const args = jsonFeld(r.override_extra_launch_args);
|
||||
return {
|
||||
path: r.path,
|
||||
name: r.name,
|
||||
iconPath: r.icon_path,
|
||||
installiert: !r.install_stage || r.install_stage === 'installed',
|
||||
lastPlayed: r.last_played,
|
||||
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
||||
gameVersion: r.game_version,
|
||||
loader: r.mod_loader,
|
||||
loaderVersion: r.mod_loader_version,
|
||||
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
|
||||
javaPath: r.override_java_path || '',
|
||||
maxMemMb: Number(r.override_mc_memory_max) || null,
|
||||
extraArgs: Array.isArray(args) ? args.join(' ') : '',
|
||||
vorStart: r.override_hook_pre_launch || '',
|
||||
nachEnde: r.override_hook_post_exit || '',
|
||||
modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// sehr alte Fassungen: profiles\<Ordner>\profile.json
|
||||
function leseProfileJson(profilesDir) {
|
||||
const liste = [];
|
||||
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
|
||||
if (!p) continue;
|
||||
const m = p.metadata || p;
|
||||
const lv = m.loader_version;
|
||||
liste.push({
|
||||
path: e.name,
|
||||
name: m.name || e.name,
|
||||
iconPath: m.icon || null,
|
||||
installiert: !p.install_stage || p.install_stage === 'installed',
|
||||
lastPlayed: m.last_played || null,
|
||||
sekunden: 0,
|
||||
gameVersion: m.game_version,
|
||||
loader: m.loader,
|
||||
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
|
||||
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
|
||||
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
|
||||
maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
|
||||
extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
|
||||
vorStart: (p.hooks && p.hooks.pre_launch) || '',
|
||||
nachEnde: (p.hooks && p.hooks.post_exit) || '',
|
||||
modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
|
||||
? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
|
||||
});
|
||||
}
|
||||
return liste;
|
||||
}
|
||||
|
||||
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
|
||||
function loaderVersion(loader, wert, mc) {
|
||||
let v = String(wert || '').trim();
|
||||
if (!v || v === 'stable' || v === 'latest') return '';
|
||||
if (loader === 'forge') {
|
||||
v = v.replace(/^forge-/i, '');
|
||||
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
|
||||
} else if (loader === 'neoforge') {
|
||||
v = v.replace(/^neoforge-/i, '');
|
||||
} else if (loader === 'fabric' || loader === 'quilt') {
|
||||
v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
|
||||
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function zeitstempel(wert) {
|
||||
if (!wert) return null;
|
||||
const n = Number(wert);
|
||||
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
|
||||
const d = new Date(wert);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
/*
|
||||
* Durchsucht die Modrinth App. Leere Eingabe = Standardort.
|
||||
* -> { ok, basis, count, instances, hinweis }
|
||||
*/
|
||||
async function scan(eingabe) {
|
||||
const orte = ermittleOrte(eingabe);
|
||||
if (!orte) return { ok: false, reason: 'not-found' };
|
||||
|
||||
let roh = [];
|
||||
let profilesDir = path.join(orte.basis, 'profiles');
|
||||
let hinweis = '';
|
||||
if (orte.db) {
|
||||
const db = await oeffneDb(orte.db);
|
||||
try {
|
||||
const t = tabellen(db);
|
||||
const eigener = eigenerOrdner(db, t);
|
||||
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
|
||||
if (t.has('instances')) roh = leseNeu(db, t);
|
||||
else if (t.has('profiles')) roh = leseProfile(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
|
||||
// ist evtl. nicht ganz aktuell
|
||||
try {
|
||||
const wal = orte.db + '-wal';
|
||||
if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
|
||||
hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
|
||||
}
|
||||
} catch { /* egal */ }
|
||||
} else if (fs.existsSync(profilesDir)) {
|
||||
roh = leseProfileJson(profilesDir);
|
||||
} else {
|
||||
return { ok: false, reason: 'not-found' };
|
||||
}
|
||||
|
||||
const instances = [];
|
||||
for (const r of roh) {
|
||||
const dir = path.join(profilesDir, String(r.path || ''));
|
||||
if (!r.path || !fs.existsSync(dir)) continue;
|
||||
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
|
||||
const version = String(r.gameVersion || '').trim();
|
||||
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
|
||||
instances.push({
|
||||
folder: r.path,
|
||||
dir,
|
||||
gameDir: dir,
|
||||
name: String(r.name || r.path),
|
||||
group: r.gruppe || '',
|
||||
version,
|
||||
loader,
|
||||
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
|
||||
installiert: r.installiert,
|
||||
iconPath: icon,
|
||||
lastPlayed: zeitstempel(r.lastPlayed),
|
||||
playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
|
||||
javaPath: String(r.javaPath || '').trim(),
|
||||
maxMemMb: r.maxMemMb || null,
|
||||
extraArgs: String(r.extraArgs || '').trim(),
|
||||
preLaunchCommand: String(r.vorStart || '').trim(),
|
||||
postExitCommand: String(r.nachEnde || '').trim(),
|
||||
modpack: r.modpack,
|
||||
});
|
||||
}
|
||||
instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
|
||||
}
|
||||
|
||||
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
|
||||
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) {
|
||||
fs.mkdirSync(ziel, { recursive: true });
|
||||
for (const name of fs.readdirSync(quelle)) {
|
||||
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
|
||||
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
|
||||
fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
|
||||
}
|
||||
}
|
||||
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
|
||||
});
|
||||
|
||||
const patch = {};
|
||||
if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
|
||||
patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
|
||||
}
|
||||
if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
|
||||
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
|
||||
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
|
||||
const vorher = store.getInstance(created.id) || {};
|
||||
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
|
||||
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
|
||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
copyInto(entry.gameDir, store.gameDir(created.id));
|
||||
copied = true;
|
||||
}
|
||||
if (entry.iconPath) {
|
||||
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, importOne, kopierQuellen, standardOrdner };
|
||||
@@ -16,6 +16,21 @@ window.addEventListener('unhandledrejection', (e) => {
|
||||
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', {
|
||||
// Einstellungen
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
@@ -154,6 +169,19 @@ contextBridge.exposeInMainWorld('api', {
|
||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
||||
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
||||
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
||||
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
||||
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),
|
||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||
@@ -183,6 +211,13 @@ contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
// Dialoge / App
|
||||
// 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'),
|
||||
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
|
||||
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
|
||||
@@ -192,6 +227,14 @@ contextBridge.exposeInMainWorld('api', {
|
||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||
},
|
||||
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
||||
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
||||
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
||||
onMrProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('mr:progress', l);
|
||||
return () => ipcRenderer.removeListener('mr:progress', l);
|
||||
},
|
||||
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||
onCfProgress: (cb) => {
|
||||
@@ -267,8 +310,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
appLicense: () => ipcRenderer.invoke('app:license'),
|
||||
|
||||
// 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'),
|
||||
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
||||
onPopoutReopen: (cb) => {
|
||||
const l = (_e, params) => cb(params);
|
||||
ipcRenderer.on('popout:reopen', l);
|
||||
@@ -285,7 +329,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
return () => ipcRenderer.removeListener('instances:changed', l);
|
||||
},
|
||||
onSkinChanged: (cb) => {
|
||||
const l = () => cb();
|
||||
const l = (_e, info) => cb(info || {});
|
||||
ipcRenderer.on('skin:changed', l);
|
||||
return () => ipcRenderer.removeListener('skin:changed', l);
|
||||
},
|
||||
|
||||
@@ -1,121 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* skins.js – Skin und Umhang über die Minecraft-Dienste ändern
|
||||
* -------------------------------------------------------------
|
||||
* Der Launcher hält bereits gültige Zugangsdaten des angemeldeten Kontos.
|
||||
* Damit lässt sich der Skin direkt setzen, ohne Umweg über den Browser.
|
||||
*
|
||||
* Verwendete Wege (api.minecraftservices.com):
|
||||
* POST /minecraft/profile/skins Skin hochladen (Datei oder Adresse)
|
||||
* DELETE /minecraft/profile/skins/active Skin zurücksetzen (Standard-Steve/Alex)
|
||||
* GET /minecraft/profile aktuelles Profil samt Skin-Adresse
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const API = 'https://api.minecraftservices.com';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
|
||||
// Minecraft nimmt nur PNG, und die Vorlagen sind 64x64 bzw. 64x32
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
function pruefeDatei(dateiPfad) {
|
||||
if (!dateiPfad || !fs.existsSync(dateiPfad)) return { ok: false, message: 'Datei nicht gefunden.' };
|
||||
if (!/\.png$/i.test(dateiPfad)) return { ok: false, message: 'Minecraft nimmt nur PNG-Dateien.' };
|
||||
const st = fs.statSync(dateiPfad);
|
||||
if (!st.isFile()) return { ok: false, message: 'Das ist keine Datei.' };
|
||||
if (st.size > MAX_BYTES) return { ok: false, message: 'Die Datei ist zu groß (über 512 KB).' };
|
||||
|
||||
// PNG-Kennung und Maße aus dem Kopf lesen, bevor wir sie hochschicken
|
||||
const kopf = Buffer.alloc(33);
|
||||
const fd = fs.openSync(dateiPfad, 'r');
|
||||
try { fs.readSync(fd, kopf, 0, 33, 0); } finally { fs.closeSync(fd); }
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (!kopf.subarray(0, 8).equals(PNG)) return { ok: false, message: 'Die Datei ist kein gültiges PNG.' };
|
||||
|
||||
const breite = kopf.readUInt32BE(16);
|
||||
const hoehe = kopf.readUInt32BE(20);
|
||||
// 64x64 ist heute üblich, 64x32 sind die alten Skins
|
||||
if (!(breite === 64 && (hoehe === 64 || hoehe === 32))) {
|
||||
return { ok: false, message: `Ein Skin muss 64×64 (oder 64×32) Pixel groß sein – diese Datei ist ${breite}×${hoehe}.` };
|
||||
}
|
||||
return { ok: true, breite, hoehe };
|
||||
}
|
||||
|
||||
function fehlerText(status) {
|
||||
if (status === 401) return 'Die Anmeldung ist abgelaufen. Bitte das Konto neu verbinden.';
|
||||
if (status === 403) return 'Minecraft lehnt die Änderung ab. Hat das Konto Minecraft Java Edition?';
|
||||
if (status === 429) return 'Zu viele Änderungen in kurzer Zeit. Bitte einen Moment warten.';
|
||||
return 'Minecraft-Dienste antworten mit HTTP ' + status + '.';
|
||||
}
|
||||
|
||||
/*
|
||||
* Skin setzen.
|
||||
* variante: 'classic' (breite Arme) oder 'slim' (schmale Arme, Alex)
|
||||
*/
|
||||
async function setzeSkin(accessToken, dateiPfad, variante) {
|
||||
const geprueft = pruefeDatei(dateiPfad);
|
||||
if (!geprueft.ok) return geprueft;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('variant', variante === 'slim' ? 'slim' : 'classic');
|
||||
const daten = fs.readFileSync(dateiPfad);
|
||||
form.append('file', new Blob([daten], { type: 'image/png' }), path.basename(dateiPfad));
|
||||
|
||||
const res = await fetch(API + '/minecraft/profile/skins', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||
|
||||
let profil = null;
|
||||
try { profil = await res.json(); } catch { /* Antwort ohne Rumpf ist auch in Ordnung */ }
|
||||
return { ok: true, variante: variante === 'slim' ? 'slim' : 'classic', profil };
|
||||
}
|
||||
|
||||
// Skin auf den Standard zurücksetzen
|
||||
async function setzeSkinZurueck(accessToken) {
|
||||
const res = await fetch(API + '/minecraft/profile/skins/active', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/*
|
||||
* Aktuelles Profil abrufen – liefert die Adresse des aktiven Skins,
|
||||
* damit die Oberfläche vorher/nachher zeigen kann.
|
||||
*/
|
||||
/*
|
||||
* Mojang liefert Skin-Adressen bis heute als "http://textures.minecraft.net/…".
|
||||
* Die Oberfläche lässt nach ihrer Sicherheitsrichtlinie nur https zu, das Bild
|
||||
* würde also blockiert. textures.minecraft.net beantwortet dieselbe Adresse
|
||||
* auch über https – daher heben wir sie an.
|
||||
*/
|
||||
function aufHttps(url) {
|
||||
const s = String(url || '');
|
||||
return s.startsWith('http://') ? 'https://' + s.slice('http://'.length) : s;
|
||||
}
|
||||
|
||||
async function holeProfil(accessToken) {
|
||||
const res = await fetch(API + '/minecraft/profile', {
|
||||
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||
const daten = await res.json();
|
||||
const aktiv = (daten.skins || []).find((s) => s.state === 'ACTIVE') || null;
|
||||
return {
|
||||
ok: true,
|
||||
name: daten.name,
|
||||
id: daten.id,
|
||||
skinUrl: aktiv ? aufHttps(aktiv.url) : '',
|
||||
variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps };
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* skins.js – Skin und Umhang über die Minecraft-Dienste ändern
|
||||
* -------------------------------------------------------------
|
||||
* Der Launcher hält bereits gültige Zugangsdaten des angemeldeten Kontos.
|
||||
* Damit lässt sich der Skin direkt setzen, ohne Umweg über den Browser.
|
||||
*
|
||||
* Verwendete Wege (api.minecraftservices.com):
|
||||
* POST /minecraft/profile/skins Skin hochladen (Datei oder Adresse)
|
||||
* DELETE /minecraft/profile/skins/active Skin zurücksetzen (Standard-Steve/Alex)
|
||||
* GET /minecraft/profile aktuelles Profil samt Skin-Adresse
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const API = 'https://api.minecraftservices.com';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
|
||||
// Minecraft nimmt nur PNG, und die Vorlagen sind 64x64 bzw. 64x32
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
function pruefeDatei(dateiPfad) {
|
||||
if (!dateiPfad || !fs.existsSync(dateiPfad)) return { ok: false, message: 'Datei nicht gefunden.' };
|
||||
if (!/\.png$/i.test(dateiPfad)) return { ok: false, message: 'Minecraft nimmt nur PNG-Dateien.' };
|
||||
const st = fs.statSync(dateiPfad);
|
||||
if (!st.isFile()) return { ok: false, message: 'Das ist keine Datei.' };
|
||||
if (st.size > MAX_BYTES) return { ok: false, message: 'Die Datei ist zu groß (über 512 KB).' };
|
||||
|
||||
// PNG-Kennung und Maße aus dem Kopf lesen, bevor wir sie hochschicken
|
||||
const kopf = Buffer.alloc(33);
|
||||
const fd = fs.openSync(dateiPfad, 'r');
|
||||
try { fs.readSync(fd, kopf, 0, 33, 0); } finally { fs.closeSync(fd); }
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (!kopf.subarray(0, 8).equals(PNG)) return { ok: false, message: 'Die Datei ist kein gültiges PNG.' };
|
||||
|
||||
const breite = kopf.readUInt32BE(16);
|
||||
const hoehe = kopf.readUInt32BE(20);
|
||||
// 64x64 ist heute üblich, 64x32 sind die alten Skins
|
||||
if (!(breite === 64 && (hoehe === 64 || hoehe === 32))) {
|
||||
return { ok: false, message: `Ein Skin muss 64×64 (oder 64×32) Pixel groß sein – diese Datei ist ${breite}×${hoehe}.` };
|
||||
}
|
||||
return { ok: true, breite, hoehe };
|
||||
}
|
||||
|
||||
function fehlerText(status) {
|
||||
if (status === 401) return 'Die Anmeldung ist abgelaufen. Bitte das Konto neu verbinden.';
|
||||
if (status === 403) return 'Minecraft lehnt die Änderung ab. Hat das Konto Minecraft Java Edition?';
|
||||
if (status === 429) return 'Zu viele Änderungen in kurzer Zeit. Bitte einen Moment warten.';
|
||||
return 'Minecraft-Dienste antworten mit HTTP ' + status + '.';
|
||||
}
|
||||
|
||||
/*
|
||||
* Skin setzen.
|
||||
* variante: 'classic' (breite Arme) oder 'slim' (schmale Arme, Alex)
|
||||
*/
|
||||
async function setzeSkin(accessToken, dateiPfad, variante) {
|
||||
const geprueft = pruefeDatei(dateiPfad);
|
||||
if (!geprueft.ok) return geprueft;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('variant', variante === 'slim' ? 'slim' : 'classic');
|
||||
const daten = fs.readFileSync(dateiPfad);
|
||||
form.append('file', new Blob([daten], { type: 'image/png' }), path.basename(dateiPfad));
|
||||
|
||||
const res = await fetch(API + '/minecraft/profile/skins', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||
|
||||
let profil = null;
|
||||
try { profil = await res.json(); } catch { /* Antwort ohne Rumpf ist auch in Ordnung */ }
|
||||
return { ok: true, variante: variante === 'slim' ? 'slim' : 'classic', profil };
|
||||
}
|
||||
|
||||
// Skin auf den Standard zurücksetzen
|
||||
async function setzeSkinZurueck(accessToken) {
|
||||
const res = await fetch(API + '/minecraft/profile/skins/active', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/*
|
||||
* Aktuelles Profil abrufen – liefert die Adresse des aktiven Skins,
|
||||
* damit die Oberfläche vorher/nachher zeigen kann.
|
||||
*/
|
||||
/*
|
||||
* Mojang liefert Skin-Adressen bis heute als "http://textures.minecraft.net/…".
|
||||
* Die Oberfläche lässt nach ihrer Sicherheitsrichtlinie nur https zu, das Bild
|
||||
* würde also blockiert. textures.minecraft.net beantwortet dieselbe Adresse
|
||||
* auch über https – daher heben wir sie an.
|
||||
*/
|
||||
function aufHttps(url) {
|
||||
const s = String(url || '');
|
||||
return s.startsWith('http://') ? 'https://' + s.slice('http://'.length) : s;
|
||||
}
|
||||
|
||||
async function holeProfil(accessToken) {
|
||||
const res = await fetch(API + '/minecraft/profile', {
|
||||
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||
const daten = await res.json();
|
||||
const aktiv = (daten.skins || []).find((s) => s.state === 'ACTIVE') || null;
|
||||
return {
|
||||
ok: true,
|
||||
name: daten.name,
|
||||
id: daten.id,
|
||||
skinUrl: aktiv ? aufHttps(aktiv.url) : '',
|
||||
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',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Offiziellen Umhang wählen (nur einer, den das Konto besitzt) bzw. mit
|
||||
* capeId = null keinen tragen. Gilt im Spiel für alle Spieler.
|
||||
*/
|
||||
async function setzeUmhang(accessToken, capeId) {
|
||||
const res = await fetch(API + '/minecraft/profile/capes/active', capeId
|
||||
? {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ capeId }),
|
||||
}
|
||||
: { method: 'DELETE', headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA } });
|
||||
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/*
|
||||
* Texturen, die andere Spieler im Spiel sehen (öffentliches Profil beim
|
||||
* Sitzungsserver). Das kann vom Kontoprofil oben abweichen: Dort liefert
|
||||
* Mojang bei manchen Konten einen Standard-Skin (z. B. Efe) als "ACTIVE",
|
||||
* obwohl im Spiel längst ein eigener Skin getragen wird.
|
||||
*/
|
||||
async function holeSpielTexturen(uuid) {
|
||||
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{32}$/.test(id)) return null;
|
||||
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, {
|
||||
headers: { 'User-Agent': UA, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const profil = await res.json();
|
||||
const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures');
|
||||
if (!eigenschaft) return null;
|
||||
const texturen = JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')).textures || {};
|
||||
const skin = texturen.SKIN;
|
||||
return {
|
||||
skinUrl: skin && skin.url ? aufHttps(skin.url) : '',
|
||||
variante: skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic',
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Welcher Skin wird als "aktuell" gezeigt? Grundsätzlich der aus dem Spiel –
|
||||
* nur direkt nach einer eigenen Änderung das Kontoprofil, weil der
|
||||
* Sitzungsserver dann noch kurz den alten Stand liefert.
|
||||
*/
|
||||
function waehleAnzeigeSkin(kontoProfil, spiel, kuerzlichGeaendert) {
|
||||
if (!kontoProfil || !kontoProfil.ok || kuerzlichGeaendert) return kontoProfil;
|
||||
if (!spiel || !spiel.skinUrl || spiel.skinUrl === kontoProfil.skinUrl) return kontoProfil;
|
||||
return Object.assign({}, kontoProfil, { skinUrl: spiel.skinUrl, variante: spiel.variante, skinAusSpiel: true });
|
||||
}
|
||||
|
||||
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin };
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self'" />
|
||||
<title>AeroMC</title>
|
||||
<style>
|
||||
@font-face { font-family: 'AeroPixel'; src: url('assets/aeropixel.ttf'); }
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; user-select: none; cursor: default; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
|
||||
/* Logo + Schriftzug in der Mitte */
|
||||
.marke {
|
||||
position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
|
||||
transition: opacity .28s ease, transform .32s ease;
|
||||
}
|
||||
.buehne { perspective: 700px; animation: rein .45s cubic-bezier(.2, .9, .3, 1.2) both; }
|
||||
.logo {
|
||||
width: 132px; height: 132px; display: block;
|
||||
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(90, 160, 255, .35));
|
||||
animation: drehen 1.3s cubic-bezier(.45, .05, .25, 1) .15s infinite;
|
||||
}
|
||||
.name {
|
||||
font-size: 34px; font-weight: 600; letter-spacing: .06em; color: #fff;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, .75), 0 0 2px rgba(0, 0, 0, .9);
|
||||
animation: schrift .5s ease .25s both;
|
||||
}
|
||||
@keyframes rein { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
|
||||
@keyframes drehen { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
|
||||
@keyframes schrift { from { opacity: 0; transform: translateY(8px); letter-spacing: .3em; } to { opacity: 1; transform: none; letter-spacing: .06em; } }
|
||||
|
||||
/* Schriftzug je Design */
|
||||
body[data-design="aero"] .name, body[data-design="lila"] .name {
|
||||
background: linear-gradient(90deg, #7fe3ff, #6c9cff); -webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
text-shadow: none; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .8));
|
||||
}
|
||||
body[data-design="lila"] .name { background-image: linear-gradient(90deg, #e3b8ff, #9d7bff); }
|
||||
body[data-design="lila"] .logo { filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(170, 120, 255, .4)); }
|
||||
body[data-design="minecraft"] .name {
|
||||
font-family: 'AeroPixel', 'Segoe UI', sans-serif; font-weight: 400; font-size: 30px; letter-spacing: .04em;
|
||||
text-shadow: 3px 3px 0 #3f3f3f, 0 0 10px rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
/* das Launcher-Fenster als Bild, wächst von der Mitte nach außen */
|
||||
#fenster { position: fixed; inset: 0; width: 100%; height: 100%; visibility: hidden; }
|
||||
body.aufbau .marke { opacity: 0; transform: scale(.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="marke">
|
||||
<div class="buehne"><img class="logo" src="assets/logo.png" alt="" /></div>
|
||||
<div class="name">AeroMC</div>
|
||||
</div>
|
||||
<img id="fenster" alt="" />
|
||||
<script>
|
||||
document.body.dataset.design = new URLSearchParams(location.search).get('design') || 'dark';
|
||||
|
||||
// vom Hauptprozess aufgerufen: Bild des fertigen Launchers von der Mitte aus aufziehen
|
||||
window.aufbauen = (bildAdresse) => new Promise((fertig) => {
|
||||
const bild = document.getElementById('fenster');
|
||||
bild.onerror = () => fertig(false);
|
||||
bild.onload = () => {
|
||||
const v = Math.max(0, innerHeight / 2 - 66);
|
||||
const h = Math.max(0, innerWidth / 2 - 66);
|
||||
bild.style.visibility = 'visible';
|
||||
document.body.classList.add('aufbau');
|
||||
bild.animate([
|
||||
{ clipPath: `inset(${v}px ${h}px round 30px)`, opacity: 0.2 },
|
||||
{ opacity: 1, offset: 0.25 },
|
||||
{ clipPath: 'inset(0px 0px round 0px)', opacity: 1 },
|
||||
], { duration: 560, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' })
|
||||
.finished.then(() => fertig(true), () => fertig(false));
|
||||
};
|
||||
bild.src = bildAdresse;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Startanimation des Hauptfensters (abschaltbar unter Optionen → Darstellung).
|
||||
*
|
||||
* Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo der
|
||||
* Launcher erscheinen wird, und zeigt das sich drehende Logo samt Schriftzug.
|
||||
* Ist die Oberfläche fertig (renderer:ready), wird das noch unsichtbare
|
||||
* Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte
|
||||
* nach außen aufgezogen. Am Ende übernimmt das echte Fenster an exakt
|
||||
* derselben Stelle – dadurch wirkt es, als baue sich der Launcher auf.
|
||||
*/
|
||||
const path = require('path');
|
||||
const { BrowserWindow } = require('electron');
|
||||
|
||||
const MINDESTENS_DREHEN_MS = 1600; // so lange dreht sich das Logo mindestens
|
||||
const HOECHSTENS_WARTEN_MS = 8000; // spätestens dann wird der Launcher gezeigt
|
||||
|
||||
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// "renderer:ready" kommt, bevor Instanzliste und Kontoname gezeichnet sind –
|
||||
// erst darauf warten, sonst wächst ein halbleeres Fenster auf
|
||||
async function warteAufGezeichneteOberflaeche(wc) {
|
||||
const pruefe = (ausdruck) => wc.executeJavaScript(ausdruck, true).catch(() => true);
|
||||
// Liste gezeichnet – oder es gibt (noch) keine Instanzen, dann steht der Leer-Hinweis da
|
||||
for (let i = 0; i < 30 && !(await pruefe("document.querySelectorAll('#grid > *').length > 0 || !document.getElementById('empty').classList.contains('hidden')")); i++) await warte(100);
|
||||
for (let i = 0; i < 12 && !(await pruefe("(document.getElementById('account-name') || {}).textContent !== 'Konten'")); i++) await warte(100);
|
||||
await warte(200);
|
||||
}
|
||||
|
||||
async function spielen(hauptfenster, oberflaecheBereit, design) {
|
||||
const b = hauptfenster.getContentBounds();
|
||||
const start = new BrowserWindow({
|
||||
x: b.x, y: b.y, width: b.width, height: b.height,
|
||||
frame: false, transparent: true, resizable: false, movable: false,
|
||||
minimizable: false, maximizable: false, fullscreenable: false,
|
||||
skipTaskbar: true, hasShadow: false, focusable: false, show: false,
|
||||
backgroundColor: '#00000000', title: 'AeroMC',
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||
});
|
||||
|
||||
const launcherZeigen = () => {
|
||||
if (hauptfenster.isDestroyed()) return;
|
||||
hauptfenster.setOpacity(1);
|
||||
hauptfenster.setIgnoreMouseEvents(false);
|
||||
if (!hauptfenster.isVisible()) hauptfenster.show();
|
||||
hauptfenster.focus();
|
||||
};
|
||||
|
||||
try {
|
||||
await start.loadFile(path.join(__dirname, 'splash.html'), { query: { design: design || 'dark' } });
|
||||
start.showInactive();
|
||||
const beginn = Date.now();
|
||||
await Promise.race([oberflaecheBereit, warte(HOECHSTENS_WARTEN_MS)]);
|
||||
const rest = MINDESTENS_DREHEN_MS - (Date.now() - beginn);
|
||||
if (rest > 0) await warte(rest);
|
||||
if (hauptfenster.isDestroyed()) return;
|
||||
|
||||
// Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
|
||||
hauptfenster.setOpacity(0);
|
||||
hauptfenster.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
|
||||
hauptfenster.show();
|
||||
await warteAufGezeichneteOberflaeche(hauptfenster.webContents);
|
||||
if (hauptfenster.isDestroyed()) return;
|
||||
const bild = await hauptfenster.webContents.capturePage();
|
||||
if (!bild.isEmpty() && !start.isDestroyed()) {
|
||||
// höchstens 2,5 s – das Hauptfenster darf nie unsichtbar hängen bleiben
|
||||
await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(bild.toDataURL())})`), warte(2500)]);
|
||||
}
|
||||
} catch {
|
||||
// Animation ist nur Zierde – im Fehlerfall einfach den Launcher zeigen
|
||||
} finally {
|
||||
launcherZeigen();
|
||||
setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { spielen };
|
||||
@@ -78,7 +78,8 @@ const DEFAULT_SETTINGS = {
|
||||
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
|
||||
friendsEnabled: true,
|
||||
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", … })
|
||||
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
|
||||
@@ -87,6 +88,9 @@ const DEFAULT_SETTINGS = {
|
||||
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
|
||||
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
|
||||
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)
|
||||
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
|
||||
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
|
||||
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
|
||||
@@ -422,6 +426,35 @@ function createInstance(data) {
|
||||
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) {
|
||||
const existing = findInstanceByName(data && data.name);
|
||||
if (!existing) return createInstance(data);
|
||||
@@ -433,8 +466,7 @@ function createOrReplaceImportedInstance(data) {
|
||||
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
|
||||
inst.launchCount = existing.launchCount || 0;
|
||||
|
||||
const p = instancePath(existing.id);
|
||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||
entsorgeOrdner(instancePath(existing.id));
|
||||
return writeInstance(inst);
|
||||
}
|
||||
|
||||
@@ -450,12 +482,11 @@ function updateInstance(id, patch) {
|
||||
}
|
||||
|
||||
function deleteInstance(id) {
|
||||
const p = instancePath(id);
|
||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||
entsorgeOrdner(instancePath(id));
|
||||
return true;
|
||||
}
|
||||
|
||||
function duplicateInstance(id) {
|
||||
function duplicateInstance(id, ohneSpieldaten) {
|
||||
const src = getInstance(id);
|
||||
if (!src) throw new Error('Instanz nicht gefunden: ' + id);
|
||||
const copy = JSON.parse(JSON.stringify(src));
|
||||
@@ -467,9 +498,11 @@ function duplicateInstance(id) {
|
||||
copy.launchCount = 0;
|
||||
ensureDir(gameDir(copy.id));
|
||||
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
|
||||
try {
|
||||
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
|
||||
} catch { /* Quelle evtl. leer */ }
|
||||
if (!ohneSpieldaten) {
|
||||
try {
|
||||
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
|
||||
} catch { /* Quelle evtl. leer */ }
|
||||
}
|
||||
writeJson(instanceFile(copy.id), copy);
|
||||
return copy;
|
||||
}
|
||||
@@ -498,5 +531,6 @@ module.exports = {
|
||||
createOrReplaceImportedInstance,
|
||||
updateInstance,
|
||||
deleteInstance,
|
||||
raeumePapierkorbAuf,
|
||||
duplicateInstance,
|
||||
};
|
||||
|
||||
@@ -1,172 +1,183 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen
|
||||
* -------------------------------------------------------------------
|
||||
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
|
||||
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
|
||||
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
|
||||
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
|
||||
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
|
||||
* bei einem klassischen .minecraft.
|
||||
*
|
||||
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
|
||||
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
|
||||
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
|
||||
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
|
||||
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const forge = require('./forge');
|
||||
|
||||
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
|
||||
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
|
||||
const SKIP_DIRS = new Set(['bin']);
|
||||
|
||||
function technicRoot() {
|
||||
return path.join(process.env.APPDATA || '', '.technic');
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
|
||||
// oder ein einzelner Pack-Ordner angegeben wurde
|
||||
function resolveModpacksDir(inputPath) {
|
||||
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
|
||||
if (!fs.existsSync(root)) return null;
|
||||
|
||||
const direct = path.join(root, 'modpacks');
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
if (path.basename(root).toLowerCase() === 'modpacks') return root;
|
||||
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
|
||||
}
|
||||
|
||||
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
|
||||
function looksLikePack(dir) {
|
||||
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
|
||||
return markers.some((name) => fs.existsSync(path.join(dir, name)));
|
||||
}
|
||||
|
||||
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
|
||||
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
|
||||
// als stabile API dokumentiert ist
|
||||
function readInstalledPacksMeta(root) {
|
||||
const data = readJson(path.join(root, 'installedPacks'));
|
||||
const packs = data && data.installedPacks;
|
||||
return packs && typeof packs === 'object' ? packs : {};
|
||||
}
|
||||
|
||||
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
|
||||
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
|
||||
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
|
||||
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
|
||||
function detectVersion(packDir) {
|
||||
const bin = path.join(packDir, 'bin');
|
||||
const jarFile = path.join(bin, 'modpack.jar');
|
||||
if (fs.existsSync(jarFile)) {
|
||||
try {
|
||||
const buf = fs.readFileSync(jarFile);
|
||||
const idx = forge.readZipIndex(buf);
|
||||
const entry = idx['version.json'];
|
||||
if (entry) {
|
||||
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
|
||||
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
|
||||
const profile = JSON.parse(text);
|
||||
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
|
||||
let loader = 'vanilla';
|
||||
let loaderVersion = '';
|
||||
for (const lib of libs) {
|
||||
const name = String((lib && lib.name) || '');
|
||||
if (/net\.minecraftforge:forge:/i.test(name)) {
|
||||
loader = 'forge';
|
||||
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
|
||||
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
|
||||
loader = 'neoforge';
|
||||
loaderVersion = name.split(':')[2] || '';
|
||||
}
|
||||
}
|
||||
const id = String(profile.id || profile.inheritsFrom || '');
|
||||
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
|
||||
if (version) return { version, loader, loaderVersion };
|
||||
}
|
||||
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
|
||||
}
|
||||
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
|
||||
const legacy = path.join(bin, 'version');
|
||||
try {
|
||||
if (fs.existsSync(legacy)) {
|
||||
const version = fs.readFileSync(legacy, 'utf8').trim();
|
||||
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
} catch { /* egal, bleibt leer */ }
|
||||
return { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
|
||||
function scan(inputPath) {
|
||||
const modpacksDir = resolveModpacksDir(inputPath);
|
||||
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
|
||||
? path.dirname(modpacksDir) : modpacksDir;
|
||||
const meta = readInstalledPacksMeta(root);
|
||||
|
||||
const list = [];
|
||||
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = path.join(modpacksDir, entry.name);
|
||||
if (!looksLikePack(dir)) continue;
|
||||
|
||||
const { version, loader, loaderVersion } = detectVersion(dir);
|
||||
const info = meta[entry.name] || {};
|
||||
list.push({
|
||||
folder: entry.name,
|
||||
dir,
|
||||
name: info.displayName || entry.name,
|
||||
group: '',
|
||||
notes: '',
|
||||
version,
|
||||
loader,
|
||||
loaderVersion,
|
||||
gameDir: dir,
|
||||
hasGameData: true,
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
|
||||
function copyInto(sourceDir, targetDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
copyInto(entry.gameDir, store.gameDir(created.id));
|
||||
copied = true;
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion };
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen
|
||||
* -------------------------------------------------------------------
|
||||
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
|
||||
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
|
||||
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
|
||||
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
|
||||
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
|
||||
* bei einem klassischen .minecraft.
|
||||
*
|
||||
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
|
||||
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
|
||||
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
|
||||
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
|
||||
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const forge = require('./forge');
|
||||
|
||||
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
|
||||
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
|
||||
const SKIP_DIRS = new Set(['bin']);
|
||||
|
||||
function technicRoot() {
|
||||
return path.join(process.env.APPDATA || '', '.technic');
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
|
||||
// oder ein einzelner Pack-Ordner angegeben wurde
|
||||
function resolveModpacksDir(inputPath) {
|
||||
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
|
||||
if (!fs.existsSync(root)) return null;
|
||||
|
||||
const direct = path.join(root, 'modpacks');
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
if (path.basename(root).toLowerCase() === 'modpacks') return root;
|
||||
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
|
||||
}
|
||||
|
||||
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
|
||||
function looksLikePack(dir) {
|
||||
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
|
||||
return markers.some((name) => fs.existsSync(path.join(dir, name)));
|
||||
}
|
||||
|
||||
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
|
||||
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
|
||||
// als stabile API dokumentiert ist
|
||||
function readInstalledPacksMeta(root) {
|
||||
const data = readJson(path.join(root, 'installedPacks'));
|
||||
const packs = data && data.installedPacks;
|
||||
return packs && typeof packs === 'object' ? packs : {};
|
||||
}
|
||||
|
||||
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
|
||||
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
|
||||
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
|
||||
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
|
||||
function detectVersion(packDir) {
|
||||
const bin = path.join(packDir, 'bin');
|
||||
const jarFile = path.join(bin, 'modpack.jar');
|
||||
if (fs.existsSync(jarFile)) {
|
||||
try {
|
||||
const buf = fs.readFileSync(jarFile);
|
||||
const idx = forge.readZipIndex(buf);
|
||||
const entry = idx['version.json'];
|
||||
if (entry) {
|
||||
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
|
||||
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
|
||||
const profile = JSON.parse(text);
|
||||
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
|
||||
let loader = 'vanilla';
|
||||
let loaderVersion = '';
|
||||
for (const lib of libs) {
|
||||
const name = String((lib && lib.name) || '');
|
||||
if (/net\.minecraftforge:forge:/i.test(name)) {
|
||||
loader = 'forge';
|
||||
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
|
||||
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
|
||||
loader = 'neoforge';
|
||||
loaderVersion = name.split(':')[2] || '';
|
||||
}
|
||||
}
|
||||
const id = String(profile.id || profile.inheritsFrom || '');
|
||||
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
|
||||
if (version) return { version, loader, loaderVersion };
|
||||
}
|
||||
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
|
||||
}
|
||||
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
|
||||
const legacy = path.join(bin, 'version');
|
||||
try {
|
||||
if (fs.existsSync(legacy)) {
|
||||
const version = fs.readFileSync(legacy, 'utf8').trim();
|
||||
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
} catch { /* egal, bleibt leer */ }
|
||||
return { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
|
||||
function scan(inputPath) {
|
||||
const modpacksDir = resolveModpacksDir(inputPath);
|
||||
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
|
||||
? path.dirname(modpacksDir) : modpacksDir;
|
||||
const meta = readInstalledPacksMeta(root);
|
||||
|
||||
const list = [];
|
||||
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = path.join(modpacksDir, entry.name);
|
||||
if (!looksLikePack(dir)) continue;
|
||||
|
||||
const { version, loader, loaderVersion } = detectVersion(dir);
|
||||
const info = meta[entry.name] || {};
|
||||
list.push({
|
||||
folder: entry.name,
|
||||
dir,
|
||||
name: info.displayName || entry.name,
|
||||
group: '',
|
||||
notes: '',
|
||||
version,
|
||||
loader,
|
||||
loaderVersion,
|
||||
gameDir: dir,
|
||||
hasGameData: true,
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
|
||||
function copyInto(sourceDir, targetDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
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 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) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
copyInto(entry.gameDir, store.gameDir(created.id));
|
||||
copied = true;
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, importOne, kopierQuellen, resolveModpacksDir, technicRoot, detectVersion };
|
||||
|
||||
@@ -1,162 +1,180 @@
|
||||
<?php
|
||||
/**
|
||||
* 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.
|
||||
* Version: 1.4.0
|
||||
* Author: M_Viper
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // kein direkter Aufruf der Datei
|
||||
}
|
||||
|
||||
define('AEROMC_FRIENDS_VERSION', '1.4.0');
|
||||
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
|
||||
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
|
||||
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
|
||||
define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
|
||||
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
|
||||
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
|
||||
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
|
||||
|
||||
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
|
||||
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
|
||||
// (ältere Launcher-Versionen funktionieren weiter). Sobald alle Nutzer
|
||||
// aktualisiert haben, in der wp-config.php auf true setzen:
|
||||
// define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
|
||||
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
|
||||
define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
|
||||
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
|
||||
*/
|
||||
function aeromc_friends_install() {
|
||||
global $wpdb;
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
|
||||
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
|
||||
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
|
||||
$sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE;
|
||||
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE;
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
|
||||
// in includes/routes.php) - vermeidet doppelte Zeilen durch
|
||||
// unterschiedliche Schreibweisen derselben UUID
|
||||
$sql_presence = "CREATE TABLE $presence_table (
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
instance_name varchar(128) DEFAULT NULL,
|
||||
server_label varchar(128) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
last_seen datetime NOT NULL,
|
||||
PRIMARY KEY (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_presence);
|
||||
|
||||
$sql_friends = "CREATE TABLE $friends_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
uuid_a varchar(32) NOT NULL,
|
||||
uuid_b varchar(32) NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY uuid_a (uuid_a),
|
||||
KEY uuid_b (uuid_b)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_friends);
|
||||
|
||||
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
|
||||
$sql_invites = "CREATE TABLE $invites_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
server varchar(128) NOT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_invites);
|
||||
|
||||
// Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
|
||||
$sql_sessions = "CREATE TABLE $sessions_table (
|
||||
token_hash char(64) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
last_used datetime NOT NULL,
|
||||
PRIMARY KEY (token_hash),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_sessions);
|
||||
|
||||
// per Code geteilte Instanzen (Mod-Liste, keine Dateien)
|
||||
$sql_shares = "CREATE TABLE $shares_table (
|
||||
code varchar(9) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
manifest longtext NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (code),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_shares);
|
||||
|
||||
// Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
|
||||
$messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE;
|
||||
$sql_messages = "CREATE TABLE $messages_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
text varchar(400) NOT NULL,
|
||||
image_url varchar(255) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
read_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid),
|
||||
KEY from_uuid (from_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_messages);
|
||||
|
||||
// Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
|
||||
$reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE;
|
||||
$sql_reports = "CREATE TABLE $reports_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
created_at datetime NOT NULL,
|
||||
uuid varchar(32) DEFAULT NULL,
|
||||
username varchar(32) DEFAULT NULL,
|
||||
launcher_version varchar(32) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
loader varchar(32) DEFAULT NULL,
|
||||
loader_version varchar(64) DEFAULT NULL,
|
||||
system varchar(128) DEFAULT NULL,
|
||||
funde text,
|
||||
mods longtext,
|
||||
bericht longtext,
|
||||
kommentar text,
|
||||
status varchar(20) NOT NULL DEFAULT 'neu',
|
||||
PRIMARY KEY (id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_reports);
|
||||
|
||||
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
|
||||
}
|
||||
register_activation_hook(__FILE__, 'aeromc_friends_install');
|
||||
|
||||
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
|
||||
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
|
||||
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
|
||||
add_action('plugins_loaded', function () {
|
||||
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
|
||||
aeromc_friends_install();
|
||||
}
|
||||
});
|
||||
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
|
||||
if (is_admin()) {
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
|
||||
}
|
||||
<?php
|
||||
/**
|
||||
* 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 sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
|
||||
* Version: 1.5.1
|
||||
* Author: M_Viper
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // kein direkter Aufruf der Datei
|
||||
}
|
||||
|
||||
define('AEROMC_FRIENDS_VERSION', '1.5.1');
|
||||
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
|
||||
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
|
||||
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
|
||||
define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
|
||||
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
|
||||
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
|
||||
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
|
||||
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
|
||||
|
||||
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
|
||||
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
|
||||
// (ältere Launcher-Versionen funktionieren weiter). Sobald alle Nutzer
|
||||
// aktualisiert haben, in der wp-config.php auf true setzen:
|
||||
// define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
|
||||
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
|
||||
define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
|
||||
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
|
||||
*/
|
||||
function aeromc_friends_install() {
|
||||
global $wpdb;
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
|
||||
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
|
||||
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
|
||||
$sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE;
|
||||
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE;
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
|
||||
// in includes/routes.php) - vermeidet doppelte Zeilen durch
|
||||
// unterschiedliche Schreibweisen derselben UUID
|
||||
$sql_presence = "CREATE TABLE $presence_table (
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
instance_name varchar(128) DEFAULT NULL,
|
||||
server_label varchar(128) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
last_seen datetime NOT NULL,
|
||||
PRIMARY KEY (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_presence);
|
||||
|
||||
$sql_friends = "CREATE TABLE $friends_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
uuid_a varchar(32) NOT NULL,
|
||||
uuid_b varchar(32) NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY uuid_a (uuid_a),
|
||||
KEY uuid_b (uuid_b)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_friends);
|
||||
|
||||
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
|
||||
$sql_invites = "CREATE TABLE $invites_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
server varchar(128) NOT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_invites);
|
||||
|
||||
// Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
|
||||
$sql_sessions = "CREATE TABLE $sessions_table (
|
||||
token_hash char(64) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
last_used datetime NOT NULL,
|
||||
PRIMARY KEY (token_hash),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_sessions);
|
||||
|
||||
// per Code geteilte Instanzen (Mod-Liste, keine Dateien)
|
||||
$sql_shares = "CREATE TABLE $shares_table (
|
||||
code varchar(9) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
manifest longtext NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (code),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_shares);
|
||||
|
||||
// Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
|
||||
$messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE;
|
||||
$sql_messages = "CREATE TABLE $messages_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
text varchar(400) NOT NULL,
|
||||
image_url varchar(255) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
read_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid),
|
||||
KEY from_uuid (from_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_messages);
|
||||
|
||||
// Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
|
||||
$reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE;
|
||||
$sql_reports = "CREATE TABLE $reports_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
created_at datetime NOT NULL,
|
||||
uuid varchar(32) DEFAULT NULL,
|
||||
username varchar(32) DEFAULT NULL,
|
||||
launcher_version varchar(32) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
loader varchar(32) DEFAULT NULL,
|
||||
loader_version varchar(64) DEFAULT NULL,
|
||||
system varchar(128) DEFAULT NULL,
|
||||
funde text,
|
||||
mods longtext,
|
||||
bericht longtext,
|
||||
kommentar text,
|
||||
status varchar(20) NOT NULL DEFAULT 'neu',
|
||||
PRIMARY KEY (id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
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);
|
||||
}
|
||||
register_activation_hook(__FILE__, 'aeromc_friends_install');
|
||||
|
||||
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
|
||||
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
|
||||
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
|
||||
add_action('plugins_loaded', function () {
|
||||
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
|
||||
aeromc_friends_install();
|
||||
}
|
||||
});
|
||||
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
|
||||
if (is_admin()) {
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
/**
|
||||
* AeroMC-Umhänge: eigene (auch HD-)Umhänge, die Spieler im Launcher gestalten.
|
||||
*
|
||||
* POST /aeromc/v1/cape { uuid, image } Umhang hochladen/ersetzen (Anmeldung Pflicht)
|
||||
* POST /aeromc/v1/cape/remove { uuid } eigenen Umhang entfernen (Anmeldung Pflicht)
|
||||
* GET /aeromc/v1/cape?uuid= Umhang eines Spielers (öffentlich)
|
||||
* GET /aeromc/v1/capes?uuids=a,b,c mehrere auf einmal, für Launcher und Mod (öffentlich)
|
||||
*
|
||||
* Bilder werden streng geprüft (echtes PNG, Seitenverhältnis 2:1, 64×32 bis
|
||||
* 1024×512) und – falls GD verfügbar ist – neu kodiert, damit keine fremden
|
||||
* Daten im Bild mitreisen. Der Dateiname enthält einen Teil der Prüfsumme, so
|
||||
* ändert sich die Adresse bei jedem neuen Umhang (kein veralteter Zwischenspeicher).
|
||||
* Admins können Umhänge unter "AeroMC Fehler → Umhänge" löschen oder sperren.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('AEROMC_FRIENDS_CAPE_DIR', 'aeromc-umhaenge');
|
||||
define('AEROMC_FRIENDS_CAPE_MAX_BYTES', 3000000);
|
||||
define('AEROMC_FRIENDS_CAPE_MIN_GAP', 15); // Sekunden zwischen zwei Uploads
|
||||
define('AEROMC_FRIENDS_CAPE_MAX_UUIDS', 100); // je Sammelabfrage
|
||||
|
||||
function aeromc_friends_capes_table() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
|
||||
}
|
||||
|
||||
function aeromc_friends_cape_dir() {
|
||||
$uploads = wp_upload_dir();
|
||||
return [
|
||||
'pfad' => trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_CAPE_DIR,
|
||||
'url' => trailingslashit($uploads['baseurl']) . AEROMC_FRIENDS_CAPE_DIR,
|
||||
];
|
||||
}
|
||||
|
||||
/** Datei eines Umhangs löschen (nur Namen in unserem Schema). */
|
||||
function aeromc_friends_cape_delete_file($datei) {
|
||||
if (!$datei || !preg_match('/^[a-f0-9]{32}-[a-f0-9]{8}\.png$/', (string) $datei)) {
|
||||
return;
|
||||
}
|
||||
$ort = aeromc_friends_cape_dir();
|
||||
$voll = $ort['pfad'] . '/' . $datei;
|
||||
if (file_exists($voll)) {
|
||||
@unlink($voll);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zeile -> öffentliche Angaben */
|
||||
function aeromc_friends_cape_info($r) {
|
||||
$ort = aeromc_friends_cape_dir();
|
||||
return [
|
||||
'url' => $ort['url'] . '/' . $r->datei,
|
||||
'width' => (int) $r->breite,
|
||||
'height' => (int) $r->hoehe,
|
||||
'hash' => $r->hash,
|
||||
'updated' => $r->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein Umhang-Bild und gibt [daten, breite, hoehe] zurück oder WP_Error.
|
||||
*/
|
||||
function aeromc_friends_cape_check_image($base64) {
|
||||
$daten = base64_decode(preg_replace('/^data:image\/png;base64,/', '', (string) $base64), true);
|
||||
if ($daten === false || strlen($daten) < 60) {
|
||||
return new WP_Error('aeromc_invalid', 'Ungültiges Bild.', ['status' => 400]);
|
||||
}
|
||||
if (strlen($daten) > AEROMC_FRIENDS_CAPE_MAX_BYTES) {
|
||||
return new WP_Error('aeromc_too_big', 'Der Umhang ist zu groß (Datei).', ['status' => 413]);
|
||||
}
|
||||
if (strncmp($daten, "\x89PNG\r\n\x1A\n", 8) !== 0 || substr($daten, 12, 4) !== 'IHDR') {
|
||||
return new WP_Error('aeromc_invalid', 'Nur PNG-Bilder.', ['status' => 400]);
|
||||
}
|
||||
$kopf = unpack('Nbreite/Nhoehe', substr($daten, 16, 8));
|
||||
$breite = (int) $kopf['breite'];
|
||||
$hoehe = (int) $kopf['hoehe'];
|
||||
if (!in_array($breite, [64, 128, 256, 512, 1024], true) || $breite !== 2 * $hoehe) {
|
||||
return new WP_Error('aeromc_invalid', 'Ein Umhang muss 64×32, 128×64, 256×128, 512×256 oder 1024×512 Pixel groß sein.', ['status' => 400]);
|
||||
}
|
||||
// neu kodieren: entfernt alles außer den Bildpunkten
|
||||
if (function_exists('imagecreatefromstring')) {
|
||||
$bild = @imagecreatefromstring($daten);
|
||||
if (!$bild || imagesx($bild) !== $breite || imagesy($bild) !== $hoehe) {
|
||||
return new WP_Error('aeromc_invalid', 'Das Bild ist beschädigt.', ['status' => 400]);
|
||||
}
|
||||
imagealphablending($bild, false);
|
||||
imagesavealpha($bild, true);
|
||||
ob_start();
|
||||
imagepng($bild, null, 9);
|
||||
$daten = ob_get_clean();
|
||||
imagedestroy($bild);
|
||||
}
|
||||
return [$daten, $breite, $hoehe];
|
||||
}
|
||||
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_route('aeromc/v1', '/cape', [
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => 'aeromc_friends_route_cape_upload',
|
||||
'permission_callback' => '__return_true',
|
||||
],
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => 'aeromc_friends_route_cape_get',
|
||||
'permission_callback' => '__return_true',
|
||||
],
|
||||
]);
|
||||
register_rest_route('aeromc/v1', '/cape/remove', [
|
||||
'methods' => 'POST',
|
||||
'callback' => 'aeromc_friends_route_cape_remove',
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('aeromc/v1', '/capes', [
|
||||
'methods' => 'GET',
|
||||
'callback' => 'aeromc_friends_route_capes',
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
});
|
||||
|
||||
/** Anmeldung ist hier immer Pflicht – auch im Übergangsmodus (öffentlich sichtbare Bilder). */
|
||||
function aeromc_friends_cape_auth($request, $uuid) {
|
||||
$sitzung = aeromc_friends_check_auth($request, $uuid);
|
||||
if (is_wp_error($sitzung)) {
|
||||
return $sitzung;
|
||||
}
|
||||
if (!$sitzung) {
|
||||
return new WP_Error('aeromc_auth', 'Anmeldung erforderlich.', ['status' => 401]);
|
||||
}
|
||||
return $sitzung;
|
||||
}
|
||||
|
||||
function aeromc_friends_route_cape_upload($request) {
|
||||
global $wpdb;
|
||||
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
||||
if (!$uuid) {
|
||||
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
|
||||
}
|
||||
$sitzung = aeromc_friends_cape_auth($request, $uuid);
|
||||
if (is_wp_error($sitzung)) {
|
||||
return $sitzung;
|
||||
}
|
||||
$table = aeromc_friends_capes_table();
|
||||
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
|
||||
if ($alt && $alt->status === 'gesperrt') {
|
||||
return new WP_Error('aeromc_blocked', 'Dein Umhang wurde vom Betreiber gesperrt.', ['status' => 403]);
|
||||
}
|
||||
if ($alt && time() - strtotime($alt->updated_at . ' UTC') < AEROMC_FRIENDS_CAPE_MIN_GAP) {
|
||||
return new WP_Error('aeromc_too_fast', 'Bitte einen Moment warten, bevor du den Umhang erneut hochlädst.', ['status' => 429]);
|
||||
}
|
||||
|
||||
$geprueft = aeromc_friends_cape_check_image($request->get_param('image'));
|
||||
if (is_wp_error($geprueft)) {
|
||||
return $geprueft;
|
||||
}
|
||||
list($daten, $breite, $hoehe) = $geprueft;
|
||||
$hash = sha1($daten);
|
||||
|
||||
$ort = aeromc_friends_cape_dir();
|
||||
if (!wp_mkdir_p($ort['pfad'])) {
|
||||
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
|
||||
}
|
||||
if (!file_exists($ort['pfad'] . '/index.html')) {
|
||||
file_put_contents($ort['pfad'] . '/index.html', '');
|
||||
}
|
||||
$datei = $uuid . '-' . substr($hash, 0, 8) . '.png';
|
||||
if (file_put_contents($ort['pfad'] . '/' . $datei, $daten) === false) {
|
||||
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
|
||||
}
|
||||
if ($alt && $alt->datei !== $datei) {
|
||||
aeromc_friends_cape_delete_file($alt->datei);
|
||||
}
|
||||
$zeile = [
|
||||
'uuid' => $uuid,
|
||||
'username' => $sitzung->username,
|
||||
'datei' => $datei,
|
||||
'breite' => $breite,
|
||||
'hoehe' => $hoehe,
|
||||
'hash' => $hash,
|
||||
'status' => 'aktiv',
|
||||
'updated_at' => current_time('mysql', true),
|
||||
];
|
||||
$wpdb->replace($table, $zeile);
|
||||
return ['ok' => true, 'cape' => aeromc_friends_cape_info((object) $zeile)];
|
||||
}
|
||||
|
||||
function aeromc_friends_route_cape_remove($request) {
|
||||
global $wpdb;
|
||||
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
||||
if (!$uuid) {
|
||||
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
|
||||
}
|
||||
$sitzung = aeromc_friends_cape_auth($request, $uuid);
|
||||
if (is_wp_error($sitzung)) {
|
||||
return $sitzung;
|
||||
}
|
||||
$table = aeromc_friends_capes_table();
|
||||
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
|
||||
if ($alt) {
|
||||
aeromc_friends_cape_delete_file($alt->datei);
|
||||
if ($alt->status === 'gesperrt') {
|
||||
// Sperre bleibt bestehen, nur das Bild ist weg
|
||||
$wpdb->update($table, ['datei' => '', 'hash' => ''], ['uuid' => $uuid]);
|
||||
} else {
|
||||
$wpdb->delete($table, ['uuid' => $uuid]);
|
||||
}
|
||||
}
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function aeromc_friends_route_cape_get($request) {
|
||||
global $wpdb;
|
||||
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
|
||||
if (!$uuid) {
|
||||
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
|
||||
}
|
||||
$table = aeromc_friends_capes_table();
|
||||
$r = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s AND status = 'aktiv'", $uuid));
|
||||
return ['ok' => true, 'cape' => $r ? aeromc_friends_cape_info($r) : null];
|
||||
}
|
||||
|
||||
function aeromc_friends_route_capes($request) {
|
||||
global $wpdb;
|
||||
$liste = array_filter(array_map('aeromc_friends_normalize_uuid', explode(',', (string) $request->get_param('uuids'))));
|
||||
$liste = array_slice(array_values(array_unique($liste)), 0, AEROMC_FRIENDS_CAPE_MAX_UUIDS);
|
||||
$ergebnis = [];
|
||||
if ($liste) {
|
||||
$table = aeromc_friends_capes_table();
|
||||
$platz = implode(',', array_fill(0, count($liste), '%s'));
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE status = 'aktiv' AND uuid IN ($platz)", $liste
|
||||
));
|
||||
foreach ($rows as $r) {
|
||||
$ergebnis[$r->uuid] = aeromc_friends_cape_info($r);
|
||||
}
|
||||
}
|
||||
$antwort = new WP_REST_Response(['ok' => true, 'capes' => (object) $ergebnis]);
|
||||
// kurz zwischenspeichern lassen – der Mod fragt beim Betreten eines Servers viele auf einmal ab
|
||||
$antwort->header('Cache-Control', 'public, max-age=60');
|
||||
return $antwort;
|
||||
}
|
||||
|
||||
// ---------- Admin: Umhänge prüfen, löschen, sperren ----------
|
||||
if (is_admin()) {
|
||||
add_action('admin_menu', function () {
|
||||
add_submenu_page('aeromc-reports', 'AeroMC Umhänge', 'Umhänge', 'manage_options', 'aeromc-capes', 'aeromc_friends_capes_page');
|
||||
}, 20);
|
||||
}
|
||||
|
||||
function aeromc_friends_capes_page() {
|
||||
global $wpdb;
|
||||
if (!current_user_can('manage_options')) {
|
||||
return;
|
||||
}
|
||||
$table = aeromc_friends_capes_table();
|
||||
if (isset($_POST['aeromc_cape'], $_POST['aeromc_aktion']) && check_admin_referer('aeromc_capes')) {
|
||||
$uuid = aeromc_friends_normalize_uuid($_POST['aeromc_cape']);
|
||||
$r = $uuid ? $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid)) : null;
|
||||
if ($r) {
|
||||
$aktion = (string) $_POST['aeromc_aktion'];
|
||||
if ($aktion === 'loeschen') {
|
||||
aeromc_friends_cape_delete_file($r->datei);
|
||||
$wpdb->delete($table, ['uuid' => $uuid]);
|
||||
echo '<div class="notice notice-success"><p>Umhang von ' . esc_html($r->username) . ' gelöscht.</p></div>';
|
||||
} elseif ($aktion === 'sperren') {
|
||||
aeromc_friends_cape_delete_file($r->datei);
|
||||
$wpdb->update($table, ['status' => 'gesperrt', 'datei' => '', 'hash' => ''], ['uuid' => $uuid]);
|
||||
echo '<div class="notice notice-success"><p>' . esc_html($r->username) . ' gesperrt – der Umhang ist entfernt, neue Uploads werden abgelehnt.</p></div>';
|
||||
} elseif ($aktion === 'entsperren') {
|
||||
$wpdb->delete($table, ['uuid' => $uuid]);
|
||||
echo '<div class="notice notice-success"><p>Sperre für ' . esc_html($r->username) . ' aufgehoben.</p></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ort = aeromc_friends_cape_dir();
|
||||
$rows = $wpdb->get_results("SELECT * FROM $table ORDER BY updated_at DESC LIMIT 300");
|
||||
echo '<div class="wrap"><h1>AeroMC Umhänge</h1>';
|
||||
echo '<p>Eigene Umhänge, die Spieler im AeroMC-Skin-Editor hochgeladen haben. „Sperren“ entfernt den Umhang und verhindert neue Uploads dieses Spielers.</p>';
|
||||
if (!$rows) {
|
||||
echo '<p>Noch keine Umhänge hochgeladen.</p></div>';
|
||||
return;
|
||||
}
|
||||
echo '<table class="widefat striped"><thead><tr><th style="width:150px">Umhang</th><th>Spieler</th><th>Größe</th><th>Geändert (UTC)</th><th>Status</th><th></th></tr></thead><tbody>';
|
||||
foreach ($rows as $r) {
|
||||
$bild = $r->datei
|
||||
? '<img src="' . esc_url($ort['url'] . '/' . $r->datei) . '" alt="" style="width:128px;height:64px;image-rendering:pixelated;background:#ddd;border:1px solid #ccc" />'
|
||||
: '–';
|
||||
echo '<tr><td>' . $bild . '</td><td>' . esc_html($r->username) . '<br /><small>' . esc_html($r->uuid) . '</small></td>'
|
||||
. '<td>' . esc_html($r->breite . '×' . $r->hoehe) . '</td><td>' . esc_html($r->updated_at) . '</td><td>' . esc_html($r->status) . '</td><td>';
|
||||
echo '<form method="post" style="display:flex;gap:6px">';
|
||||
wp_nonce_field('aeromc_capes');
|
||||
echo '<input type="hidden" name="aeromc_cape" value="' . esc_attr($r->uuid) . '" />';
|
||||
if ($r->status === 'gesperrt') {
|
||||
echo '<button class="button" name="aeromc_aktion" value="entsperren">Sperre aufheben</button>';
|
||||
} else {
|
||||
echo '<button class="button" name="aeromc_aktion" value="loeschen" onclick="return confirm(\'Umhang löschen?\')">Löschen</button>';
|
||||
echo '<button class="button button-link-delete" name="aeromc_aktion" value="sperren" onclick="return confirm(\'Spieler sperren? Der Umhang wird entfernt und neue Uploads abgelehnt.\')">Sperren</button>';
|
||||
}
|
||||
echo '</form></td></tr>';
|
||||
}
|
||||
echo '</tbody></table></div>';
|
||||
}
|
||||