3 Commits
Author SHA1 Message Date
M_Viper 972a3e73da Upload via GUI (138 Dateien) 2026-10-03 14:20:38 +00:00
M_Viper e83c951256 Upload via GUI (138 Dateien) 2026-10-03 14:20:37 +00:00
M_Viper 67fe4cba48 Upload via GUI (138 Dateien) 2026-10-03 14:20:36 +00:00
38 changed files with 9651 additions and 7594 deletions
+18 -2
View File
@@ -2,7 +2,20 @@
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
## [1.1.6] - 2026-10-02 ## [1.1.7] - 2026-10-03
### Neu
- Umhänge im Skin-Fenster: offiziellen Mojang-Umhang auswählen oder ablegen (Liste zeigt alle Umhänge, die das Konto besitzt) und den eigenen AeroMC-Umhang verwalten – Figur und Skin-Hintergrund zeigen den Umhang mit
- Skin-Editor, neuer Modus „Umhang“: eigene Umhänge gestalten, auch in HD bis 1024×512 – mit allen Werkzeugen, Pinselgröße, Spiegeln (linke ↔ rechte Kante), Zoom und Verschieben, Rückgängig auch über Größenwechsel
- Umhang auf der 3D-Figur bemalen, wahlweise als Elytra ansehen, beliebiges Bild (PNG, JPG …) mit „Bild einsetzen…“ auf die Außenseite setzen, aktuellen Umhang laden, als PNG speichern oder mit „Als Umhang übernehmen“ hochladen
- AeroMC-Umhänge im Spiel: in jeder Minecraft-Version und mit jedem Loader (Vanilla, Fabric, Quilt, Forge, NeoForge) ohne zusätzliche Mod sichtbar – für einen selbst und für andere AeroMC-Spieler; abschaltbar unter Optionen → Spiel → Umhänge
- Mod „AeroMC Umhänge“ für Fabric und Quilt: damit sehen auch Spieler mit anderen Launchern (offizieller Launcher, MultiMC, Prism, Modrinth App …) die AeroMC-Umhänge – ein Jar für Minecraft 1.14 bis 26.x, nur auf dem Client nötig
### Geändert
- Suche in der Werkzeugleiste: statt des dauerhaft sichtbaren Suchfelds gibt es eine Lupe – ein Klick oder Strg+F klappt das Feld auf, Esc leert und schließt es, mit Suchbegriff bleibt es offen
- Benötigt für eigene Umhänge das Plugin „AeroMC Freunde“ 1.5.0 auf dem Server
## [1.1.6] - 2026-10-03
### Neu ### Neu
- Skin-Editor: Skins direkt im Launcher pixeln – erreichbar im Skin-Fenster über „Im Editor bearbeiten…“ - Skin-Editor: Skins direkt im Launcher pixeln – erreichbar im Skin-Fenster über „Im Editor bearbeiten…“
@@ -15,11 +28,14 @@ Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
- Aktuellen Skin laden, PNG öffnen oder speichern und das Ergebnis mit einem Klick als Skin übernehmen – klassische und schmale Arme, alte 64×32-Skins werden automatisch umgewandelt - Aktuellen Skin laden, PNG öffnen oder speichern und das Ergebnis mit einem Klick als Skin übernehmen – klassische und schmale Arme, alte 64×32-Skins werden automatisch umgewandelt
- Skin-Hintergrund: Armform wählbar (Optionen → Darstellung) – „Automatisch“ übernimmt die Armform des Kontos, alternativ fest „Normal“ oder „Schmal“ - Skin-Hintergrund: Armform wählbar (Optionen → Darstellung) – „Automatisch“ übernimmt die Armform des Kontos, alternativ fest „Normal“ oder „Schmal“
- Designs zur Auswahl (Optionen → Darstellung → Design, mit Vorschau-Kacheln): „Klassisch“ hell oder dunkel wie bisher, „AeroMC“ im Look der Webseite (Nachtblau, Cyan-Verläufe, runde Knöpfe), „Nachtlila“ (wie AeroMC, nur in dunklem Lila) und „Minecraft“ mit Block-Optik, Stein-Knöpfen, grünem Starten-Knopf und eigener Pixel-Schrift - Designs zur Auswahl (Optionen → Darstellung → Design, mit Vorschau-Kacheln): „Klassisch“ hell oder dunkel wie bisher, „AeroMC“ im Look der Webseite (Nachtblau, Cyan-Verläufe, runde Knöpfe), „Nachtlila“ (wie AeroMC, nur in dunklem Lila) und „Minecraft“ mit Block-Optik, Stein-Knöpfen, grünem Starten-Knopf und eigener Pixel-Schrift
- Ein gewähltes Design gilt sofort in allen offenen Fenstern - Ein Klick auf eine Kachel zeigt das Design sofort zur Probe, nach dem Speichern gilt es in allen offenen Fenstern
- Eigene Titelleiste im Design statt der weißen Windows-Leiste – Minimieren, Maximieren und Schließen bleiben echte Windows-Knöpfe (Andocken, Fensterlayouts) und werden passend eingefärbt - Eigene Titelleiste im Design statt der weißen Windows-Leiste – Minimieren, Maximieren und Schließen bleiben echte Windows-Knöpfe (Andocken, Fensterlayouts) und werden passend eingefärbt
- Import aus der Modrinth App (Importieren → „Modrinth App“, auch im Installer): übernimmt Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen – auch bei verlegtem Datenordner und während die Modrinth App läuft - Import aus der Modrinth App (Importieren → „Modrinth App“, auch im Installer): übernimmt Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen – auch bei verlegtem Datenordner und während die Modrinth App läuft
- Aus der Modrinth App übernommene Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC aktualisieren - Aus der Modrinth App übernommene Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich in AeroMC aktualisieren
### Geändert
- Die Design-Auswahl (bisher „Design: Dunkel/Hell“ unter Optionen → Allgemein) steht jetzt unter Optionen → Darstellung
### Behoben ### Behoben
- Das Symbol für „Optionen“ sah aus wie eine Sonne – jetzt ein Zahnrad - Das Symbol für „Optionen“ sah aus wie eine Sonne – jetzt ein Zahnrad
- Der Skin-Hintergrund zeigte schmale (Alex-)Skins mit breiten Armen – dadurch erschienen falsche Streifen an den Armen - Der Skin-Hintergrund zeigte schmale (Alex-)Skins mit breiten Armen – dadurch erschienen falsche Streifen an den Armen
+32 -2
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung. Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
Aktueller Stand: Version 1.1.6 Aktueller Stand: Version 1.1.7
<img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" /> <img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" />
@@ -65,6 +65,8 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Microsoft-Login mit mehreren Konten - Microsoft-Login mit mehreren Konten
- Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen - Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen
- **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig - **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
- **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“
- Offiziellen Mojang-Umhang direkt im Launcher wechseln oder ablegen
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd) - Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten - Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt) - Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
@@ -162,7 +164,7 @@ Im Launcher:
| Taste | Wirkung | | Taste | Wirkung |
|---|---| |---|---|
| Strg+F | Suche fokussieren | | Strg+F | Suche öffnen (Lupe in der Werkzeugleiste), Esc leert und schließt sie |
| Strg+N | Neue Instanz anlegen | | Strg+N | Neue Instanz anlegen |
| Strg+1 … Strg+5 | Schnellstart-Instanz starten | | Strg+1 … Strg+5 | Schnellstart-Instanz starten |
| Entf | Markierte Instanz(en) löschen | | Entf | Markierte Instanz(en) löschen |
@@ -279,6 +281,33 @@ Die Änderung wird direkt bei Mojang für das angemeldete Konto hinterlegt und g
| M | Spiegeln ein/aus | | M | Spiegeln ein/aus |
| L | Schicht wechseln | | L | Schicht wechseln |
| Strg+Z / Strg+Y | Rückgängig / Wiederholen | | Strg+Z / Strg+Y | Rückgängig / Wiederholen |
| 0 | Umhang: Ansicht einpassen |
### Umhänge
Im Skin-Fenster (**Kontoname oben rechts → Skin ändern…**) gibt es den Bereich **Umhang**:
- **Offizieller Umhang:** zeigt alle Umhänge, die dein Konto bei Mojang besitzt (z. B. Migrator, Pan …) – auswählen oder „Kein Umhang“. Das gilt wie der Skin überall, auch außerhalb von AeroMC.
- **AeroMC-Umhang:** dein eigener Umhang, auch in HD. **Gestalten…** öffnet den Skin-Editor im Modus **Umhang**, **Entfernen** löscht ihn wieder.
Im Editor-Modus **Umhang**:
- Größe wählen: Normal (64×32) oder HD 128×64, 256×128, 512×256, 1024×512
- Alle Werkzeuge wie beim Skin, dazu Pinselgröße (1–16 Pixel); Spiegeln malt linke und rechte Kante gleichzeitig
- Mausrad zoomt, rechte oder mittlere Maustaste verschiebt die Ansicht, **0** passt sie wieder ein
- In der 3D-Ansicht direkt auf dem Umhang an der Figur malen; **Als Elytra zeigen** zeigt dieselbe Datei als Flügel
- **Bild einsetzen…** setzt ein beliebiges Bild (PNG, JPG …) auf die Außenseite
- **Aktuellen Umhang laden**, PNG öffnen oder speichern, **Als Umhang übernehmen** lädt ihn hoch
**Wer sieht den AeroMC-Umhang?**
| Spieler | sieht den AeroMC-Umhang? |
|---|---|
| Spielt über AeroMC (Optionen → Spiel → Umhänge eingeschaltet) | Ja – in jeder Minecraft-Version und mit jedem Loader, ohne Mod |
| Anderer Launcher mit Fabric oder Quilt und dem Mod **„AeroMC Umhänge“** | Ja – ein Jar für Minecraft 1.14 bis 26.x, nur auf dem Client nötig |
| Anderer Launcher ohne den Mod (auch mit OptiFine) | Nein – sieht weiter den offiziellen Mojang-Umhang |
Der AeroMC-Umhang hat Vorrang vor dem offiziellen Umhang. Benötigt das Plugin „AeroMC Freunde“ 1.5.0 auf dem Server.
## Backup und Wiederherstellung ## Backup und Wiederherstellung
@@ -312,6 +341,7 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
### Spiel ### Spiel
- Standard-RAM (min./max.) - Standard-RAM (min./max.)
- Umhänge: AeroMC-Umhänge im Spiel anzeigen (gilt ab dem nächsten Start)
- Fenstergröße bzw. maximiert starten - Fenstergröße bzw. maximiert starten
- Mods: vor jedem Start automatisch nach Updates suchen und installieren - Mods: vor jedem Start automatisch nach Updates suchen und installieren
+42
View File
@@ -0,0 +1,42 @@
package de.aeromc.capes;
import java.lang.instrument.Instrumentation;
/**
* AeroMC-Umhang-Agent.
*
* Wird vom Launcher per -javaagent an Minecraft angehängt (jede Version, jeder
* Loader). Er klinkt sich in Mojangs authlib ein – über sie lädt Minecraft für
* jeden Spieler Skin und Umhang – und setzt dort den AeroMC-Umhang des Spielers
* ein, falls er einen hat. Alles Weitere (Herunterladen, Zwischenspeichern,
* Anzeigen, auch HD) erledigt Minecraft selbst wie bei einem offiziellen Umhang.
*
* Argument: Basisadresse des Umhang-Servers (…/wp-json/aeromc/v1).
*/
public final class Agent {
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
public static final String SCHLUESSEL = "aeromc.capes.hook";
private Agent() {}
public static void premain(String argument, Instrumentation inst) {
starte(argument, inst);
}
public static void agentmain(String argument, Instrumentation inst) {
starte(argument, inst);
}
private static void starte(String argument, Instrumentation inst) {
try {
String basis = argument != null && argument.startsWith("http") ? argument : Nachschlagen.STANDARD_BASIS;
// Die eingefügten Befehle in authlib holen sich den Dienst von hier – nur
// über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt.
System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis));
inst.addTransformer(new Umbau());
System.out.println("[AeroMC] Umhang-Agent aktiv");
} catch (Throwable t) {
System.out.println("[AeroMC] Umhang-Agent konnte nicht starten: " + t);
}
}
}
+183
View File
@@ -0,0 +1,183 @@
package de.aeromc.capes;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Wird aus authlib heraus mit (ergebnis, argument) aufgerufen und gibt das
* Ergebnis zurück – mit eingesetztem AeroMC-Umhang, falls der Spieler einen hat.
* Arbeitet nur über Reflexion, damit es zu jeder authlib-Fassung passt, und
* fängt jeden Fehler ab: im schlimmsten Fall bleibt alles wie ohne Agent.
*/
public final class Nachschlagen implements BiFunction<Object, Object, Object> {
public static final String STANDARD_BASIS = "https://viper-network.de/wp-json/aeromc/v1";
private static final long GEFUNDEN_MS = 5 * 60 * 1000L;
private static final long KEINER_MS = 2 * 60 * 1000L;
private static final Pattern URL_MUSTER = Pattern.compile("\"url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
private static final Pattern ID_MUSTER = Pattern.compile("\"profileId\"\\s*:\\s*\"([0-9a-fA-F-]{32,36})\"");
private static final class Eintrag {
final String url; // null = kein Umhang
final long zeit;
Eintrag(String url) { this.url = url; this.zeit = System.currentTimeMillis(); }
boolean frisch() { return System.currentTimeMillis() - zeit < (url != null ? GEFUNDEN_MS : KEINER_MS); }
}
private final String basis;
private final Map<String, Eintrag> speicher = new ConcurrentHashMap<>();
private final Map<String, Boolean> unterwegs = new ConcurrentHashMap<>();
private final Map<String, Boolean> gemeldet = new ConcurrentHashMap<>();
public Nachschlagen(String basis) {
this.basis = basis.endsWith("/") ? basis.substring(0, basis.length() - 1) : basis;
}
@Override
public Object apply(Object ergebnis, Object argument) {
try {
String id = spielerId(argument);
if (id == null) return ergebnis;
String url = umhangUrl(id);
if (url == null) return ergebnis;
Object neu = mitUmhang(ergebnis, url, argument);
if (neu != ergebnis && gemeldet.putIfAbsent(id, Boolean.TRUE) == null) {
System.out.println("[AeroMC] Umhang fuer " + id + " eingesetzt");
}
return neu;
} catch (Throwable t) {
return ergebnis;
}
}
// ---------- Spieler erkennen ----------
private static String spielerId(Object argument) throws Exception {
if (argument == null) return null;
String klasse = argument.getClass().getName();
if (klasse.endsWith("GameProfile")) {
Object id = rufe(argument, "getId", "id");
return id instanceof UUID ? hex((UUID) id) : null;
}
if (klasse.endsWith("Property")) {
Object wert = rufe(argument, "getValue", "value");
if (!(wert instanceof String)) return null;
String json = new String(Base64.getDecoder().decode(((String) wert).trim()), StandardCharsets.UTF_8);
Matcher m = ID_MUSTER.matcher(json);
return m.find() ? m.group(1).replace("-", "").toLowerCase() : null;
}
return null;
}
private static String hex(UUID u) {
return u.toString().replace("-", "").toLowerCase();
}
private static Object rufe(Object ziel, String... namen) throws Exception {
for (String n : namen) {
try {
Method m = ziel.getClass().getMethod(n);
return m.invoke(ziel);
} catch (NoSuchMethodException ignoriert) {
// nächster Name
}
}
return null;
}
// ---------- Umhang-Server ----------
private String umhangUrl(String id) {
Eintrag e = speicher.get(id);
if (e != null && e.frisch()) return e.url;
// Auf dem Haupt-/Render-Thread nie warten: dort nur im Hintergrund holen;
// Minecraft fragt beim nächsten Laden des Spielers erneut
String thread = Thread.currentThread().getName();
boolean hauptThread = thread.equals("Render thread") || thread.equals("Client thread") || thread.equals("main");
if (hauptThread) {
if (unterwegs.putIfAbsent(id, Boolean.TRUE) == null) {
Thread t = new Thread(() -> { try { hole(id); } finally { unterwegs.remove(id); } }, "AeroMC-Umhang");
t.setDaemon(true);
t.start();
}
return e != null ? e.url : null;
}
return hole(id);
}
private String hole(String id) {
try {
HttpURLConnection c = (HttpURLConnection) new URL(basis + "/capes?uuids=" + id).openConnection();
c.setConnectTimeout(2000);
c.setReadTimeout(3000);
c.setRequestProperty("User-Agent", "AeroMC-Umhang/1.0");
c.setRequestProperty("Accept", "application/json");
if (c.getResponseCode() != 200) {
speicher.put(id, new Eintrag(null));
return null;
}
String json;
try (InputStream in = c.getInputStream()) {
ByteArrayOutputStream puffer = new ByteArrayOutputStream();
byte[] b = new byte[4096];
int n;
while ((n = in.read(b)) > 0 && puffer.size() < 65536) puffer.write(b, 0, n);
json = new String(puffer.toByteArray(), StandardCharsets.UTF_8);
}
String url = null;
Matcher m = URL_MUSTER.matcher(json);
if (json.contains("\"" + id + "\"") && m.find()) {
url = m.group(1).replace("\\/", "/");
if (!url.startsWith("https://")) url = null;
}
speicher.put(id, new Eintrag(url));
return url;
} catch (Throwable t) {
// Server nicht erreichbar: kurz merken, damit nicht jeder Spieler hängt
speicher.put(id, new Eintrag(null));
return null;
}
}
// ---------- Umhang einsetzen ----------
private static Object textur(ClassLoader cl, String url) throws Exception {
Class<?> k = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture", false, cl);
Constructor<?> ctor = k.getConstructor(String.class, Map.class);
return ctor.newInstance(url, null);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static Object mitUmhang(Object ergebnis, String url, Object argument) throws Exception {
ClassLoader cl = argument.getClass().getClassLoader();
if (ergebnis instanceof Map) {
// authlib 1–4: Map<MinecraftProfileTexture.Type, MinecraftProfileTexture>
Class<?> typ = Class.forName("com.mojang.authlib.minecraft.MinecraftProfileTexture$Type", false, cl);
Object cape = Enum.valueOf((Class<Enum>) typ, "CAPE");
Map neu = new HashMap((Map) ergebnis);
neu.put(cape, textur(cl, url));
return neu;
}
if (ergebnis == null) return null;
// authlib 5+: record MinecraftProfileTextures(skin, cape, elytra, signatureState)
Class<?> k = ergebnis.getClass();
Object skin = k.getMethod("skin").invoke(ergebnis);
Object elytra = k.getMethod("elytra").invoke(ergebnis);
Object signatur = k.getMethod("signatureState").invoke(ergebnis);
for (Constructor<?> ctor : k.getConstructors()) {
if (ctor.getParameterCount() == 4) {
return ctor.newInstance(skin, textur(cl, url), elytra, signatur);
}
}
return ergebnis;
}
}
+78
View File
@@ -0,0 +1,78 @@
package de.aeromc.capes;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
/**
* Baut YggdrasilMinecraftSessionService beim Laden um: vor jedem "return" der
* Textur-Methode wird das Ergebnis an den Nachschlage-Dienst gereicht, der den
* AeroMC-Umhang einsetzt.
*
* authlib 5+ (Minecraft 1.20.2 … 26.x):
* MinecraftProfileTextures unpackTextures(Property)
* authlib 1–4 (Minecraft 1.8 … 1.20.1):
* Map&lt;Type, MinecraftProfileTexture&gt; getTextures(GameProfile, boolean)
*
* Eingefügt wird nur geradliniger Code ohne Sprünge – die vorhandenen
* Stack-Map-Frames bleiben gültig, es muss nichts neu berechnet werden.
*/
final class Umbau implements ClassFileTransformer {
static final String ZIEL = "com/mojang/authlib/yggdrasil/YggdrasilMinecraftSessionService";
@Override
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
if (!ZIEL.equals(name) || umdefiniert != null) return null;
try {
ClassReader leser = new ClassReader(klasse);
ClassWriter schreiber = new ClassWriter(leser, ClassWriter.COMPUTE_MAXS);
final int[] umgebaut = {0};
leser.accept(new ClassVisitor(Opcodes.ASM9, schreiber) {
@Override
public MethodVisitor visitMethod(int zugriff, String mname, String desc, String sig, String[] ausn) {
MethodVisitor mv = super.visitMethod(zugriff, mname, desc, sig, ausn);
if ((zugriff & Opcodes.ACC_STATIC) != 0) return mv;
String rueckgabe = null;
if ("unpackTextures".equals(mname) && desc.startsWith("(Lcom/mojang/authlib/properties/Property;)L")) {
rueckgabe = desc.substring(desc.indexOf(")L") + 2, desc.length() - 1);
} else if ("getTextures".equals(mname) && desc.equals("(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;")) {
rueckgabe = "java/util/Map";
}
if (rueckgabe == null) return mv;
umgebaut[0]++;
final String typ = rueckgabe;
return new MethodVisitor(Opcodes.ASM9, mv) {
@Override
public void visitInsn(int op) {
if (op == Opcodes.ARETURN) {
// Stapel: [ergebnis] -> [dienst, ergebnis, argument] -> apply -> [neu]
super.visitVarInsn(Opcodes.ALOAD, 1);
super.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false);
super.visitLdcInsn(Agent.SCHLUESSEL);
super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false);
super.visitTypeInsn(Opcodes.CHECKCAST, "java/util/function/BiFunction");
super.visitInsn(Opcodes.DUP_X2);
super.visitInsn(Opcodes.POP);
super.visitMethodInsn(Opcodes.INVOKEINTERFACE, "java/util/function/BiFunction", "apply",
"(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", true);
super.visitTypeInsn(Opcodes.CHECKCAST, typ);
}
super.visitInsn(op);
}
};
}
}, 0);
if (umgebaut[0] == 0) return null;
System.out.println("[AeroMC] authlib fuer Umhaenge vorbereitet (" + umgebaut[0] + " Methode)");
return schreiber.toByteArray();
} catch (Throwable t) {
System.out.println("[AeroMC] authlib konnte nicht angepasst werden: " + t);
return null;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.Enumeration;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.commons.ClassRemapper;
import org.objectweb.asm.commons.Remapper;
/**
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
*
* Aufruf: java Shade &lt;ziel.jar&gt; &lt;klassenordner&gt; &lt;asm.jar&gt;
*/
public class Shade {
static final Remapper UMBENENNEN = new Remapper() {
@Override
public String map(String name) {
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
}
};
static byte[] umbenennen(byte[] klasse) {
ClassReader r = new ClassReader(klasse);
ClassWriter w = new ClassWriter(0);
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
return w.toByteArray();
}
static byte[] lies(InputStream in) throws Exception {
ByteArrayOutputStream b = new ByteArrayOutputStream();
byte[] p = new byte[8192];
int n;
while ((n = in.read(p)) > 0) b.write(p, 0, n);
return b.toByteArray();
}
public static void main(String[] a) throws Exception {
Manifest mf = new Manifest();
Attributes at = mf.getMainAttributes();
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
at.putValue("Can-Retransform-Classes", "false");
at.putValue("Implementation-Title", "AeroMC Umhang-Agent");
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
// eigene Klassen
File basis = new File(a[1]);
for (File f : Files.walk(basis.toPath()).map(p -> p.toFile()).filter(f -> f.getName().endsWith(".class")).toArray(File[]::new)) {
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
out.putNextEntry(new JarEntry(name));
out.write(umbenennen(Files.readAllBytes(f.toPath())));
out.closeEntry();
}
// ASM (umbenannt), ohne Modul-Infos und Signaturen
try (JarFile asm = new JarFile(a[2])) {
Enumeration<JarEntry> e = asm.entries();
while (e.hasMoreElements()) {
JarEntry je = e.nextElement();
String n = je.getName();
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
out.write(neu);
out.closeEntry();
}
}
}
}
}
@@ -5,7 +5,7 @@
}, },
"projects": { "projects": {
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": { "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
"version": "1.1.6", "version": "0.0.0",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
Binary file not shown.
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")] [assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")] [assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.6.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.7.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.6")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.7")]
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.1.6.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.1.7.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")] [assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert. // Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -1 +1 @@
5b6f28fb41c3f0e5fe29f9489cfc618a7d6593efb71d420030e4a1d4fdb4066e acf80df582ccd59574a6332950a42ff0628e88f4288eb303a60e8577b37c7bef
+1 -1
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {} "C:\\Users\\Viper\\.nuget\\packages\\": {}
}, },
"project": { "project": {
"version": "1.1.6", "version": "0.0.0",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "lJiGEWCFg6M=", "dgSpecHash": "FnRoda2hVew=",
"success": true, "success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
+42
View File
@@ -0,0 +1,42 @@
# AeroMC Capes
Shows the **custom capes (including HD up to 1024×512)** of players who designed their cape in the
[AeroMC Launcher](https://aeromc.viper.ipv64.net) – right in the game, even if you use a different
launcher yourself.
## How it works
- Drop the mod into your `mods` folder – done. No configuration, no Fabric API required.
- Whenever Minecraft loads a player's skin, the mod asks the AeroMC server whether that player has
an AeroMC cape and shows it instead of the regular cape.
- If a player has no AeroMC cape, nothing changes (official Mojang cape or none).
- **Client-side only** – servers don't need anything.
## Compatibility
- **Fabric** and **Quilt**
- **One jar for all versions**: Minecraft 1.14 to 26.x
- Works with old Fabric Loader versions too (0.10 and newer)
- Players using the AeroMC Launcher don't need this mod (the launcher already does this on its
own) – but having it installed doesn't hurt either.
## Privacy
The mod only sends the (public) UUIDs of players whose skins are currently being loaded to
`viper-network.de` to look up their capes. Results are cached for a few minutes.
No account or login data is ever transmitted.
## Create your own cape
In the AeroMC Launcher: click your account name (top right) → **Skin ändern…** (Change skin) →
in the **Umhang** (Cape) section click **Gestalten…** (Design). You can paint your cape pixel by
pixel or insert any image, in sizes from 64×32 up to HD 1024×512.
---
### Hinweise zum Hochladen (nicht Teil der Beschreibung)
- Name: AeroMC Capes (URL bleibt `aeromc-umhaenge`)
- Kurzbeschreibung: Shows custom capes (HD up to 1024×512) made in the AeroMC Launcher – even if you use another launcher. One jar for 1.14–26.x, client-side.
- Datei: `dist/aeromc-umhaenge-fabric-1.0.0.jar`
- Loader: Fabric, Quilt · Umgebung: Client · Server: nicht nötig
- Spielversionen: alle Releases von 1.14 bis zur aktuellen
- Kategorien: Sozial (hervorgehoben), Werkzeug („Cosmetic“ gibt es für Mods nicht)
- Abhängigkeiten: keine
- Lizenz: aktuell „All Rights Reserved“ (in `fabric.mod.json`) – Modrinth akzeptiert das, eine
offene Lizenz (z. B. MIT) wirkt aber vertrauenswürdiger.
+10
View File
@@ -0,0 +1,10 @@
{
"required": false,
"minVersion": "0.8",
"package": "de.aeromc.capes.mixin",
"compatibilityLevel": "JAVA_8",
"client": ["SessionServiceMixin", "SkinLaderMixin"],
"injectors": {
"defaultRequire": 0
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"id": "aeromc-capes",
"version": "1.0.0",
"name": "AeroMC Umhänge",
"description": "Zeigt die eigenen Umhänge (auch HD) von AeroMC-Spielern im Spiel – gestaltet im AeroMC Launcher. Nur auf dem Client nötig. Funktioniert mit jeder Minecraft-Version, die Fabric unterstützt.",
"authors": ["M_Viper"],
"contact": {
"homepage": "https://aeromc.viper.ipv64.net"
},
"license": "LicenseRef-All-Rights-Reserved",
"icon": "assets/aeromc-capes/icon.png",
"environment": "client",
"mixins": ["aeromc-capes.mixins.json"],
"depends": {
"fabricloader": ">=0.10.0"
}
}
@@ -0,0 +1,48 @@
package de.aeromc.capes.mixin;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.Property;
import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService;
import de.aeromc.capes.mod.Umhaenge;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Hängt sich an die Textur-Methoden von authlib (beide Fassungen, siehe
* agent/…/Umbau.java). require = 0: Es greift immer nur die Methode, die es in
* der jeweiligen authlib-Fassung gibt, die andere wird still übersprungen.
* remap = false: authlib ist nicht verschleiert, Namen bleiben wie sie sind.
*/
@Mixin(value = YggdrasilMinecraftSessionService.class, remap = false)
public abstract class SessionServiceMixin {
// wird in den statischen Initialisierer von authlib übernommen: zeigt im Spielprotokoll, dass der Mod greift
static {
Umhaenge.authlibGreift = true;
Umhaenge.meldeAktiv("authlib");
}
// authlib 5+ (Minecraft 1.20.2 … 26.x)
@Inject(method = "unpackTextures", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
private void aeromc$neu(Property eigenschaft, CallbackInfoReturnable<Object> cir) {
aeromc$ersetze(cir, eigenschaft);
}
// authlib 1–4 (Minecraft 1.14 … 1.20.1)
@Inject(method = "getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;", at = @At("RETURN"), cancellable = true, remap = false, require = 0)
private void aeromc$alt(GameProfile profil, boolean sicher, CallbackInfoReturnable<Object> cir) {
aeromc$ersetze(cir, profil);
}
private static void aeromc$ersetze(CallbackInfoReturnable<Object> cir, Object argument) {
try {
Object alt = cir.getReturnValue();
Object neu = Umhaenge.mitUmhang(alt, argument);
if (neu != alt) cir.setReturnValue(neu);
} catch (Throwable ignoriert) {
// im Zweifel bleibt alles wie ohne Mod
}
}
}
@@ -0,0 +1,38 @@
package de.aeromc.capes.mixin;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.minecraft.MinecraftSessionService;
import de.aeromc.capes.mod.Umhaenge;
import net.minecraft.class_1071;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import java.util.Map;
/**
* Zweiter Weg für alte Fabric Loader (vor 0.12): Die lassen Mixins nur in
* Minecraft selbst zu, nicht in authlib – SessionServiceMixin greift dort also
* nie. Deshalb wird hier der Aufruf von getTextures im Skin-Lader von Minecraft
* (PlayerSkinProvider = class_1071, Intermediary-Name, gleich in 1.14 … 1.20.1)
* umgeleitet. Bei neuen Loadern erledigt das bereits SessionServiceMixin, dann
* reicht diese Umleitung das Ergebnis nur unverändert durch.
*/
@Mixin(value = class_1071.class, targets = "net.minecraft.class_1071$1", remap = false)
public abstract class SkinLaderMixin {
@Redirect(method = "*", at = @At(value = "INVOKE", remap = false,
target = "Lcom/mojang/authlib/minecraft/MinecraftSessionService;getTextures(Lcom/mojang/authlib/GameProfile;Z)Ljava/util/Map;"),
remap = false, require = 0)
private Map aeromc$texturen(MinecraftSessionService dienst, GameProfile profil, boolean sicher) {
Map texturen = dienst.getTextures(profil, sicher);
if (Umhaenge.authlibGreift) return texturen;
Umhaenge.meldeAktiv("Skin-Lader");
try {
Object neu = Umhaenge.mitUmhang(texturen, profil);
return neu instanceof Map ? (Map) neu : texturen;
} catch (Throwable ignoriert) {
return texturen;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package de.aeromc.capes.mod;
import de.aeromc.capes.Nachschlagen;
/**
* AeroMC Umhänge – Fabric/Quilt-Mod für Spieler, die NICHT den AeroMC Launcher
* nutzen. Er macht dasselbe wie der Umhang-Agent des Launchers: In Mojangs
* authlib wird der AeroMC-Umhang eines Spielers eingesetzt, sobald Minecraft
* dessen Skin lädt. Da nur authlib angepasst wird (nicht Minecraft selbst),
* passt ein und derselbe Mod zu jeder Minecraft-Version, die Fabric kennt.
* Alte Fabric Loader (vor 0.12) erlauben keine Mixins in authlib, dort greift
* stattdessen SkinLaderMixin im Skin-Lader von Minecraft.
*/
public final class Umhaenge {
private static final Nachschlagen DIENST = new Nachschlagen(Nachschlagen.STANDARD_BASIS);
/** true, sobald SessionServiceMixin in authlib eingebaut wurde (dann ist SkinLaderMixin überflüssig). */
public static volatile boolean authlibGreift;
private static volatile boolean gemeldet;
private Umhaenge() {}
/** Schreibt einmalig ins Spielprotokoll, dass (und über welchen Weg) der Mod greift. */
public static void meldeAktiv(String weg) {
if (gemeldet || agentAktiv()) return;
gemeldet = true;
System.out.println("[AeroMC] Umhang-Mod aktiv (" + weg + ")");
}
/** Läuft das Spiel über den AeroMC Launcher, setzt schon dessen Agent die Umhänge ein. */
public static boolean agentAktiv() {
return System.getProperties().containsKey("aeromc.capes.hook");
}
public static Object mitUmhang(Object ergebnis, Object argument) {
if (agentAktiv()) return ergebnis;
return DIENST.apply(ergebnis, argument);
}
}
+4
View File
@@ -0,0 +1,4 @@
package net.minecraft;
/** Nur zum Kompilieren: Platzhalter für Minecrafts PlayerSkinProvider (Intermediary-Name). Kommt nicht ins Mod-Jar. */
public class class_1071 {}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aeromc", "name": "aeromc",
"version": "1.1.6", "version": "1.1.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aeromc", "name": "aeromc",
"version": "1.1.6", "version": "1.1.7",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@xhayper/discord-rpc": "^1.3.4", "@xhayper/discord-rpc": "^1.3.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.1.6", "version": "1.1.7",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de", "homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net", "launcherWebsite": "https://aeromc.viper.ipv64.net",
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/*
* build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent)
* ------------------------------------------------------------------------------
* Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
* Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab).
* Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version.
*
* Aufruf: node scripts/build-agent.js
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const WURZEL = path.join(__dirname, '..');
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
function findeJdk() {
const kandidaten = [];
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
if (fs.existsSync(adoptium)) {
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
}
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
return jdk;
}
function findeAsm(name) {
const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name);
if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`);
const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v))
.sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0));
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
return datei;
}
function quellen(ordner) {
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
const p = path.join(ordner, e.name);
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
});
}
const jdk = findeJdk();
const javac = path.join(jdk, 'bin', 'javac');
const java = path.join(jdk, 'bin', 'java');
const asm = findeAsm('asm');
const asmCommons = findeAsm('asm-commons');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
const klassen = path.join(tmp, 'klassen');
const werkzeug = path.join(tmp, 'werkzeug');
console.log('JDK:', jdk);
console.log('ASM:', asm);
try {
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen,
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' });
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
+83
View File
@@ -0,0 +1,83 @@
'use strict';
/*
* build-mod.js – baut den Fabric/Quilt-Mod "AeroMC Umhänge" (dist/aeromc-umhaenge-fabric-<version>.jar)
* -------------------------------------------------------------------------------------------------
* Für Spieler, die NICHT den AeroMC Launcher nutzen. Der Mod greift wie der
* Umhang-Agent nur in Mojangs authlib ein (Mixin, ohne Namensumsetzung) – darum
* braucht es weder Gradle noch Fabric Loom, und eine Datei passt zu allen
* Minecraft-Versionen, die Fabric unterstützt. Übersetzt wird für Java 8.
*
* Gebraucht werden (nur beim Bauen) ein JDK sowie sponge-mixin und authlib aus
* dem Bibliotheksordner von AeroMC. Der Nachschlage-Code ist derselbe wie im
* Agenten (agent/src/de/aeromc/capes/Nachschlagen.java).
*
* Aufruf: node scripts/build-mod.js
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const WURZEL = path.join(__dirname, '..');
const LIB = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries');
const modInfo = JSON.parse(fs.readFileSync(path.join(WURZEL, 'mod', 'resources', 'fabric.mod.json'), 'utf8'));
const ZIEL = path.join(WURZEL, 'dist', `aeromc-umhaenge-fabric-${modInfo.version}.jar`);
function findeJdk() {
const kandidaten = [];
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
if (fs.existsSync(adoptium)) {
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
}
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
return jdk;
}
// neueste (oder mit aelteste=true älteste) Fassung einer Bibliothek im AeroMC-Bibliotheksordner
function bibliothek(gruppe, name, aelteste = false) {
const basis = path.join(LIB, ...gruppe.split('.'), name);
if (!fs.existsSync(basis)) throw new Error(`${name} nicht gefunden unter ${basis}`);
const fassungen = fs.readdirSync(basis).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const v = aelteste ? fassungen[0] : fassungen.pop();
const datei = path.join(basis, v, `${name}-${v}.jar`);
if (!fs.existsSync(datei)) throw new Error('Datei fehlt: ' + datei);
return datei;
}
function quellen(ordner) {
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
const p = path.join(ordner, e.name);
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
});
}
// authlib in der ältesten Fassung: SkinLaderMixin ruft getTextures(GameProfile, boolean) auf, das es nur bis authlib 4 gibt
const jdk = findeJdk();
const klassenpfad = [bibliothek('net.fabricmc', 'sponge-mixin'), bibliothek('com.mojang', 'authlib', true)].join(path.delimiter);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-mod-'));
const inhalt = path.join(tmp, 'inhalt');
const stubs = path.join(tmp, 'stubs');
console.log('JDK:', jdk);
try {
// Platzhalter für Minecraft-Klassen (nur zum Kompilieren, landen nicht im Jar)
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-d', stubs,
...quellen(path.join(WURZEL, 'mod', 'stubs'))], { stdio: 'inherit' });
execFileSync(path.join(jdk, 'bin', 'javac'), ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-proc:none', '-cp', klassenpfad + path.delimiter + stubs, '-d', inhalt,
...quellen(path.join(WURZEL, 'mod', 'src')),
path.join(WURZEL, 'agent', 'src', 'de', 'aeromc', 'capes', 'Nachschlagen.java')], { stdio: 'inherit' });
for (const datei of fs.readdirSync(path.join(WURZEL, 'mod', 'resources'))) {
fs.copyFileSync(path.join(WURZEL, 'mod', 'resources', datei), path.join(inhalt, datei));
}
fs.mkdirSync(path.join(inhalt, 'assets', 'aeromc-capes'), { recursive: true });
fs.copyFileSync(path.join(WURZEL, 'src', 'assets', 'logo.png'), path.join(inhalt, 'assets', 'aeromc-capes', 'icon.png'));
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
if (fs.existsSync(ZIEL)) fs.rmSync(ZIEL);
execFileSync(path.join(jdk, 'bin', 'jar'), ['--create', '--file', ZIEL, '-C', inhalt, '.'], { stdio: 'inherit' });
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
Binary file not shown.
+2 -2
View File
@@ -73,7 +73,7 @@
:root:is([data-design="aero"],[data-design="lila"]) .tbtn:active{background:var(--surface2)} :root:is([data-design="aero"],[data-design="lila"]) .tbtn:active{background:var(--surface2)}
:root:is([data-design="aero"],[data-design="lila"]) .tbtn svg, :root:is([data-design="aero"],[data-design="lila"]) .tbtn svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-new svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-import svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-open-dir svg, :root:is([data-design="aero"],[data-design="lila"]) #btn-new svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-import svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-open-dir svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-console svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-account svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-settings svg, :root:is([data-design="aero"],[data-design="lila"]) #btn-search svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-console svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-account svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-settings svg,
:root:is([data-design="aero"],[data-design="lila"]) .acct-av svg{color:var(--cyan-soft)} :root:is([data-design="aero"],[data-design="lila"]) .acct-av svg{color:var(--cyan-soft)}
:root:is([data-design="aero"],[data-design="lila"]) .tbsep{background:var(--border)} :root:is([data-design="aero"],[data-design="lila"]) .tbsep{background:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px;padding:5px 12px;width:150px} :root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px;padding:5px 12px;width:150px}
@@ -270,7 +270,7 @@
:root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto} :root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto}
:root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff} :root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff}
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)} :root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
:root[data-design="minecraft"] .tbtn svg,:root[data-design="minecraft"] #btn-console svg,:root[data-design="minecraft"] .acct-av svg{color:#e6e6e6} :root[data-design="minecraft"] #btn-search svg,:root[data-design="minecraft"] .tbtn svg,:root[data-design="minecraft"] #btn-console svg,:root[data-design="minecraft"] .acct-av svg{color:#e6e6e6}
:root[data-design="minecraft"] #btn-new svg,:root[data-design="minecraft"] #btn-account svg{color:#7fd858} :root[data-design="minecraft"] #btn-new svg,:root[data-design="minecraft"] #btn-account svg{color:#7fd858}
:root[data-design="minecraft"] #btn-import svg{color:#6db8ff} :root[data-design="minecraft"] #btn-import svg{color:#6db8ff}
:root[data-design="minecraft"] #btn-open-dir svg{color:#f2c94c} :root[data-design="minecraft"] #btn-open-dir svg{color:#f2c94c}
+25
View File
@@ -200,7 +200,32 @@ async function sendReport(daten) {
} }
} }
// ---------- AeroMC-Umhänge ----------
// Hochladen/Entfernen nur angemeldet; Abrufen ist öffentlich (auch für den Mod)
function uploadCape(uuid, image) {
return anfrage('/cape', { method: 'POST', body: { uuid, image } });
}
function removeCape(uuid) {
return anfrage('/cape/remove', { method: 'POST', body: { uuid } });
}
async function getCapes(uuids) {
try {
const liste = [...new Set((uuids || []).map((u) => String(u).replace(/-/g, '').toLowerCase()))].filter(Boolean);
if (!liste.length) return { ok: true, capes: {} };
const { res, daten } = await roheAnfrage('/capes', { method: 'GET', body: { uuids: liste.join(',') } });
// ältere Plugin-Fassung ohne Umhänge
if (res.status === 404) return { ok: false, fehlt: true, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
return { ok: true, capes: (daten && daten.capes) || {} };
} catch (err) {
return { ok: false, message: 'Umhang-Server nicht erreichbar: ' + err.message };
}
}
module.exports = { module.exports = {
BASIS_URL, uploadCape, removeCape, getCapes,
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend, heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer, sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer,
sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport, sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport,
+62 -4
View File
@@ -27,8 +27,9 @@
<button id="btn-stats" class="tbtn" data-ic="chart" title="Spielzeit-Statistik">Statistik</button> <button id="btn-stats" class="tbtn" data-ic="chart" title="Spielzeit-Statistik">Statistik</button>
<button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button> <button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button>
<span class="tbgrow"></span> <span class="tbgrow"></span>
<div class="search-wrap"> <div id="search-wrap" class="search-wrap">
<input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" /> <button id="btn-search" class="tbtn tb-lupe" data-ic="search" title="Instanzen suchen (Strg+F)"></button>
<input id="search" type="text" placeholder="Suchen… (Esc schließt)" autocomplete="off" title="Instanzen nach Name, Version oder Gruppe filtern" />
</div> </div>
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button> <button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button> <button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
@@ -445,6 +446,14 @@
</label> </label>
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small> <small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
</section> </section>
<section class="settings-card">
<h5>Umhänge</h5>
<label class="checkbox-field">
<input id="s-aeromc-capes" type="checkbox" /> <span>AeroMC-Umhänge im Spiel anzeigen</span>
</label>
<small class="hint">Zeigt deinen eigenen Umhang und die anderer AeroMC-Spieler – in jeder Minecraft-Version und mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small>
</section>
</div> </div>
</div> </div>
@@ -1249,6 +1258,26 @@
</select> </select>
</label> </label>
<p id="skin-status" class="hint"></p> <p id="skin-status" class="hint"></p>
<div class="skin-umhang">
<div class="skin-label">Umhang</div>
<div class="field-row">
<label class="field">
<span>Offizieller Umhang <small class="opt">(im Spiel für alle sichtbar)</small></span>
<select id="skin-cape-offiziell" title="Nur Umhänge, die dein Konto bei Mojang besitzt">
<option value="">Kein Umhang</option>
</select>
</label>
<div class="field">
<span>AeroMC-Umhang <small class="opt">(eigener, auch HD)</small></span>
<div class="skin-umhang-aero">
<span id="skin-cape-aero" class="hint">–</span>
<button id="skin-cape-edit" class="btn btn-mini" title="Im Skin-Editor einen eigenen Umhang gestalten">Gestalten…</button>
<button id="skin-cape-remove" class="btn btn-mini btn-danger hidden">Entfernen</button>
</div>
</div>
</div>
<small id="skin-cape-status" class="hint">Den AeroMC-Umhang sehen im Spiel alle, die AeroMC nutzen oder den Mod „AeroMC Umhänge“ (Fabric/Quilt) installiert haben – dort hat er Vorrang vor dem offiziellen.</small>
</div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button> <button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
@@ -1268,6 +1297,7 @@
<div class="se-modus"> <div class="se-modus">
<button class="se-modus-btn on" data-se-modus="2d" title="Auf der aufgeklappten Vorlage malen">2D-Vorlage</button> <button class="se-modus-btn on" data-se-modus="2d" title="Auf der aufgeklappten Vorlage malen">2D-Vorlage</button>
<button class="se-modus-btn" data-se-modus="3d" title="Direkt auf der drehbaren Figur malen">3D-Figur</button> <button class="se-modus-btn" data-se-modus="3d" title="Direkt auf der drehbaren Figur malen">3D-Figur</button>
<button class="se-modus-btn" data-se-modus="umhang" title="Eigenen Umhang (auch HD) gestalten">Umhang</button>
</div> </div>
<button class="modal-close" data-close>✕</button> <button class="modal-close" data-close>✕</button>
</div> </div>
@@ -1290,7 +1320,7 @@
</div> </div>
<div id="se-zuletzt" class="se-zuletzt"></div> <div id="se-zuletzt" class="se-zuletzt"></div>
</div> </div>
<div class="se-gruppe"> <div id="se-gruppe-ebene" class="se-gruppe">
<div class="se-titel">Ebene</div> <div class="se-titel">Ebene</div>
<div class="se-ebenen"> <div class="se-ebenen">
<button class="se-ebene on" data-se-schicht="0" title="Die eigentliche Figur (L wechselt)">Grundschicht</button> <button class="se-ebene on" data-se-schicht="0" title="Die eigentliche Figur (L wechselt)">Grundschicht</button>
@@ -1300,6 +1330,30 @@
<label class="se-check"><input id="se-durch" type="checkbox" checked /> Grundschicht durchscheinen</label> <label class="se-check"><input id="se-durch" type="checkbox" checked /> Grundschicht durchscheinen</label>
<button id="se-leeren" class="btn btn-mini">2. Schicht leeren</button> <button id="se-leeren" class="btn btn-mini">2. Schicht leeren</button>
</div> </div>
<div id="se-gruppe-umhang" class="se-gruppe hidden">
<div class="se-titel">Umhang</div>
<label class="se-feld"><span>Größe</span>
<select id="se-u-groesse" title="Größere Umhänge (HD) erlauben feinere Motive">
<option value="64">Normal (64×32)</option>
<option value="128">HD 128×64</option>
<option value="256">HD 256×128</option>
<option value="512">HD 512×256</option>
<option value="1024">HD 1024×512</option>
</select>
</label>
<label class="se-feld"><span>Pinselgröße</span>
<select id="se-u-pinsel">
<option value="1">1 Pixel</option>
<option value="2">2 Pixel</option>
<option value="4">4 Pixel</option>
<option value="8">8 Pixel</option>
<option value="16">16 Pixel</option>
</select>
</label>
<button id="se-u-bild" class="btn btn-mini" title="Ein beliebiges Bild (PNG, JPG …) auf die Außenseite setzen">Bild einsetzen…</button>
<label class="se-check" title="Dieselbe Datei trägt auch die Elytra"><input id="se-u-elytra" type="checkbox" /> Als Elytra zeigen</label>
<button id="se-u-einpassen" class="btn btn-mini" title="Zoom zurücksetzen (Taste 0)">Ansicht einpassen</button>
</div>
<div class="se-gruppe"> <div class="se-gruppe">
<div class="se-titel">Optionen</div> <div class="se-titel">Optionen</div>
<label class="se-check" title="Malt gleichzeitig auf der anderen Körperseite (M)"><input id="se-spiegeln" type="checkbox" /> Spiegeln (links ↔ rechts)</label> <label class="se-check" title="Malt gleichzeitig auf der anderen Körperseite (M)"><input id="se-spiegeln" type="checkbox" /> Spiegeln (links ↔ rechts)</label>
@@ -1319,6 +1373,10 @@
<div id="se-buehne" class="se-buehne"> <div id="se-buehne" class="se-buehne">
<div id="se-2d" class="se-ansicht se-2d"><canvas id="se-2d-canvas"></canvas></div> <div id="se-2d" class="se-ansicht se-2d"><canvas id="se-2d-canvas"></canvas></div>
</div> </div>
<!-- gerade nicht gezeigte Ansichten (je nach Modus) -->
<div id="se-ablage" class="se-ablage">
<div id="se-u2d" class="se-ansicht se-u2d"><canvas id="se-u2d-canvas"></canvas></div>
</div>
<div id="se-info" class="se-info hint"></div> <div id="se-info" class="se-info hint"></div>
</section> </section>
<aside class="se-seite"> <aside class="se-seite">
@@ -1326,7 +1384,7 @@
<div id="se-mini" class="se-mini"> <div id="se-mini" class="se-mini">
<div id="se-3d" class="se-ansicht se-3d"><canvas id="se-3d-canvas"></canvas><span id="se-3d-leer" class="hint se-3d-leer">wird geladen …</span></div> <div id="se-3d" class="se-ansicht se-3d"><canvas id="se-3d-canvas"></canvas><span id="se-3d-leer" class="hint se-3d-leer">wird geladen …</span></div>
</div> </div>
<p class="hint se-tipp">Beide Ansichten sind bemalbar. Die <b>2. Schicht</b> liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.</p> <p id="se-tipp" class="hint se-tipp">Beide Ansichten sind bemalbar. Die <b>2. Schicht</b> liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.</p>
</aside> </aside>
</div> </div>
<div class="modal-foot se-fuss"> <div class="modal-foot se-fuss">
+3
View File
@@ -344,6 +344,8 @@ function buildArgs(vj, ctx) {
// RAM // RAM
jvm = ['-Xms' + ctx.minMemMb + 'M', '-Xmx' + ctx.maxMemMb + 'M', ...jvm]; jvm = ['-Xms' + ctx.minMemMb + 'M', '-Xmx' + ctx.maxMemMb + 'M', ...jvm];
if (ctx.extraArgs) jvm = [...ctx.extraArgs.split(' ').filter(Boolean), ...jvm]; if (ctx.extraArgs) jvm = [...ctx.extraArgs.split(' ').filter(Boolean), ...jvm];
// AeroMC-Umhang-Agent (zeigt eigene Umhänge im Spiel, siehe agent/)
if (ctx.javaAgent) jvm = ['-javaagent:' + ctx.javaAgent, ...jvm];
return [...jvm, vj.mainClass, ...game]; return [...jvm, vj.mainClass, ...game];
} }
@@ -528,6 +530,7 @@ async function prepareAndLaunch(opts, onProgress) {
minMemMb: opts.minMemMb || 2048, minMemMb: opts.minMemMb || 2048,
maxMemMb: opts.maxMemMb || 4096, maxMemMb: opts.maxMemMb || 4096,
extraArgs: opts.extraArgs || '', extraArgs: opts.extraArgs || '',
javaAgent: opts.javaAgent || '',
}); });
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente) // Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
+171
View File
@@ -1864,6 +1864,176 @@ ipcMain.handle('skin:currentData', async () => {
} }
}); });
// ---------- Umhänge (offiziell von Mojang + eigene AeroMC-Umhänge) ----------
// Bilder immer als data:-Adresse in die Oberfläche (fremde Server schicken
// keine CORS-Kopfzeilen – die 3D-Ansicht dürfte sie sonst nicht laden)
async function bildAlsDataUri(url) {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const daten = Buffer.from(await res.arrayBuffer());
if (daten.length > 6 * 1024 * 1024) throw new Error('Bild zu groß');
return 'data:image/png;base64,' + daten.toString('base64');
}
// PNG mit Seitenverhältnis 2:1 (64×32 … 1024×512) – wie der Server es verlangt
function umhangPngAusBase64(base64) {
const daten = Buffer.from(String(base64 || ''), 'base64');
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
const breite = daten.readUInt32BE(16);
const hoehe = daten.readUInt32BE(20);
if (![64, 128, 256, 512, 1024].includes(breite) || breite !== 2 * hoehe) return null;
return daten;
}
// Umhang eines beliebigen Spielers fürs Anzeigen: eigener AeroMC-Umhang hat
// Vorrang, sonst der offizielle aus Mojangs Profil. 5 Minuten gemerkt.
const umhangCache = new Map(); // uuid -> { dataUri, quelle, zeit }
async function umhangFuer(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const gemerkt = umhangCache.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 5 * 60 * 1000) return gemerkt;
let ergebnis = { dataUri: null, quelle: null, zeit: Date.now() };
try {
const aero = await friends.getCapes([id]);
const eintrag = aero.ok && aero.capes && aero.capes[id];
if (eintrag && eintrag.url) ergebnis = { dataUri: await bildAlsDataUri(eintrag.url), quelle: 'aeromc', zeit: Date.now() };
} catch { /* weiter mit Mojang */ }
if (!ergebnis.dataUri) {
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (res.ok) {
const profil = await res.json();
const eig = (profil.properties || []).find((p) => p.name === 'textures');
const tex = eig ? JSON.parse(Buffer.from(eig.value, 'base64').toString('utf8')) : {};
const cape = tex.textures && tex.textures.CAPE;
if (cape && cape.url) ergebnis = { dataUri: await bildAlsDataUri(skins.aufHttps(cape.url)), quelle: 'mojang', zeit: Date.now() };
}
} catch { /* kein Umhang */ }
}
umhangCache.set(id, ergebnis);
return ergebnis;
}
ipcMain.handle('cape:forUuid', async (_e, uuid) => {
const r = await umhangFuer(uuid);
return r && r.dataUri ? { dataUri: r.dataUri, quelle: r.quelle } : null;
});
// Übersicht fürs Skin-Fenster: offizielle Umhänge + eigener AeroMC-Umhang
ipcMain.handle('cape:info', async () => {
const konto = aktivesFreundeKonto();
const token = await aktivesToken();
if (!konto || !token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const ergebnis = { ok: true, name: konto.username, offiziell: [], aeromc: null, aeromcFehler: '' };
try {
const p = await skins.holeProfil(token);
if (p.ok) ergebnis.offiziell = p.capes || [];
} catch { /* ohne offizielle Liste weiter */ }
const aero = await friends.getCapes([konto.uuid]);
if (aero.ok) ergebnis.aeromc = aero.capes[String(konto.uuid).replace(/-/g, '').toLowerCase()] || null;
else { ergebnis.aeromcFehler = aero.message; ergebnis.aeromcFehlt = !!aero.fehlt; }
return ergebnis;
});
ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
const res = await skins.setzeUmhang(token, capeId || null);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res;
} catch (err) { return { ok: false, message: 'Umhang konnte nicht gesetzt werden: ' + err.message }; }
});
// Der Umhang-Agent muss als echte Datei vorliegen (Java liest nicht aus app.asar):
// einmal nach userData kopieren, Dateiname mit Größe, damit ein Update ihn ersetzt.
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
function umhangAgent() {
try {
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
const groesse = fs.statSync(quelle).size;
const ordner = path.join(app.getPath('userData'), 'agent');
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(ziel, fs.readFileSync(quelle));
for (const alt of fs.readdirSync(ordner)) {
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
}
}
return `${ziel}=${friends.BASIS_URL}`;
} catch (err) {
console.error('Umhang-Agent nicht verfügbar:', err.message);
return '';
}
}
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
function umhangFehler(res) {
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
return res;
}
ipcMain.handle('cape:upload', async (_e, base64) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' };
const res = await friends.uploadCape(konto.uuid, base64);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res);
});
ipcMain.handle('cape:remove', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
const res = await friends.removeCape(konto.uuid);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? res : umhangFehler(res);
});
// für den Editor: eigener Umhang, sonst der getragene offizielle
ipcMain.handle('cape:currentData', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
umhangCache.delete(String(konto.uuid).replace(/-/g, '').toLowerCase());
const r = await umhangFuer(konto.uuid);
if (!r || !r.dataUri) return { ok: false, keiner: true, message: 'Dein Konto hat noch keinen Umhang.' };
return { ok: true, dataUri: r.dataUri, quelle: r.quelle };
});
// Bild wählen: art 'umhang' = fertige Umhang-Datei (PNG), 'bild' = beliebiges Motiv zum Einsetzen
ipcMain.handle('cape:chooseFile', async (e, art) => {
const motiv = art === 'bild';
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: motiv ? 'Bild für den Umhang wählen' : 'Umhang-Datei öffnen',
properties: ['openFile'],
filters: motiv
? [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] }]
: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const pfad = res.filePaths[0];
try {
const st = fs.statSync(pfad);
if (st.size > 15 * 1024 * 1024) return { ok: false, message: 'Die Datei ist zu groß (über 15 MB).' };
const endung = path.extname(pfad).slice(1).toLowerCase().replace('jpg', 'jpeg');
return { ok: true, pfad, dataUri: `data:image/${endung || 'png'};base64,` + fs.readFileSync(pfad).toString('base64') };
} catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
});
ipcMain.handle('cape:saveData', async (e, base64) => {
const daten = umhangPngAusBase64(base64);
if (!daten) return { ok: false, message: 'Ungültige Umhang-Daten.' };
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Umhang speichern',
defaultPath: path.join(app.getPath('pictures'), 'umhang.png'),
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('skin:readData', (_e, dateiPfad) => { ipcMain.handle('skin:readData', (_e, dateiPfad) => {
const geprueft = skins.pruefeDatei(dateiPfad); const geprueft = skins.pruefeDatei(dateiPfad);
if (!geprueft.ok) return geprueft; if (!geprueft.ok) return geprueft;
@@ -3602,6 +3772,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null); if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
const opts = { const opts = {
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(),
versionId: inst.minecraft.version, versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla', loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '', loaderVersion: inst.minecraft.loaderVersion || '',
+8
View File
@@ -159,6 +159,14 @@ contextBridge.exposeInMainWorld('api', {
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad), skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64), skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante), skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
capeInfo: () => ipcRenderer.invoke('cape:info'),
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
capeRemove: () => ipcRenderer.invoke('cape:remove'),
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
authRemove: (id) => ipcRenderer.invoke('auth:remove', id), authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
authLogin: () => ipcRenderer.invoke('auth:login'), authLogin: () => ipcRenderer.invoke('auth:login'),
authLogout: () => ipcRenderer.invoke('auth:logout'), authLogout: () => ipcRenderer.invoke('auth:logout'),
+105 -9
View File
@@ -1564,7 +1564,9 @@ async function applySkinBackground(uuidOverride) {
// schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm) // schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm)
let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : ''; let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : '';
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } } if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell }); let umhang = null;
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; } if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
// 2) Rückfall: externe Render-Dienste // 2) Rückfall: externe Render-Dienste
@@ -1711,6 +1713,7 @@ async function renderSkin3D(quelle, poseKey, opts = {}) {
viewer.autoRotate = false; viewer.autoRotate = false;
viewer.animation = null; viewer.animation = null;
pose.apply(viewer.playerObject); pose.apply(viewer.playerObject);
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
viewer.render(); viewer.render();
const data = canvas.toDataURL('image/png'); const data = canvas.toDataURL('image/png');
@@ -1740,6 +1743,7 @@ const ICONS_SVG = {
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>', export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>', trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>', chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4.2-4.2"/>',
}; };
function svgIcon(name) { function svgIcon(name) {
const p = ICONS_SVG[name]; const p = ICONS_SVG[name];
@@ -1909,6 +1913,17 @@ function buildCard(inst) {
return card; return card;
} }
// Suchfeld in der Werkzeugleiste: standardmäßig nur eine Lupe
function sucheOeffnen() {
el('search-wrap').classList.add('offen');
el('search').focus();
el('search').select();
}
function sucheSchliessen() {
el('search-wrap').classList.remove('offen');
if (document.activeElement === el('search')) el('search').blur();
}
function render() { function render() {
const q = el('search').value.trim().toLowerCase(); const q = el('search').value.trim().toLowerCase();
const grid = el('grid'); const grid = el('grid');
@@ -2607,7 +2622,7 @@ function schliesseSkinViewer(bildId) {
* Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht, * Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht,
* wird stattdessen ein Hinweistext gezeigt. * wird stattdessen ein Hinweistext gezeigt.
*/ */
async function zeigeSkinFigur(bildId, leerId, textur, leerText) { async function zeigeSkinFigur(bildId, leerId, textur, leerText, umhang) {
schliesseSkinViewer(bildId); schliesseSkinViewer(bildId);
const canvas = el(bildId); const canvas = el(bildId);
const leer = el(leerId); const leer = el(leerId);
@@ -2644,6 +2659,7 @@ async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
viewer.autoRotate = false; viewer.autoRotate = false;
viewer.animation = null; viewer.animation = null;
pose.apply(viewer.playerObject); pose.apply(viewer.playerObject);
if (umhang) { try { await viewer.loadCape(umhang); } catch { /* ohne Umhang weiter */ } }
viewer.controls.enableRotate = true; viewer.controls.enableRotate = true;
viewer.controls.enableZoom = true; viewer.controls.enableZoom = true;
viewer.controls.enablePan = false; viewer.controls.enablePan = false;
@@ -2679,14 +2695,78 @@ async function openSkin() {
// Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird // Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird
if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic'; if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic';
skinTexturAktuell = p.skinUrl || ''; skinTexturAktuell = p.skinUrl || '';
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
'Standard-Skin (keine eigene Datei hinterlegt)'); 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
// ---------- Umhänge im Skin-Fenster ----------
let skinUmhang = null; // data:-Adresse des angezeigten Umhangs (AeroMC vor offiziell)
async function ladeUmhangInfo() {
const sel = el('skin-cape-offiziell');
const info = await window.api.capeInfo();
sel.innerHTML = '<option value="">Kein Umhang</option>';
if (!info.ok) {
el('skin-cape-aero').textContent = info.message || '–';
el('skin-cape-remove').classList.add('hidden');
skinUmhang = null;
return;
}
for (const c of info.offiziell) {
const o = document.createElement('option');
o.value = c.id;
o.textContent = c.name;
if (c.aktiv) o.selected = true;
sel.appendChild(o);
}
sel.disabled = !info.offiziell.length;
sel.title = info.offiziell.length ? 'Nur Umhänge, die dein Konto bei Mojang besitzt' : 'Dein Konto besitzt keinen offiziellen Umhang';
if (info.aeromc) {
el('skin-cape-aero').textContent = `Hochgeladen (${info.aeromc.width}×${info.aeromc.height})`;
el('skin-cape-remove').classList.remove('hidden');
el('skin-cape-edit').textContent = 'Bearbeiten…';
} else {
el('skin-cape-aero').textContent = info.aeromcFehlt ? 'Server noch nicht bereit' : info.aeromcFehler ? 'Umhang-Server nicht erreichbar' : 'Noch keiner';
el('skin-cape-aero').title = info.aeromcFehler || '';
el('skin-cape-remove').classList.add('hidden');
el('skin-cape-edit').textContent = 'Gestalten…';
}
const konto = await window.api.authStatus();
const r = konto && konto.profile ? await window.api.capeForUuid(konto.profile.id) : null;
skinUmhang = r ? r.dataUri : null;
}
async function waehleOffiziellenUmhang() {
const sel = el('skin-cape-offiziell');
sel.disabled = true;
el('skin-cape-status').textContent = 'Umhang wird gesetzt …';
const res = await window.api.capeSetOfficial(sel.value || null);
sel.disabled = false;
if (res.ok) {
el('skin-cape-status').textContent = sel.value ? '✔ Offizieller Umhang gesetzt.' : '✔ Kein offizieller Umhang mehr.';
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
} else {
el('skin-cape-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
}
function entferneAeroUmhang() {
askConfirm('AeroMC-Umhang entfernen?', 'Dein eigener Umhang wird vom AeroMC-Umhang-Server gelöscht. Hast du ihn nicht als PNG gespeichert, ist er danach weg.', 'Entfernen', async () => {
const res = await window.api.capeRemove();
el('skin-cape-status').textContent = res.ok ? '✔ AeroMC-Umhang entfernt.' : '✖ ' + (res.message || 'Fehlgeschlagen.');
if (res.ok) {
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
});
} }
// Armform gewechselt -> beide Figuren neu zeichnen // Armform gewechselt -> beide Figuren neu zeichnen
async function skinVarianteGewechselt() { async function skinVarianteGewechselt() {
if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, ''); if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, '', skinUmhang);
if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, ''); if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, '', skinUmhang);
} }
async function waehleSkinDatei() { async function waehleSkinDatei() {
@@ -2697,7 +2777,7 @@ async function waehleSkinDatei() {
gewaehlterSkin = res.pfad; gewaehlterSkin = res.pfad;
el('skin-pfad').value = res.pfad; el('skin-pfad').value = res.pfad;
skinTexturNeu = res.dataUri || ''; skinTexturNeu = res.dataUri || '';
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar'); await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar', skinUmhang);
el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`; el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`;
el('skin-apply').disabled = false; el('skin-apply').disabled = false;
} }
@@ -4918,6 +4998,7 @@ async function openSettings(pane) {
el('s-friends').checked = s.friendsEnabled !== false; el('s-friends').checked = s.friendsEnabled !== false;
el('s-friends-server').checked = s.friendsShareServer !== false; el('s-friends-server').checked = s.friendsShareServer !== false;
el('s-auto-mod-updates').checked = !!s.autoModUpdates; el('s-auto-mod-updates').checked = !!s.autoModUpdates;
el('s-aeromc-capes').checked = s.aeromcCapes !== false;
el('s-autostart').checked = !!s.autostart; el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false; el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled; el('s-autobackup').checked = !!s.autoBackupEnabled;
@@ -5051,6 +5132,7 @@ async function saveSettings() {
friendsEnabled: el('s-friends').checked, friendsEnabled: el('s-friends').checked,
friendsShareServer: el('s-friends-server').checked, friendsShareServer: el('s-friends-server').checked,
autoModUpdates: el('s-auto-mod-updates').checked, autoModUpdates: el('s-auto-mod-updates').checked,
aeromcCapes: el('s-aeromc-capes').checked,
autostart: el('s-autostart').checked, autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked, autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked, autoBackupEnabled: el('s-autobackup').checked,
@@ -5527,6 +5609,18 @@ function wire() {
// ohne Wrapper käme das Klick-Ereignis als Bereichsname an // ohne Wrapper käme das Klick-Ereignis als Bereichsname an
el('btn-settings').addEventListener('click', () => openSettings()); el('btn-settings').addEventListener('click', () => openSettings());
el('search').addEventListener('input', render); el('search').addEventListener('input', render);
el('btn-search').addEventListener('click', () => sucheOeffnen());
el('search').addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
// Esc gehört hier der Suche, nicht dem globalen Dialog-Schließen
e.stopPropagation();
el('search').value = '';
render();
sucheSchliessen();
});
// leer verlassen = wieder zur Lupe; mit Suchbegriff bleibt das Feld offen,
// damit man sieht, dass die Liste gerade gefiltert ist
el('search').addEventListener('blur', () => { if (!el('search').value.trim()) sucheSchliessen(); });
el('btn-open-dir').addEventListener('click', () => { el('btn-open-dir').addEventListener('click', () => {
if (currentDetailId) window.api.openInstanceFolder(currentDetailId); if (currentDetailId) window.api.openInstanceFolder(currentDetailId);
@@ -5925,6 +6019,9 @@ function wire() {
el('login-skin').addEventListener('click', openSkin); el('login-skin').addEventListener('click', openSkin);
el('skin-pick').addEventListener('click', waehleSkinDatei); el('skin-pick').addEventListener('click', waehleSkinDatei);
el('skin-variante').addEventListener('change', skinVarianteGewechselt); el('skin-variante').addEventListener('change', skinVarianteGewechselt);
el('skin-cape-offiziell').addEventListener('change', waehleOffiziellenUmhang);
el('skin-cape-edit').addEventListener('click', () => window.openSkinEditor({ modus: 'umhang', quelle: 'aktuell' }));
el('skin-cape-remove').addEventListener('click', entferneAeroUmhang);
el('skin-apply').addEventListener('click', setzeSkin); el('skin-apply').addEventListener('click', setzeSkin);
// gewählte Datei (sonst der aktuelle Skin des Kontos) im Editor öffnen // gewählte Datei (sonst der aktuelle Skin des Kontos) im Editor öffnen
el('skin-editor').addEventListener('click', () => window.openSkinEditor(gewaehlterSkin el('skin-editor').addEventListener('click', () => window.openSkinEditor(gewaehlterSkin
@@ -6274,8 +6371,7 @@ function wire() {
// der eine eigene Suche hat (Mods, Modpack-Katalog, Import …) // der eine eigene Suche hat (Mods, Modpack-Katalog, Import …)
if (strg && e.key.toLowerCase() === 'f' && !modalOffen) { if (strg && e.key.toLowerCase() === 'f' && !modalOffen) {
e.preventDefault(); e.preventDefault();
el('search').focus(); sucheOeffnen();
el('search').select();
return; return;
} }
// Strg+1 … Strg+5: Schnellstart-Instanz starten (nur im Hauptfenster) // Strg+1 … Strg+5: Schnellstart-Instanz starten (nur im Hauptfenster)
@@ -6535,7 +6631,7 @@ window.addEventListener('DOMContentLoaded', async () => {
window.api.onSkinChanged((info) => { window.api.onSkinChanged((info) => {
if (!POPOUT) refreshSkinBackground(); if (!POPOUT) refreshSkinBackground();
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand // im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
if (POPOUT === 'skin' && info.quelle === 'editor' && !el('modal-skin').classList.contains('hidden')) openSkin(); if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
}); });
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); }); window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ } try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
+713 -164
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -115,7 +115,30 @@ async function holeProfil(accessToken) {
id: daten.id, id: daten.id,
skinUrl: aktiv ? aufHttps(aktiv.url) : '', skinUrl: aktiv ? aufHttps(aktiv.url) : '',
variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '', variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '',
// offizielle Umhänge, die dieses Konto besitzt (Migrator, MINECON …)
capes: (daten.capes || []).map((c) => ({
id: c.id,
name: c.alias || 'Umhang',
url: aufHttps(c.url),
aktiv: c.state === 'ACTIVE',
})),
}; };
} }
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps }; /*
* Offiziellen Umhang wählen (nur einer, den das Konto besitzt) bzw. mit
* capeId = null keinen tragen. Gilt im Spiel für alle Spieler.
*/
async function setzeUmhang(accessToken, capeId) {
const res = await fetch(API + '/minecraft/profile/capes/active', capeId
? {
method: 'PUT',
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, 'Content-Type': 'application/json' },
body: JSON.stringify({ capeId }),
}
: { method: 'DELETE', headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA } });
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
return { ok: true };
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang };
+2 -1
View File
@@ -78,7 +78,8 @@ const DEFAULT_SETTINGS = {
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
friendsEnabled: true, friendsEnabled: true,
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten") friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
autoModUpdates: false, // vor jedem Start Mods über Modrinth aktualisieren autoModUpdates: false,
aeromcCapes: true, // AeroMC-Umhänge im Spiel anzeigen (Umhang-Agent beim Start) // vor jedem Start Mods über Modrinth aktualisieren
schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … }) schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … })
autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund
+17 -1
View File
@@ -65,7 +65,7 @@ body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:mi
:root[data-theme="dark"] .empty h2, :root[data-theme="dark"] .empty h2,
:root[data-theme="dark"] .mods-col h4, :root[data-theme="dark"] .mods-col h4,
:root[data-theme="dark"] .field>span{color:var(--text)} :root[data-theme="dark"] .field>span{color:var(--text)}
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc} :root[data-theme="dark"] #btn-console svg,:root[data-theme="dark"] #btn-search svg{color:#c8c8cc}
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc} :root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
:root[data-theme="dark"] .mods-filters select, :root[data-theme="dark"] .mods-filters select,
:root[data-theme="dark"] .mods-search select, :root[data-theme="dark"] .mods-search select,
@@ -103,6 +103,14 @@ body.is-popout>.titelleiste{display:none}
.tbgrow{flex:1} .tbgrow{flex:1}
.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px} .search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px}
.search-wrap input:focus{outline:none;border-color:var(--accent)} .search-wrap input:focus{outline:none;border-color:var(--accent)}
/* eingeklappt nur die Lupe, aufgeklappt nur das Feld */
.search-wrap{display:flex;align-items:center}
.search-wrap:not(.offen) input{display:none}
.search-wrap.offen .tb-lupe{display:none}
.search-wrap.offen input{animation:suche-auf .16s ease-out}
@keyframes suche-auf{from{width:0;opacity:0}}
.tbtn.tb-lupe{padding:6px 8px}
#btn-search svg{color:#5a6470}
/* ---------- Arbeitsbereich ---------- */ /* ---------- Arbeitsbereich ---------- */
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)} .workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)}
@@ -337,6 +345,11 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated} .skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab} .skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
.skin-bild canvas:active{cursor:grabbing} .skin-bild canvas:active{cursor:grabbing}
.popout-host .skin-bild{height:240px}
.skin-umhang{margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)}
.skin-umhang .field-row{margin-top:6px}
.skin-umhang .field{margin-bottom:6px}
.skin-umhang-aero{display:flex;align-items:center;gap:8px;min-height:30px;flex-wrap:wrap}
.crash-details{margin-top:12px} .crash-details{margin-top:12px}
.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)} .crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word} .crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
@@ -702,5 +715,8 @@ img.account-av{object-fit:cover;image-rendering:auto}
.se-3d canvas:active{cursor:grabbing} .se-3d canvas:active{cursor:grabbing}
.se-2d[data-werkzeug="pipette"] canvas{cursor:copy} .se-2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-3d-leer{position:absolute} .se-3d-leer{position:absolute}
.se-ablage{display:none}
.se-u2d canvas{cursor:crosshair}
.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-fuss{align-items:center} .se-fuss{align-items:center}
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -1,8 +1,8 @@
<?php <?php
/** /**
* Plugin Name: AeroMC Freunde * Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort. * Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.4.0 * Version: 1.5.0
* Author: M_Viper * Author: M_Viper
*/ */
@@ -10,7 +10,7 @@ if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei exit; // kein direkter Aufruf der Datei
} }
define('AEROMC_FRIENDS_VERSION', '1.4.0'); define('AEROMC_FRIENDS_VERSION', '1.5.0');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence'); define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends'); define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites'); define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
@@ -18,6 +18,7 @@ define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares'); define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages'); define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports'); define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber // Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben // nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
@@ -143,6 +144,22 @@ function aeromc_friends_install() {
) $charset_collate;"; ) $charset_collate;";
dbDelta($sql_reports); dbDelta($sql_reports);
// eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
$capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
$sql_capes = "CREATE TABLE $capes_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
datei varchar(64) NOT NULL DEFAULT '',
breite smallint(5) unsigned NOT NULL DEFAULT 0,
hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
hash varchar(40) NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'aktiv',
updated_at datetime NOT NULL,
PRIMARY KEY (uuid),
KEY status (status)
) $charset_collate;";
dbDelta($sql_capes);
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION); update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
} }
register_activation_hook(__FILE__, 'aeromc_friends_install'); register_activation_hook(__FILE__, 'aeromc_friends_install');
@@ -157,6 +174,7 @@ add_action('plugins_loaded', function () {
}); });
require_once plugin_dir_path(__FILE__) . 'includes/routes.php'; require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
if (is_admin()) { if (is_admin()) {
require_once plugin_dir_path(__FILE__) . 'includes/admin.php'; require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
} }
@@ -0,0 +1,306 @@
<?php
/**
* AeroMC-Umhänge: eigene (auch HD-)Umhänge, die Spieler im Launcher gestalten.
*
* POST /aeromc/v1/cape { uuid, image } Umhang hochladen/ersetzen (Anmeldung Pflicht)
* POST /aeromc/v1/cape/remove { uuid } eigenen Umhang entfernen (Anmeldung Pflicht)
* GET /aeromc/v1/cape?uuid= Umhang eines Spielers (öffentlich)
* GET /aeromc/v1/capes?uuids=a,b,c mehrere auf einmal, für Launcher und Mod (öffentlich)
*
* Bilder werden streng geprüft (echtes PNG, Seitenverhältnis 2:1, 64×32 bis
* 1024×512) und – falls GD verfügbar ist – neu kodiert, damit keine fremden
* Daten im Bild mitreisen. Der Dateiname enthält einen Teil der Prüfsumme, so
* ändert sich die Adresse bei jedem neuen Umhang (kein veralteter Zwischenspeicher).
* Admins können Umhänge unter "AeroMC Fehler → Umhänge" löschen oder sperren.
*/
if (!defined('ABSPATH')) {
exit;
}
define('AEROMC_FRIENDS_CAPE_DIR', 'aeromc-umhaenge');
define('AEROMC_FRIENDS_CAPE_MAX_BYTES', 3000000);
define('AEROMC_FRIENDS_CAPE_MIN_GAP', 15); // Sekunden zwischen zwei Uploads
define('AEROMC_FRIENDS_CAPE_MAX_UUIDS', 100); // je Sammelabfrage
function aeromc_friends_capes_table() {
global $wpdb;
return $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
}
function aeromc_friends_cape_dir() {
$uploads = wp_upload_dir();
return [
'pfad' => trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_CAPE_DIR,
'url' => trailingslashit($uploads['baseurl']) . AEROMC_FRIENDS_CAPE_DIR,
];
}
/** Datei eines Umhangs löschen (nur Namen in unserem Schema). */
function aeromc_friends_cape_delete_file($datei) {
if (!$datei || !preg_match('/^[a-f0-9]{32}-[a-f0-9]{8}\.png$/', (string) $datei)) {
return;
}
$ort = aeromc_friends_cape_dir();
$voll = $ort['pfad'] . '/' . $datei;
if (file_exists($voll)) {
@unlink($voll);
}
}
/** Zeile -> öffentliche Angaben */
function aeromc_friends_cape_info($r) {
$ort = aeromc_friends_cape_dir();
return [
'url' => $ort['url'] . '/' . $r->datei,
'width' => (int) $r->breite,
'height' => (int) $r->hoehe,
'hash' => $r->hash,
'updated' => $r->updated_at,
];
}
/**
* Prüft ein Umhang-Bild und gibt [daten, breite, hoehe] zurück oder WP_Error.
*/
function aeromc_friends_cape_check_image($base64) {
$daten = base64_decode(preg_replace('/^data:image\/png;base64,/', '', (string) $base64), true);
if ($daten === false || strlen($daten) < 60) {
return new WP_Error('aeromc_invalid', 'Ungültiges Bild.', ['status' => 400]);
}
if (strlen($daten) > AEROMC_FRIENDS_CAPE_MAX_BYTES) {
return new WP_Error('aeromc_too_big', 'Der Umhang ist zu groß (Datei).', ['status' => 413]);
}
if (strncmp($daten, "\x89PNG\r\n\x1A\n", 8) !== 0 || substr($daten, 12, 4) !== 'IHDR') {
return new WP_Error('aeromc_invalid', 'Nur PNG-Bilder.', ['status' => 400]);
}
$kopf = unpack('Nbreite/Nhoehe', substr($daten, 16, 8));
$breite = (int) $kopf['breite'];
$hoehe = (int) $kopf['hoehe'];
if (!in_array($breite, [64, 128, 256, 512, 1024], true) || $breite !== 2 * $hoehe) {
return new WP_Error('aeromc_invalid', 'Ein Umhang muss 64×32, 128×64, 256×128, 512×256 oder 1024×512 Pixel groß sein.', ['status' => 400]);
}
// neu kodieren: entfernt alles außer den Bildpunkten
if (function_exists('imagecreatefromstring')) {
$bild = @imagecreatefromstring($daten);
if (!$bild || imagesx($bild) !== $breite || imagesy($bild) !== $hoehe) {
return new WP_Error('aeromc_invalid', 'Das Bild ist beschädigt.', ['status' => 400]);
}
imagealphablending($bild, false);
imagesavealpha($bild, true);
ob_start();
imagepng($bild, null, 9);
$daten = ob_get_clean();
imagedestroy($bild);
}
return [$daten, $breite, $hoehe];
}
add_action('rest_api_init', function () {
register_rest_route('aeromc/v1', '/cape', [
[
'methods' => 'POST',
'callback' => 'aeromc_friends_route_cape_upload',
'permission_callback' => '__return_true',
],
[
'methods' => 'GET',
'callback' => 'aeromc_friends_route_cape_get',
'permission_callback' => '__return_true',
],
]);
register_rest_route('aeromc/v1', '/cape/remove', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_cape_remove',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/capes', [
'methods' => 'GET',
'callback' => 'aeromc_friends_route_capes',
'permission_callback' => '__return_true',
]);
});
/** Anmeldung ist hier immer Pflicht – auch im Übergangsmodus (öffentlich sichtbare Bilder). */
function aeromc_friends_cape_auth($request, $uuid) {
$sitzung = aeromc_friends_check_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
if (!$sitzung) {
return new WP_Error('aeromc_auth', 'Anmeldung erforderlich.', ['status' => 401]);
}
return $sitzung;
}
function aeromc_friends_route_cape_upload($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$sitzung = aeromc_friends_cape_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
$table = aeromc_friends_capes_table();
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
if ($alt && $alt->status === 'gesperrt') {
return new WP_Error('aeromc_blocked', 'Dein Umhang wurde vom Betreiber gesperrt.', ['status' => 403]);
}
if ($alt && time() - strtotime($alt->updated_at . ' UTC') < AEROMC_FRIENDS_CAPE_MIN_GAP) {
return new WP_Error('aeromc_too_fast', 'Bitte einen Moment warten, bevor du den Umhang erneut hochlädst.', ['status' => 429]);
}
$geprueft = aeromc_friends_cape_check_image($request->get_param('image'));
if (is_wp_error($geprueft)) {
return $geprueft;
}
list($daten, $breite, $hoehe) = $geprueft;
$hash = sha1($daten);
$ort = aeromc_friends_cape_dir();
if (!wp_mkdir_p($ort['pfad'])) {
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
}
if (!file_exists($ort['pfad'] . '/index.html')) {
file_put_contents($ort['pfad'] . '/index.html', '');
}
$datei = $uuid . '-' . substr($hash, 0, 8) . '.png';
if (file_put_contents($ort['pfad'] . '/' . $datei, $daten) === false) {
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
}
if ($alt && $alt->datei !== $datei) {
aeromc_friends_cape_delete_file($alt->datei);
}
$zeile = [
'uuid' => $uuid,
'username' => $sitzung->username,
'datei' => $datei,
'breite' => $breite,
'hoehe' => $hoehe,
'hash' => $hash,
'status' => 'aktiv',
'updated_at' => current_time('mysql', true),
];
$wpdb->replace($table, $zeile);
return ['ok' => true, 'cape' => aeromc_friends_cape_info((object) $zeile)];
}
function aeromc_friends_route_cape_remove($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$sitzung = aeromc_friends_cape_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
$table = aeromc_friends_capes_table();
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
if ($alt) {
aeromc_friends_cape_delete_file($alt->datei);
if ($alt->status === 'gesperrt') {
// Sperre bleibt bestehen, nur das Bild ist weg
$wpdb->update($table, ['datei' => '', 'hash' => ''], ['uuid' => $uuid]);
} else {
$wpdb->delete($table, ['uuid' => $uuid]);
}
}
return ['ok' => true];
}
function aeromc_friends_route_cape_get($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$table = aeromc_friends_capes_table();
$r = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s AND status = 'aktiv'", $uuid));
return ['ok' => true, 'cape' => $r ? aeromc_friends_cape_info($r) : null];
}
function aeromc_friends_route_capes($request) {
global $wpdb;
$liste = array_filter(array_map('aeromc_friends_normalize_uuid', explode(',', (string) $request->get_param('uuids'))));
$liste = array_slice(array_values(array_unique($liste)), 0, AEROMC_FRIENDS_CAPE_MAX_UUIDS);
$ergebnis = [];
if ($liste) {
$table = aeromc_friends_capes_table();
$platz = implode(',', array_fill(0, count($liste), '%s'));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE status = 'aktiv' AND uuid IN ($platz)", $liste
));
foreach ($rows as $r) {
$ergebnis[$r->uuid] = aeromc_friends_cape_info($r);
}
}
$antwort = new WP_REST_Response(['ok' => true, 'capes' => (object) $ergebnis]);
// kurz zwischenspeichern lassen – der Mod fragt beim Betreten eines Servers viele auf einmal ab
$antwort->header('Cache-Control', 'public, max-age=60');
return $antwort;
}
// ---------- Admin: Umhänge prüfen, löschen, sperren ----------
if (is_admin()) {
add_action('admin_menu', function () {
add_submenu_page('aeromc-reports', 'AeroMC Umhänge', 'Umhänge', 'manage_options', 'aeromc-capes', 'aeromc_friends_capes_page');
}, 20);
}
function aeromc_friends_capes_page() {
global $wpdb;
if (!current_user_can('manage_options')) {
return;
}
$table = aeromc_friends_capes_table();
if (isset($_POST['aeromc_cape'], $_POST['aeromc_aktion']) && check_admin_referer('aeromc_capes')) {
$uuid = aeromc_friends_normalize_uuid($_POST['aeromc_cape']);
$r = $uuid ? $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid)) : null;
if ($r) {
$aktion = (string) $_POST['aeromc_aktion'];
if ($aktion === 'loeschen') {
aeromc_friends_cape_delete_file($r->datei);
$wpdb->delete($table, ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>Umhang von ' . esc_html($r->username) . ' gelöscht.</p></div>';
} elseif ($aktion === 'sperren') {
aeromc_friends_cape_delete_file($r->datei);
$wpdb->update($table, ['status' => 'gesperrt', 'datei' => '', 'hash' => ''], ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>' . esc_html($r->username) . ' gesperrt – der Umhang ist entfernt, neue Uploads werden abgelehnt.</p></div>';
} elseif ($aktion === 'entsperren') {
$wpdb->delete($table, ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>Sperre für ' . esc_html($r->username) . ' aufgehoben.</p></div>';
}
}
}
$ort = aeromc_friends_cape_dir();
$rows = $wpdb->get_results("SELECT * FROM $table ORDER BY updated_at DESC LIMIT 300");
echo '<div class="wrap"><h1>AeroMC Umhänge</h1>';
echo '<p>Eigene Umhänge, die Spieler im AeroMC-Skin-Editor hochgeladen haben. „Sperren“ entfernt den Umhang und verhindert neue Uploads dieses Spielers.</p>';
if (!$rows) {
echo '<p>Noch keine Umhänge hochgeladen.</p></div>';
return;
}
echo '<table class="widefat striped"><thead><tr><th style="width:150px">Umhang</th><th>Spieler</th><th>Größe</th><th>Geändert (UTC)</th><th>Status</th><th></th></tr></thead><tbody>';
foreach ($rows as $r) {
$bild = $r->datei
? '<img src="' . esc_url($ort['url'] . '/' . $r->datei) . '" alt="" style="width:128px;height:64px;image-rendering:pixelated;background:#ddd;border:1px solid #ccc" />'
: '–';
echo '<tr><td>' . $bild . '</td><td>' . esc_html($r->username) . '<br /><small>' . esc_html($r->uuid) . '</small></td>'
. '<td>' . esc_html($r->breite . '×' . $r->hoehe) . '</td><td>' . esc_html($r->updated_at) . '</td><td>' . esc_html($r->status) . '</td><td>';
echo '<form method="post" style="display:flex;gap:6px">';
wp_nonce_field('aeromc_capes');
echo '<input type="hidden" name="aeromc_cape" value="' . esc_attr($r->uuid) . '" />';
if ($r->status === 'gesperrt') {
echo '<button class="button" name="aeromc_aktion" value="entsperren">Sperre aufheben</button>';
} else {
echo '<button class="button" name="aeromc_aktion" value="loeschen" onclick="return confirm(\'Umhang löschen?\')">Löschen</button>';
echo '<button class="button button-link-delete" name="aeromc_aktion" value="sperren" onclick="return confirm(\'Spieler sperren? Der Umhang wird entfernt und neue Uploads abgelehnt.\')">Sperren</button>';
}
echo '</form></td></tr>';
}
echo '</tbody></table></div>';
}