Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009038d48d | ||
|
|
3a7822302f | ||
|
|
aae442ae48 | ||
|
|
5611c5883a | ||
|
|
d976cb4595 | ||
|
|
3ac5a6c14d | ||
|
|
7e7a2021ed | ||
|
|
7e5d743367 | ||
|
|
6ef51edede | ||
|
|
93c74f94a1 | ||
|
|
384354f44e | ||
|
|
9afe91a592 |
@@ -2,6 +2,38 @@
|
||||
|
||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||
|
||||
## [1.2.1] - 2026-10-05
|
||||
|
||||
### Neu
|
||||
- Garderobe im Skin-Fenster: Skins (samt Armform) und eigene AeroMC-Umhänge speichern und mit einem Klick wieder anziehen – vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch gesichert, denn Mojang merkt sich keine alten Skins
|
||||
- Erfolge: 17 Erfolge (z. B. Spielzeit, Starts, Instanzen, Mods, Modpack, erster Freund, eigener Umhang, Skin aus dem Editor) mit „Erfolg erzielt!“-Meldung passend zum Design (im Minecraft-Design wie im Spiel); ein Klick auf die Meldung öffnet die Übersicht
|
||||
- Freunde mit 3D-Figur: Neben der Freundesliste dreht sich groß die Figur des ausgewählten Freundes mit Skin, 2. Ebene und Umhang (online läuft sie, offline steht sie) – das Freunde-Fenster ist dafür breiter, schmal gezogen steht die Figur über der Liste; die Köpfe in der Liste zeigen ebenfalls die 2. Ebene
|
||||
- Leistungs-Paket im Mod-Dialog: installiert mit einem Klick passende Performance-Mods – Sodium (bzw. Embeddium), Lithium (bzw. Radium), FerriteCore, Entity Culling, ImmediatelyFast, ModernFix und Dynamic FPS; Vorhandenes und für die Version nicht Verfügbares wird übersprungen, mit OptiFine bleibt Sodium weg
|
||||
- Starten-Knopf mit Fortschritt: füllt sich während Download und Vorbereitung und zeigt die Phase (z. B. „Assets 57 %“), danach kurz „Läuft ✓“
|
||||
- Mehr Leben in der Oberfläche (alles über „Bewegte Oberfläche“ abschaltbar):
|
||||
- Laufende Instanzen haben einen pulsierenden Rand – man sieht sofort, was gerade gespielt wird
|
||||
- Instanzen löschen, verschieben oder Gruppen auf-/zuklappen: Karten schrumpfen weg bzw. blenden ein, die übrigen rutschen weich nach
|
||||
- Skin übernommen: Die Figur dreht sich einmal um sich selbst; nach einem Umhang-Wechsel weht der Umhang kurz auf
|
||||
- Konto gewechselt: Der Spielerkopf oben rechts dreht sich wie eine Münze zum neuen Konto
|
||||
- Erfolg erzielt: Funken um die Meldung, im Minecraft-Design grüne XP-Kugeln
|
||||
- Freund kommt online: Sein Kopf hüpft in der Freundesliste, der grüne Punkt pulsiert
|
||||
- AeroMC/Nachtlila: dezent treibende Lichtpunkte im Instanzbereich
|
||||
- Minecraft-Design: Wolken ziehen durch die Werkzeugleiste, und beim Download zeigt das Block-Symbol der Instanz Abbau-Risse als Fortschritt
|
||||
- Das Zahnrad dreht sich beim Darüberfahren, das Block-Symbol einer Instanz kippt leicht zur Maus
|
||||
- Armform-Prüfung im Skin-Fenster: Passt die eingestellte Armform nicht zur Skin-Datei (gequetschte Arme oder dunkle Streifen), zeigt AeroMC einen Hinweis und stellt sie mit einem Klick richtig
|
||||
- Absturz-Assistent erkennt zu alte Forge-Versionen, die mit aktuellem Java 8 abstürzen („ManifestEntryVerifier“), und aktualisiert Forge auf Wunsch auf die neueste Version für dieselbe Minecraft-Version
|
||||
|
||||
### Geändert
|
||||
- Statistik-Fenster mit zwei Reitern: „Spielzeit“ und „Erfolge (x/17)“
|
||||
- Update-Meldung zeigt die Änderungshinweise formatiert an (Überschriften, Listen, fett/kursiv, Code, Links) statt als rohen Markdown-Text; das Fenster ist dafür größer, Links öffnen im Browser
|
||||
- Einstellungen aufgeräumt: jede Option als eigene Zeile mit kurzer Erklärung und Schalter statt Häkchen, alles einspaltig statt verstreuter Kacheln; neu sortiert in Allgemein, Darstellung, Spiel (inkl. Konsole), Java & Erweitert, Freunde & Umhänge (neu), Netzwerk, Konten, Backup (Welt-Sicherung jetzt bei „Automatisch sichern“)
|
||||
|
||||
### Behoben
|
||||
- Alte Forge-Versionen vor Minecraft 1.13 (z. B. 1.12.2) starteten nicht („Hauptklasse net.minecraft.launchwrapper.Launch konnte nicht gefunden werden“) – Klassenpfad und Startargumente fehlten
|
||||
- Bei Forge und NeoForge wurde ohne festgelegte Version teils die älteste statt der neuesten Version gewählt – die Versionsliste ist jetzt richtig sortiert
|
||||
- Der Mod-Katalog zeigte bei von Hand kopierten oder importierten Mods „Installieren“, obwohl sie schon da waren – AeroMC erkennt sie jetzt per Prüfsumme über Modrinth
|
||||
- Der ZIP-Import (AeroMC-Export oder gezippte MultiMC/Prism-Instanz) blockierte bei großen Archiven den Launcher – die Dateien werden jetzt im Hintergrund verschoben bzw. kopiert
|
||||
|
||||
## [1.2.0] - 2026-10-04
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -67,6 +67,8 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
|
||||
- **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
|
||||
- **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“
|
||||
- Offiziellen Mojang-Umhang direkt im Launcher wechseln oder ablegen
|
||||
- **Garderobe**: Skins und Umhänge speichern und mit einem Klick wieder anziehen – der bisherige Skin wird vor jedem Wechsel automatisch gesichert
|
||||
- Armform-Prüfung: Hinweis und Ein-Klick-Korrektur, wenn die Armform nicht zur Skin-Datei passt
|
||||
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
|
||||
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
|
||||
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
|
||||
|
||||
@@ -3,15 +3,22 @@ package de.aeromc.capes;
|
||||
import java.lang.instrument.Instrumentation;
|
||||
|
||||
/**
|
||||
* AeroMC-Umhang-Agent.
|
||||
* AeroMC-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.
|
||||
* Loader) und kann zwei Dinge, einzeln zuschaltbar:
|
||||
*
|
||||
* Argument: Basisadresse des Umhang-Servers (…/wp-json/aeromc/v1).
|
||||
* Umhänge: 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.
|
||||
* Spieldesign: AeroMC-Ladebildschirm, Fenstertitel und -symbol (siehe Design).
|
||||
*
|
||||
* Argument, Teile durch "|" getrennt:
|
||||
* <Basisadresse des Umhang-Servers> (…/wp-json/aeromc/v1)
|
||||
* capes=0 Umhänge aus
|
||||
* design=<Ordner> Spieldesign an, Grafiken aus diesem Ordner
|
||||
* Ein Argument nur mit Adresse (ältere Launcher) heißt: nur Umhänge.
|
||||
*/
|
||||
public final class Agent {
|
||||
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
|
||||
@@ -28,15 +35,42 @@ public final class Agent {
|
||||
}
|
||||
|
||||
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);
|
||||
String basis = Nachschlagen.STANDARD_BASIS;
|
||||
boolean umhaenge = true;
|
||||
String design = null;
|
||||
String mods = null;
|
||||
if (argument != null) {
|
||||
for (String teil : argument.split("\\|")) {
|
||||
if (teil.startsWith("http")) basis = teil;
|
||||
else if (teil.equals("capes=0")) umhaenge = false;
|
||||
else if (teil.startsWith("design=") && teil.length() > 7) design = teil.substring(7);
|
||||
else if (teil.startsWith("mods=") && teil.length() > 5) mods = teil.substring(5);
|
||||
}
|
||||
}
|
||||
if (mods != null) {
|
||||
try {
|
||||
Design.installiereMods(mods, inst);
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] Mod-Schalter konnte nicht starten: " + t);
|
||||
}
|
||||
}
|
||||
if (umhaenge) {
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if (design != null) {
|
||||
try {
|
||||
Design.installiere(design, inst);
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] Spieldesign konnte nicht starten: " + t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package de.aeromc.capes;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.lang.instrument.Instrumentation;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* AeroMC-Look im Spiel (Minecraft 1.14 … 26.x):
|
||||
*
|
||||
* – Ladebildschirm: AeroMC-Nachtblau statt Mojang-Rot, hellblauer Balken und
|
||||
* AeroMC-Logo statt Mojang-Logo. Das Logo lädt Minecraft am Ressourcenpaket
|
||||
* vorbei direkt aus dem Spiel – deshalb tauscht der Agent den Datenstrom aus.
|
||||
* – Fenster: Titel "AeroMC - Minecraft …" und AeroMC-Symbol.
|
||||
*
|
||||
* Titel-Logo, Untertitel, Sprüche und Knöpfe kommen über ein Ressourcenpaket,
|
||||
* das der Launcher anlegt (src/spieldesign.js).
|
||||
*
|
||||
* Die umgebauten Stellen im Spiel holen sich ihre Helfer – wie beim Umhang –
|
||||
* über die System-Properties und nur über JDK-Typen (java.util.function.Function),
|
||||
* damit es mit jedem Classloader klappt (Vanilla, Fabric, Forge, NeoForge).
|
||||
*/
|
||||
final class Design {
|
||||
static final String LOGO = "aeromc.design.logo";
|
||||
static final String LOGO_ALT = "aeromc.design.logoAlt";
|
||||
static final String TITEL = "aeromc.design.titel";
|
||||
static final String ICON = "aeromc.design.icon";
|
||||
static final String MODS = "aeromc.mods";
|
||||
|
||||
/** Hintergrund des Ladebildschirms (wie das AeroMC-Design im Launcher). */
|
||||
static final int HINTERGRUND = 0x0A0E1A;
|
||||
/** Fortschrittsbalken. */
|
||||
static final int BALKEN = 0x4FD2FF;
|
||||
/** Breite des Balkens als Anteil der Bildschirmbreite (das Logo ist jetzt bildschirmbreit). */
|
||||
static final float BALKEN_ANTEIL = 0.56f;
|
||||
|
||||
private static final int[] ICON_GROESSEN = {16, 32, 48, 64, 128, 256};
|
||||
/** Pixelspeicher der Symbole festhalten, bis GLFW sie übernommen hat. */
|
||||
private static final List<ByteBuffer> FESTHALTEN = new ArrayList<>();
|
||||
|
||||
private Design() {}
|
||||
|
||||
static void installiere(String ordner, Instrumentation inst) {
|
||||
File dir = new File(ordner);
|
||||
Properties p = System.getProperties();
|
||||
p.put(LOGO, strom(new File(dir, "ladelogo.png")));
|
||||
p.put(LOGO_ALT, strom(new File(dir, "ladelogo_alt.png")));
|
||||
p.put(TITEL, (Function<Object, Object>) Design::titel);
|
||||
p.put(ICON, (Function<Object, Object>) bilder -> symbol(dir, bilder));
|
||||
inst.addTransformer(new DesignUmbau());
|
||||
System.out.println("[AeroMC] Spieldesign aktiv");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mod-Schalter: Klick auf „Mods verwalten“ im Pausemenü (Adresse aeromc://mods)
|
||||
* meldet sich beim Launcher, der dann das Mod-Fenster über dem Spiel öffnet.
|
||||
* Rückgabe TRUE = erledigt (Minecraft öffnet keinen Link), sonst FALSE.
|
||||
*/
|
||||
static void installiereMods(String adresse, Instrumentation inst) {
|
||||
System.getProperties().put(MODS, (Function<Object, Object>) link -> {
|
||||
if (link == null || !link.toString().startsWith("aeromc:")) return Boolean.FALSE;
|
||||
Thread t = new Thread(() -> {
|
||||
try {
|
||||
java.net.HttpURLConnection c = (java.net.HttpURLConnection) new java.net.URL(adresse).openConnection();
|
||||
c.setConnectTimeout(2000);
|
||||
c.setReadTimeout(4000);
|
||||
c.getResponseCode();
|
||||
c.disconnect();
|
||||
} catch (Throwable e) {
|
||||
System.out.println("[AeroMC] Launcher fuer den Mod-Schalter nicht erreichbar: " + e);
|
||||
}
|
||||
}, "AeroMC-Mod-Schalter");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
return Boolean.TRUE;
|
||||
});
|
||||
inst.addTransformer(new ModsUmbau());
|
||||
System.out.println("[AeroMC] Mod-Schalter aktiv");
|
||||
}
|
||||
|
||||
/** Ersetzt den Datenstrom des Mojang-Logos durch das AeroMC-Logo (bei Fehlern bleibt das Original). */
|
||||
private static Function<Object, Object> strom(File datei) {
|
||||
return original -> {
|
||||
try {
|
||||
return new ByteArrayInputStream(Files.readAllBytes(datei.toPath()));
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] Ladelogo nicht lesbar: " + t);
|
||||
return original;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Object titel(Object original) {
|
||||
if (original == null) return null;
|
||||
String s = original.toString();
|
||||
return s.startsWith("AeroMC") ? original : "AeroMC - " + s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus den mitgelieferten Rohdaten (icon_N.rgba) einen neuen
|
||||
* GLFWImage.Buffer. LWJGL wird über den Classloader des übergebenen Buffers
|
||||
* angesprochen – der Agent selbst kennt LWJGL nicht.
|
||||
*/
|
||||
private static Object symbol(File dir, Object original) {
|
||||
if (original == null) return null;
|
||||
try {
|
||||
List<int[]> groessen = new ArrayList<>();
|
||||
List<byte[]> daten = new ArrayList<>();
|
||||
for (int n : ICON_GROESSEN) {
|
||||
File f = new File(dir, "icon_" + n + ".rgba");
|
||||
if (!f.isFile()) continue;
|
||||
byte[] b = Files.readAllBytes(f.toPath());
|
||||
if (b.length != n * n * 4) continue;
|
||||
groessen.add(new int[] {n, n});
|
||||
daten.add(b);
|
||||
}
|
||||
if (daten.isEmpty()) return original;
|
||||
ClassLoader cl = original.getClass().getClassLoader();
|
||||
Class<?> bild = Class.forName("org.lwjgl.glfw.GLFWImage", true, cl);
|
||||
Object puffer = bild.getMethod("malloc", int.class).invoke(null, daten.size());
|
||||
Method hole = puffer.getClass().getMethod("get", int.class);
|
||||
Method setze = bild.getMethod("set", int.class, int.class, ByteBuffer.class);
|
||||
for (int i = 0; i < daten.size(); i++) {
|
||||
ByteBuffer px = ByteBuffer.allocateDirect(daten.get(i).length);
|
||||
px.put(daten.get(i)).flip();
|
||||
synchronized (FESTHALTEN) { FESTHALTEN.add(px); }
|
||||
setze.invoke(hole.invoke(puffer, i), groessen.get(i)[0], groessen.get(i)[1], px);
|
||||
}
|
||||
return puffer;
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] Fenstersymbol nicht gesetzt: " + t);
|
||||
return original;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package de.aeromc.capes;
|
||||
|
||||
import java.lang.instrument.ClassFileTransformer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.ProtectionDomain;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
import org.objectweb.asm.tree.InsnList;
|
||||
import org.objectweb.asm.tree.InsnNode;
|
||||
import org.objectweb.asm.tree.IntInsnNode;
|
||||
import org.objectweb.asm.tree.LdcInsnNode;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.MethodNode;
|
||||
import org.objectweb.asm.tree.TypeInsnNode;
|
||||
import org.objectweb.asm.tree.VarInsnNode;
|
||||
|
||||
/**
|
||||
* Baut beim Laden um (siehe Design):
|
||||
*
|
||||
* 1. Ladebildschirm – erkannt am Text "textures/gui/title/mojangstudios.png"
|
||||
* (ab 1.16) bzw. "…/mojang.png" (1.14/1.15), denn die Klassennamen sind je
|
||||
* Version und Loader anders (verschleiert, Intermediary, SRG, Mojang-Namen):
|
||||
* ab 1.16: color(255, 239, 50, 61) -> AeroMC-Nachtblau
|
||||
* color(alpha, 255, 255, 255) -> Balkenfarbe
|
||||
* 1.14/1.15: weiße Füllung (0xFFFFFF / -1) -> Nachtblau,
|
||||
* Balken-Rot (226, 40, 55) -> Balkenfarbe
|
||||
* 2. Logo-Textur – die innere Klasse des Ladebildschirms ("…$…") liest das
|
||||
* Logo mit NativeImage.read(InputStream); davor wird der Strom getauscht.
|
||||
* 3. LWJGL-GLFW – Fenstertitel (glfwCreateWindow/glfwSetWindowTitle) und
|
||||
* Fenstersymbol (glfwSetWindowIcon).
|
||||
*
|
||||
* Eingefügt bzw. ersetzt wird nur geradliniger Code ohne Sprünge mit gleicher
|
||||
* Stapelwirkung – die vorhandenen Stack-Map-Frames bleiben gültig.
|
||||
*/
|
||||
final class DesignUmbau implements ClassFileTransformer {
|
||||
private static final byte[] MERKMAL = "textures/gui/title/mojang".getBytes(StandardCharsets.UTF_8);
|
||||
private static final String LOGO_NEU = "textures/gui/title/mojangstudios.png";
|
||||
private static final String LOGO_ALT = "textures/gui/title/mojang.png";
|
||||
private static final byte[] TITEL_MERKMAL = "textures/gui/title/minecraft.png".getBytes(StandardCharsets.UTF_8);
|
||||
private static final int KEIN_WERT = Integer.MIN_VALUE;
|
||||
|
||||
/** Name der Ladebildschirm-Klasse, sobald sie gesehen wurde. */
|
||||
private volatile String ladeKlasse;
|
||||
private volatile boolean ladeAlt;
|
||||
|
||||
@Override
|
||||
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
|
||||
if (name == null || umdefiniert != null) return null;
|
||||
try {
|
||||
if ("org/lwjgl/glfw/GLFW".equals(name)) return glfw(klasse);
|
||||
if (name.startsWith("java/") || name.startsWith("jdk/") || name.startsWith("sun/")
|
||||
|| name.startsWith("de/aeromc/") || name.startsWith("org/lwjgl/")) return null;
|
||||
String lk = ladeKlasse;
|
||||
if (lk != null && name.startsWith(lk + "$")) return logoStrom(klasse, ladeAlt);
|
||||
if (enthaelt(klasse, MERKMAL)) return ladeBildschirm(name, klasse);
|
||||
if (enthaelt(klasse, TITEL_MERKMAL)) return titelLogo(name, klasse);
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] Spieldesign: " + name + " nicht angepasst: " + t);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 1. Ladebildschirm ----------
|
||||
|
||||
private byte[] ladeBildschirm(String name, byte[] klasse) {
|
||||
ClassNode cn = lies(klasse);
|
||||
boolean neu = hatText(cn, LOGO_NEU), alt = hatText(cn, LOGO_ALT);
|
||||
if (!neu && !alt) return null;
|
||||
ladeKlasse = name;
|
||||
ladeAlt = alt && !neu;
|
||||
int r = Design.HINTERGRUND >> 16 & 255, g = Design.HINTERGRUND >> 8 & 255, b = Design.HINTERGRUND & 255;
|
||||
int br = Design.BALKEN >> 16 & 255, bg = Design.BALKEN >> 8 & 255, bb = Design.BALKEN & 255;
|
||||
int hintergrund = 0, balken = 0, vollbild = 0, balkenBreite = 0;
|
||||
for (MethodNode m : cn.methods) {
|
||||
List<AbstractInsnNode> a = befehle(m);
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
if (!ladeAlt) {
|
||||
// color(255, 239, 50, 61) – Mojang-Rot
|
||||
if (i + 4 < a.size() && wert(a.get(i)) == 255 && wert(a.get(i + 1)) == 239
|
||||
&& wert(a.get(i + 2)) == 50 && wert(a.get(i + 3)) == 61 && aufruf(a.get(i + 4), "(IIII)I")) {
|
||||
setze(m, a, i + 1, r); setze(m, a, i + 2, g); setze(m, a, i + 3, b);
|
||||
hintergrund++;
|
||||
}
|
||||
// color(alpha, 255, 255, 255) – weißer Balken
|
||||
if (i + 4 < a.size() && a.get(i).getOpcode() == Opcodes.ILOAD && wert(a.get(i + 1)) == 255
|
||||
&& wert(a.get(i + 2)) == 255 && wert(a.get(i + 3)) == 255 && aufruf(a.get(i + 4), "(IIII)I")) {
|
||||
setze(m, a, i + 1, br); setze(m, a, i + 2, bg); setze(m, a, i + 3, bb);
|
||||
balken++;
|
||||
}
|
||||
// Logo-Fläche auf Vollbild: Höhe = min(Breite·0,75; Höhe)·0,25 und Breite = 4·Höhe
|
||||
// wird zu Höhe = min(Breite·9/16; Höhe) und Breite = 16/9·Höhe (Bild im 16:9-Format)
|
||||
AbstractInsnNode n = a.get(i);
|
||||
if (n instanceof LdcInsnNode && ((LdcInsnNode) n).cst instanceof Double) {
|
||||
double d = (Double) ((LdcInsnNode) n).cst;
|
||||
double ersatzWert = d == 0.75 ? 9.0 / 16.0 : d == 0.25 ? 1.0 : d == 4.0 ? 16.0 / 9.0 : -1;
|
||||
if (ersatzWert > 0) {
|
||||
LdcInsnNode e = new LdcInsnNode(ersatzWert);
|
||||
m.instructions.set(n, e);
|
||||
a.set(i, e);
|
||||
vollbild++;
|
||||
}
|
||||
}
|
||||
// Fortschrittsbalken: Minecraft macht ihn so breit wie das Logo – jetzt also
|
||||
// bildschirmbreit; beim Aufruf auf einen Teil der Breite in der Mitte kürzen
|
||||
if (n instanceof MethodInsnNode && ((MethodInsnNode) n).owner.equals(name)
|
||||
&& ((MethodInsnNode) n).desc.endsWith("IIIIF)V")) {
|
||||
m.instructions.insertBefore(n, balkenKuerzen(m));
|
||||
balkenBreite++;
|
||||
}
|
||||
} else {
|
||||
AbstractInsnNode n = a.get(i);
|
||||
// weiße Füllung beim Ein-/Ausblenden: 0xFFFFFF | alpha << 24
|
||||
if (n instanceof LdcInsnNode && Integer.valueOf(0xFFFFFF).equals(((LdcInsnNode) n).cst)) {
|
||||
m.instructions.set(n, ersatz(Design.HINTERGRUND, a, i));
|
||||
hintergrund++;
|
||||
}
|
||||
// fill(0, 0, breite, hoehe, -1) – weißer Hintergrund
|
||||
if (i + 5 < a.size() && n.getOpcode() == Opcodes.ICONST_0 && a.get(i + 1).getOpcode() == Opcodes.ICONST_0
|
||||
&& a.get(i + 2).getOpcode() == Opcodes.ILOAD && a.get(i + 3).getOpcode() == Opcodes.ILOAD
|
||||
&& a.get(i + 4).getOpcode() == Opcodes.ICONST_M1 && aufruf(a.get(i + 5), "(IIIII)V")) {
|
||||
m.instructions.set(a.get(i + 4), ersatz(0xFF000000 | Design.HINTERGRUND, a, i + 4));
|
||||
hintergrund++;
|
||||
}
|
||||
// Balken-Rot (226, 40, 55) als Gleitkommazahlen
|
||||
if (n instanceof LdcInsnNode && ((LdcInsnNode) n).cst instanceof Float) {
|
||||
float f = (Float) ((LdcInsnNode) n).cst;
|
||||
float neuWert = f == 226f ? br : f == 40f ? bg : f == 55f ? bb : -1f;
|
||||
if (neuWert >= 0) {
|
||||
LdcInsnNode e = new LdcInsnNode(neuWert);
|
||||
m.instructions.set(n, e);
|
||||
a.set(i, e);
|
||||
balken++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("[AeroMC] Ladebildschirm angepasst (" + (ladeAlt ? "1.14/1.15" : "ab 1.16")
|
||||
+ ", Hintergrund " + hintergrund + ", Balken " + balken + ", Vollbild " + vollbild + ", Balkenbreite " + balkenBreite + ")");
|
||||
return schreibe(cn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vor dem Aufruf drawProgressBar(…, x1, y1, x2, y2, f): die vier Zahlen und f in
|
||||
* neue lokale Variablen legen, x1/x2 um die Mitte auf BALKEN_ANTEIL der Breite
|
||||
* zusammenziehen und alles wieder auf den Stapel legen.
|
||||
*/
|
||||
private static InsnList balkenKuerzen(MethodNode m) {
|
||||
int b = m.maxLocals;
|
||||
m.maxLocals += 7;
|
||||
int x1 = b, y1 = b + 1, x2 = b + 2, y2 = b + 3, f = b + 4, mitte = b + 5, halb = b + 6;
|
||||
InsnList l = new InsnList();
|
||||
l.add(new VarInsnNode(Opcodes.FSTORE, f));
|
||||
l.add(new VarInsnNode(Opcodes.ISTORE, y2));
|
||||
l.add(new VarInsnNode(Opcodes.ISTORE, x2));
|
||||
l.add(new VarInsnNode(Opcodes.ISTORE, y1));
|
||||
l.add(new VarInsnNode(Opcodes.ISTORE, x1));
|
||||
// mitte = (x1 + x2) / 2
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, x1));
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, x2));
|
||||
l.add(new InsnNode(Opcodes.IADD));
|
||||
l.add(new InsnNode(Opcodes.ICONST_2));
|
||||
l.add(new InsnNode(Opcodes.IDIV));
|
||||
l.add(new VarInsnNode(Opcodes.ISTORE, mitte));
|
||||
// halb = (int) ((x2 - x1) * anteil / 2)
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, x2));
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, x1));
|
||||
l.add(new InsnNode(Opcodes.ISUB));
|
||||
l.add(new InsnNode(Opcodes.I2F));
|
||||
l.add(new LdcInsnNode(Design.BALKEN_ANTEIL / 2f));
|
||||
l.add(new InsnNode(Opcodes.FMUL));
|
||||
l.add(new InsnNode(Opcodes.F2I));
|
||||
l.add(new VarInsnNode(Opcodes.ISTORE, halb));
|
||||
// (mitte - halb, y1, mitte + halb, y2, f)
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, mitte));
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, halb));
|
||||
l.add(new InsnNode(Opcodes.ISUB));
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, y1));
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, mitte));
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, halb));
|
||||
l.add(new InsnNode(Opcodes.IADD));
|
||||
l.add(new VarInsnNode(Opcodes.ILOAD, y2));
|
||||
l.add(new VarInsnNode(Opcodes.FLOAD, f));
|
||||
return l;
|
||||
}
|
||||
|
||||
// ---------- 1b. Titel-Logo im Hauptmenü ----------
|
||||
// Minecraft zeichnet vom Logo nur einen 44 Punkte hohen Streifen – zu knapp für
|
||||
// Pyramide, großen Schriftzug und Untertitel. Hier wird er auf 64 Punkte erweitert:
|
||||
// bis 1.19.4: blit(u 0, v 0, 155, 44) + blit(u 0, v 45, 155, 44) -> Höhe 64, zweites Stück ab v 65
|
||||
// ab 1.20: blit(…, 256, 44, Textur 256, 64) -> Höhe 64
|
||||
private byte[] titelLogo(String name, byte[] klasse) {
|
||||
ClassNode cn = lies(klasse);
|
||||
int n = 0;
|
||||
for (MethodNode m : cn.methods) {
|
||||
List<AbstractInsnNode> a = befehle(m);
|
||||
for (int i = 0; i + 4 < a.size(); i++) {
|
||||
int w0 = wert(a.get(i)), w1 = wert(a.get(i + 1)), w2 = wert(a.get(i + 2)), w3 = wert(a.get(i + 3));
|
||||
boolean aufrufDanach = a.get(i + 4) instanceof MethodInsnNode;
|
||||
if (w0 == 0 && w1 == 0 && w2 == 155 && w3 == 44 && aufrufDanach) {
|
||||
setze(m, a, i + 3, 64); n++;
|
||||
} else if (w0 == 0 && w1 == 45 && w2 == 155 && w3 == 44 && aufrufDanach) {
|
||||
setze(m, a, i + 1, 65); setze(m, a, i + 3, 64); n++;
|
||||
} else if (w0 == 256 && w1 == 44 && w2 == 256 && w3 == 64) {
|
||||
setze(m, a, i + 1, 64); n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n == 0) return null;
|
||||
System.out.println("[AeroMC] Titel-Logo vergroessert (" + n + ")");
|
||||
return schreibe(cn);
|
||||
}
|
||||
|
||||
// ---------- 2. Logo-Textur ----------
|
||||
|
||||
private byte[] logoStrom(byte[] klasse, boolean alt) {
|
||||
ClassNode cn = lies(klasse);
|
||||
int n = 0;
|
||||
for (MethodNode m : cn.methods) {
|
||||
for (AbstractInsnNode i : befehle(m)) {
|
||||
if (i.getOpcode() != Opcodes.INVOKESTATIC) continue;
|
||||
MethodInsnNode mi = (MethodInsnNode) i;
|
||||
if (!mi.desc.startsWith("(Ljava/io/InputStream;)L") || mi.owner.startsWith("java/")) continue;
|
||||
// Stapel: [strom] -> [strom, helfer] -> [helfer, strom] -> apply -> [neuer strom]
|
||||
InsnList neu = new InsnList();
|
||||
helfer(neu, alt ? Design.LOGO_ALT : Design.LOGO);
|
||||
neu.add(new InsnNode(Opcodes.SWAP));
|
||||
neu.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE, "java/util/function/Function", "apply",
|
||||
"(Ljava/lang/Object;)Ljava/lang/Object;", true));
|
||||
neu.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/io/InputStream"));
|
||||
m.instructions.insertBefore(i, neu);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n == 0) return null;
|
||||
System.out.println("[AeroMC] Ladelogo ersetzt");
|
||||
return schreibe(cn);
|
||||
}
|
||||
|
||||
// ---------- 3. Fenstertitel und -symbol ----------
|
||||
|
||||
private byte[] glfw(byte[] klasse) {
|
||||
ClassNode cn = lies(klasse);
|
||||
int n = 0;
|
||||
for (MethodNode m : cn.methods) {
|
||||
String schluessel = null, typ = null;
|
||||
if ("glfwCreateWindow".equals(m.name) && "(IILjava/lang/CharSequence;JJ)J".equals(m.desc)) {
|
||||
schluessel = Design.TITEL; typ = "java/lang/CharSequence";
|
||||
} else if ("glfwSetWindowTitle".equals(m.name) && "(JLjava/lang/CharSequence;)V".equals(m.desc)) {
|
||||
schluessel = Design.TITEL; typ = "java/lang/CharSequence";
|
||||
} else if ("glfwSetWindowIcon".equals(m.name) && "(JLorg/lwjgl/glfw/GLFWImage$Buffer;)V".equals(m.desc)) {
|
||||
schluessel = Design.ICON; typ = "org/lwjgl/glfw/GLFWImage$Buffer";
|
||||
}
|
||||
if (schluessel == null) continue;
|
||||
// am Anfang: argument = helfer.apply(argument) (Argument liegt in beiden Fällen in Slot 2)
|
||||
InsnList neu = new InsnList();
|
||||
helfer(neu, schluessel);
|
||||
neu.add(new VarInsnNode(Opcodes.ALOAD, 2));
|
||||
neu.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE, "java/util/function/Function", "apply",
|
||||
"(Ljava/lang/Object;)Ljava/lang/Object;", true));
|
||||
neu.add(new TypeInsnNode(Opcodes.CHECKCAST, typ));
|
||||
neu.add(new VarInsnNode(Opcodes.ASTORE, 2));
|
||||
m.instructions.insert(neu);
|
||||
n++;
|
||||
}
|
||||
if (n == 0) return null;
|
||||
System.out.println("[AeroMC] Fenstertitel und -symbol vorbereitet (" + n + ")");
|
||||
return schreibe(cn);
|
||||
}
|
||||
|
||||
// ---------- Hilfen ----------
|
||||
|
||||
/** legt (Function) System.getProperties().get(schluessel) auf den Stapel */
|
||||
private static void helfer(InsnList l, String schluessel) {
|
||||
l.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false));
|
||||
l.add(new LdcInsnNode(schluessel));
|
||||
l.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false));
|
||||
l.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/util/function/Function"));
|
||||
}
|
||||
|
||||
private static ClassNode lies(byte[] klasse) {
|
||||
ClassNode cn = new ClassNode();
|
||||
new ClassReader(klasse).accept(cn, 0);
|
||||
return cn;
|
||||
}
|
||||
|
||||
private static byte[] schreibe(ClassNode cn) {
|
||||
ClassWriter w = new ClassWriter(ClassWriter.COMPUTE_MAXS);
|
||||
cn.accept(w);
|
||||
return w.toByteArray();
|
||||
}
|
||||
|
||||
/** echte Befehle eines Methodenrumpfs (ohne Labels, Zeilennummern, Frames) */
|
||||
private static List<AbstractInsnNode> befehle(MethodNode m) {
|
||||
List<AbstractInsnNode> l = new ArrayList<>();
|
||||
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||
if (i.getOpcode() >= 0) l.add(i);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
private static boolean hatText(ClassNode cn, String text) {
|
||||
for (MethodNode m : cn.methods) {
|
||||
for (AbstractInsnNode i : befehle(m)) {
|
||||
if (i instanceof LdcInsnNode && text.equals(((LdcInsnNode) i).cst)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Zahlenwert eines Konstanten-Befehls, sonst KEIN_WERT */
|
||||
private static int wert(AbstractInsnNode i) {
|
||||
int op = i.getOpcode();
|
||||
if (op >= Opcodes.ICONST_M1 && op <= Opcodes.ICONST_5) return op - Opcodes.ICONST_0;
|
||||
if (op == Opcodes.BIPUSH || op == Opcodes.SIPUSH) return ((IntInsnNode) i).operand;
|
||||
if (i instanceof LdcInsnNode && ((LdcInsnNode) i).cst instanceof Integer) return (Integer) ((LdcInsnNode) i).cst;
|
||||
return KEIN_WERT;
|
||||
}
|
||||
|
||||
private static boolean aufruf(AbstractInsnNode i, String desc) {
|
||||
return (i.getOpcode() == Opcodes.INVOKESTATIC || i.getOpcode() == Opcodes.INVOKEVIRTUAL)
|
||||
&& desc.equals(((MethodInsnNode) i).desc);
|
||||
}
|
||||
|
||||
private static void setze(MethodNode m, List<AbstractInsnNode> a, int index, int v) {
|
||||
m.instructions.set(a.get(index), ersatz(v, a, index));
|
||||
}
|
||||
|
||||
/** neuer Konstanten-Befehl; ersetzt auch den Eintrag in der Befehlsliste */
|
||||
private static AbstractInsnNode ersatz(int v, List<AbstractInsnNode> a, int index) {
|
||||
AbstractInsnNode n = v >= -32768 && v <= 32767 ? new IntInsnNode(Opcodes.SIPUSH, v) : new LdcInsnNode(v);
|
||||
a.set(index, n);
|
||||
return n;
|
||||
}
|
||||
|
||||
private static boolean enthaelt(byte[] daten, byte[] muster) {
|
||||
outer:
|
||||
for (int i = 0, ende = daten.length - muster.length; i <= ende; i++) {
|
||||
for (int k = 0; k < muster.length; k++) {
|
||||
if (daten[i + k] != muster[k]) continue outer;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package de.aeromc.capes;
|
||||
|
||||
import java.lang.instrument.ClassFileTransformer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.ProtectionDomain;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.Type;
|
||||
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
import org.objectweb.asm.tree.FieldInsnNode;
|
||||
import org.objectweb.asm.tree.FrameNode;
|
||||
import org.objectweb.asm.tree.InsnList;
|
||||
import org.objectweb.asm.tree.InsnNode;
|
||||
import org.objectweb.asm.tree.JumpInsnNode;
|
||||
import org.objectweb.asm.tree.LabelNode;
|
||||
import org.objectweb.asm.tree.LdcInsnNode;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.MethodNode;
|
||||
import org.objectweb.asm.tree.TypeInsnNode;
|
||||
import org.objectweb.asm.tree.VarInsnNode;
|
||||
|
||||
/**
|
||||
* Mod-Schalter im Pausemenü (Minecraft 1.14 … 26.x, jeder Loader):
|
||||
* Der Knopf „Feedback geben“ wird zum Knopf „Mods verwalten“.
|
||||
*
|
||||
* 1. Seine Adresse (aka.ms/javafeedback bzw. snapshotfeedback) wird zu
|
||||
* "aeromc://mods" – je nach Version steht sie im Pausemenü selbst
|
||||
* (bis 1.20) oder in CommonLinks (ab 1.21).
|
||||
* 2. Die Lambdas, über die Minecraft einen Link öffnet (im Pausemenü bzw. in
|
||||
* ConfirmLinkScreen), fragen vorher den Helfer: Ist es "aeromc://mods",
|
||||
* öffnet der Launcher das Mod-Fenster und Minecraft zeigt keine Rückfrage.
|
||||
* Alle anderen Links laufen unverändert weiter.
|
||||
*
|
||||
* Beschriftung bzw. Symbol des Knopfs kommen aus dem Ressourcenpaket.
|
||||
*/
|
||||
final class ModsUmbau implements ClassFileTransformer {
|
||||
static final String ZIEL = "aeromc://mods";
|
||||
private static final String[] ADRESSEN = {"https://aka.ms/javafeedback?ref=game", "https://aka.ms/snapshotfeedback?ref=game"};
|
||||
private static final byte[] MERKMAL_FEEDBACK = "aka.ms/".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] MERKMAL_LINK = "chat.link.confirm".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Override
|
||||
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
|
||||
if (name == null || umdefiniert != null || name.startsWith("java/") || name.startsWith("jdk/")
|
||||
|| name.startsWith("sun/") || name.startsWith("de/aeromc/") || name.startsWith("org/lwjgl/")) return null;
|
||||
try {
|
||||
boolean feedback = enthaelt(klasse, MERKMAL_FEEDBACK);
|
||||
boolean link = enthaelt(klasse, MERKMAL_LINK);
|
||||
if (!feedback && !link) return null;
|
||||
ClassNode cn = new ClassNode();
|
||||
new ClassReader(klasse).accept(cn, 0);
|
||||
int adressen = 0, waechter = 0;
|
||||
for (MethodNode m : cn.methods) {
|
||||
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||
if (i instanceof LdcInsnNode && istFeedbackAdresse(((LdcInsnNode) i).cst)) {
|
||||
((LdcInsnNode) i).cst = ZIEL;
|
||||
adressen++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (adressen == 0 && !link) return null;
|
||||
for (MethodNode m : cn.methods) {
|
||||
if ((m.access & Opcodes.ACC_SYNTHETIC) == 0 || Type.getReturnType(m.desc) != Type.VOID_TYPE) continue;
|
||||
int slot = adressParameter(m);
|
||||
boolean konstante = slot < 0 && enthaeltZiel(m);
|
||||
if (slot < 0 && !konstante) continue;
|
||||
if (frameAmAnfang(m)) continue; // nicht sicher erweiterbar
|
||||
InsnList w = new InsnList();
|
||||
w.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false));
|
||||
w.add(new LdcInsnNode(Design.MODS));
|
||||
w.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false));
|
||||
w.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/util/function/Function"));
|
||||
if (slot >= 0) w.add(new VarInsnNode(Opcodes.ALOAD, slot)); else w.add(new LdcInsnNode(ZIEL));
|
||||
w.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE, "java/util/function/Function", "apply", "(Ljava/lang/Object;)Ljava/lang/Object;", true));
|
||||
w.add(new FieldInsnNode(Opcodes.GETSTATIC, "java/lang/Boolean", "TRUE", "Ljava/lang/Boolean;"));
|
||||
LabelNode weiter = new LabelNode();
|
||||
w.add(new JumpInsnNode(Opcodes.IF_ACMPNE, weiter));
|
||||
w.add(new InsnNode(Opcodes.RETURN));
|
||||
w.add(weiter);
|
||||
w.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
|
||||
m.instructions.insert(w);
|
||||
waechter++;
|
||||
}
|
||||
if (adressen == 0 && waechter == 0) return null;
|
||||
System.out.println("[AeroMC] Mod-Schalter vorbereitet (Adressen " + adressen + ", Links " + waechter + ")");
|
||||
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
|
||||
cn.accept(cw);
|
||||
return cw.toByteArray();
|
||||
} catch (Throwable t) {
|
||||
System.out.println("[AeroMC] Mod-Schalter: " + name + " nicht angepasst: " + t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean istFeedbackAdresse(Object cst) {
|
||||
for (String a : ADRESSEN) if (a.equals(cst)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Slot des ersten Parameters vom Typ String oder URI, sonst -1 */
|
||||
private static int adressParameter(MethodNode m) {
|
||||
int slot = (m.access & Opcodes.ACC_STATIC) != 0 ? 0 : 1;
|
||||
for (Type t : Type.getArgumentTypes(m.desc)) {
|
||||
String d = t.getDescriptor();
|
||||
if ("Ljava/lang/String;".equals(d) || "Ljava/net/URI;".equals(d)) return slot;
|
||||
slot += t.getSize();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean enthaeltZiel(MethodNode m) {
|
||||
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||
if (i instanceof LdcInsnNode && ZIEL.equals(((LdcInsnNode) i).cst)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** steht vor dem ersten echten Befehl schon ein Frame (Sprungziel am Anfang)? */
|
||||
private static boolean frameAmAnfang(MethodNode m) {
|
||||
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||
if (i instanceof FrameNode) return true;
|
||||
if (i.getOpcode() >= 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean enthaelt(byte[] daten, byte[] muster) {
|
||||
outer:
|
||||
for (int i = 0, ende = daten.length - muster.length; i <= ende; i++) {
|
||||
for (int k = 0; k < muster.length; k++) if (daten[i + k] != muster[k]) continue outer;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,80 @@
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Enumeration;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.commons.ClassRemapper;
|
||||
import org.objectweb.asm.commons.Remapper;
|
||||
|
||||
/**
|
||||
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
|
||||
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
|
||||
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
|
||||
*
|
||||
* Aufruf: java Shade <ziel.jar> <klassenordner> <asm.jar>
|
||||
*/
|
||||
public class Shade {
|
||||
static final Remapper UMBENENNEN = new Remapper() {
|
||||
@Override
|
||||
public String map(String name) {
|
||||
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
|
||||
}
|
||||
};
|
||||
|
||||
static byte[] umbenennen(byte[] klasse) {
|
||||
ClassReader r = new ClassReader(klasse);
|
||||
ClassWriter w = new ClassWriter(0);
|
||||
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
|
||||
return w.toByteArray();
|
||||
}
|
||||
|
||||
static byte[] lies(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||
byte[] p = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(p)) > 0) b.write(p, 0, n);
|
||||
return b.toByteArray();
|
||||
}
|
||||
|
||||
public static void main(String[] a) throws Exception {
|
||||
Manifest mf = new Manifest();
|
||||
Attributes at = mf.getMainAttributes();
|
||||
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
||||
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Can-Retransform-Classes", "false");
|
||||
at.putValue("Implementation-Title", "AeroMC Umhang-Agent");
|
||||
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
|
||||
// eigene Klassen
|
||||
File basis = new File(a[1]);
|
||||
for (File f : Files.walk(basis.toPath()).map(p -> p.toFile()).filter(f -> f.getName().endsWith(".class")).toArray(File[]::new)) {
|
||||
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
|
||||
out.putNextEntry(new JarEntry(name));
|
||||
out.write(umbenennen(Files.readAllBytes(f.toPath())));
|
||||
out.closeEntry();
|
||||
}
|
||||
// ASM (umbenannt), ohne Modul-Infos und Signaturen
|
||||
try (JarFile asm = new JarFile(a[2])) {
|
||||
Enumeration<JarEntry> e = asm.entries();
|
||||
while (e.hasMoreElements()) {
|
||||
JarEntry je = e.nextElement();
|
||||
String n = je.getName();
|
||||
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
|
||||
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
|
||||
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
|
||||
out.write(neu);
|
||||
out.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Enumeration;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.commons.ClassRemapper;
|
||||
import org.objectweb.asm.commons.Remapper;
|
||||
|
||||
/**
|
||||
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
|
||||
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
|
||||
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
|
||||
*
|
||||
* Aufruf: java Shade <ziel.jar> <klassenordner> <asm.jar> [weitere ASM-Jars, z. B. asm-tree]
|
||||
*/
|
||||
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 Agent (Umhaenge, Spieldesign)");
|
||||
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
|
||||
for (int j = 2; j < a.length; j++) try (JarFile asm = new JarFile(a[j])) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +181,35 @@ public static class Installer
|
||||
k.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||
}
|
||||
|
||||
progress.Report((0.98, "Installation wird abgeschlossen …"));
|
||||
Vorwaermen(exe);
|
||||
|
||||
progress.Report((1.0, "Fertig"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet AeroMC einmal kurz mit "--aeromc-vorwaermen" – die App beendet sich
|
||||
/// damit sofort wieder (siehe src/main.js). Windows Defender prüft eine neue,
|
||||
/// noch unbekannte EXE beim ersten Start mehrere Sekunden lang (gemessen: ~8 s);
|
||||
/// das passiert so während des Fortschrittsbalkens und nicht erst beim Klick
|
||||
/// auf "Fertigstellen". Das Ergebnis merkt sich Windows, der echte Start ist
|
||||
/// danach sofort da.
|
||||
/// </summary>
|
||||
private static void Vorwaermen(string exe)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(new ProcessStartInfo(exe, "--aeromc-vorwaermen")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(exe)!,
|
||||
});
|
||||
if (p != null && !p.WaitForExit(30000)) { try { p.Kill(); } catch { } }
|
||||
}
|
||||
catch { /* nur eine Beschleunigung – Fehler sind egal */ }
|
||||
}
|
||||
|
||||
private static List<string> TopLevelNames(ZipArchive zip) =>
|
||||
zip.Entries
|
||||
.Select(e => e.FullName.Replace('\\', '/').Split('/')[0])
|
||||
|
||||
@@ -300,7 +300,16 @@ public partial class MainWindow : Window
|
||||
await RunInstall(DirBox.Text.Trim(), DesktopLinkBox.IsChecked == true, update: false);
|
||||
break;
|
||||
case PageNumProgress:
|
||||
if (LaunchBox.IsChecked == true) StartApp();
|
||||
// Start im Hintergrund: Process.Start kann blockieren, solange Windows
|
||||
// die EXE prüft – das Fenster darf dabei nie einfrieren
|
||||
if (LaunchBox.IsChecked == true)
|
||||
{
|
||||
_busy = true;
|
||||
NextButton.IsEnabled = false;
|
||||
NextButton.Content = "AeroMC startet …";
|
||||
await Task.Run(StartApp);
|
||||
_busy = false;
|
||||
}
|
||||
Close();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"projects": {
|
||||
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.3",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
|
||||
@@ -14,11 +14,11 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.0")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.3.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.3")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.0.0")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.3.0")]
|
||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -1 +1 @@
|
||||
e1b134ff0a2fb2387ee195d0721c9af9546a7821126b2fd5bae3f90099e4d686
|
||||
111db8ee3dc7193961107c73afd8913fff02b92fd4e49b8b7e4bf051181600bd
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
"C:\\Users\\Viper\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.3",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "IwFtzT9tvgw=",
|
||||
"dgSpecHash": "5nf4o8NXPS0=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"expectedPackageFiles": [
|
||||
|
||||
@@ -1,54 +1,64 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.2.0",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/write-build-config.js",
|
||||
"prebuild:portable": "node scripts/write-build-config.js",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.aeromc.launcher",
|
||||
"productName": "AeroMC",
|
||||
"artifactName": "AeroMC.${ext}",
|
||||
"compression": "store",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/loopback-capture/**/*",
|
||||
"node_modules/node-window-manager/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "dir",
|
||||
"icon": "src/assets/icon.ico"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"archiver": "^7.0.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"loopback-capture": "^3.0.2",
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"sql.js": "^1.14.2",
|
||||
"undici": "^6.28.0",
|
||||
"unzipper": "^0.12.5"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.2.3",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
|
||||
"prebuild:portable": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js && node scripts/sign-release.js setup",
|
||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\" && node scripts/sign-release.js portable"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.aeromc.launcher",
|
||||
"productName": "AeroMC",
|
||||
"artifactName": "AeroMC.${ext}",
|
||||
"compression": "store",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/loopback-capture/**/*",
|
||||
"node_modules/node-window-manager/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "dir",
|
||||
"icon": "src/assets/icon.ico"
|
||||
},
|
||||
"electronFuses": {
|
||||
"runAsNode": false,
|
||||
"enableNodeOptionsEnvironmentVariable": false,
|
||||
"enableNodeCliInspectArguments": false,
|
||||
"enableEmbeddedAsarIntegrityValidation": true,
|
||||
"onlyLoadAppFromAsar": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.6.0",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"archiver": "^7.0.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"loopback-capture": "^3.0.2",
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"sql.js": "^1.14.2",
|
||||
"undici": "^6.28.0",
|
||||
"unzipper": "^0.12.5"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion@^2.0.0": "^2.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,73 @@
|
||||
'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 });
|
||||
}
|
||||
'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 asmTree = findeAsm('asm-tree');
|
||||
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, asmTree].join(path.delimiter), '-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, asmTree], { stdio: 'inherit' });
|
||||
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
"""
|
||||
make-spieldesign.py – erzeugt die Grafiken für den AeroMC-Look im Spiel
|
||||
=======================================================================
|
||||
Ausgabe nach src/assets/spieldesign/:
|
||||
|
||||
titel_alt.png / titel_neu.png Titel-Logo (textures/gui/title/minecraft.png)
|
||||
alt = bis 1.19.4 (zweigeteilt im 256×256-Atlas),
|
||||
neu = ab 1.20 (ein Stück, 256×64)
|
||||
edition_alt.png / edition_neu.png Untertitel "DEIN MINECRAFT ERLEBNIS" (edition.png)
|
||||
ladelogo.png Ladebildschirm ab 1.16 als Vollbild (der Agent zieht
|
||||
die Logo-Fläche auf Bildschirmgröße); im Format von
|
||||
mojangstudios.png: linke Bildhälfte oben, rechte unten
|
||||
ladelogo_alt.png Logo im Ladebildschirm 1.14/1.15 (ersetzt mojang.png)
|
||||
panorama_0..5.png Hintergrund des Hauptmenüs (Würfelseiten)
|
||||
pack.png, icon_<n>.rgba Paket- und Fenstersymbol
|
||||
|
||||
Eigene Bilder (optional) in scripts/spieldesign-quelle/:
|
||||
hintergrund.png|jpg Ladebildschirm-Hintergrund (am besten 16:9, z. B. 1920×1080)
|
||||
panorama.png|jpg Rundum-Panorama (360° × 180°, equirektangular, Seitenverhältnis 2:1)
|
||||
Fehlen sie, wird eine stilisierte Block-Landschaft in AeroMC-Blau gezeichnet.
|
||||
|
||||
Alle Grafiken sind eigene Entwürfe – kein Mojang-Material.
|
||||
Aufruf: python scripts/make-spieldesign.py
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
WURZEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
|
||||
ZIEL = os.path.join(WURZEL, 'src', 'assets', 'spieldesign')
|
||||
QUELLE = os.path.join(WURZEL, 'scripts', 'spieldesign-quelle')
|
||||
LOGO = os.path.join(WURZEL, 'src', 'assets', 'logo.png')
|
||||
SCHRIFT = 'C:/Windows/Fonts/seguisb.ttf'
|
||||
NACHTBLAU = (10, 14, 26) # Hintergrund des Ladebildschirms (muss zu Design.HINTERGRUND passen)
|
||||
KONTUR = (7, 14, 36)
|
||||
|
||||
# ---------- Block-Buchstaben (7 Zeilen hoch) ----------
|
||||
GLYPHEN = {
|
||||
'A': ['.####.', '##..##', '##..##', '######', '##..##', '##..##', '##..##'],
|
||||
'E': ['######', '##....', '##....', '#####.', '##....', '##....', '######'],
|
||||
'R': ['#####.', '##..##', '##..##', '#####.', '##.##.', '##..##', '##..##'],
|
||||
'O': ['.####.', '##..##', '##..##', '##..##', '##..##', '##..##', '.####.'],
|
||||
'M': ['##...##', '###.###', '#######', '##.#.##', '##...##', '##...##', '##...##'],
|
||||
'C': ['.#####', '##....', '##....', '##....', '##....', '##....', '.#####'],
|
||||
}
|
||||
# kleine Pixelschrift für den Untertitel (5 Zeilen hoch)
|
||||
KLEIN = {
|
||||
'D': ['###.', '#..#', '#..#', '#..#', '###.'], 'E': ['###', '#..', '##.', '#..', '###'],
|
||||
'I': ['#', '#', '#', '#', '#'], 'N': ['#..#', '##.#', '#.##', '#..#', '#..#'],
|
||||
'M': ['#...#', '##.##', '#.#.#', '#...#', '#...#'], 'C': ['.##', '#..', '#..', '#..', '.##'],
|
||||
'R': ['##.', '#.#', '##.', '#.#', '#.#'], 'A': ['.#.', '#.#', '###', '#.#', '#.#'],
|
||||
'F': ['###', '#..', '##.', '#..', '#..'], 'T': ['###', '.#.', '.#.', '.#.', '.#.'],
|
||||
'L': ['#..', '#..', '#..', '#..', '###'], 'B': ['##.', '#.#', '##.', '#.#', '##.'],
|
||||
'S': ['.##', '#..', '.#.', '..#', '##.'],
|
||||
}
|
||||
|
||||
|
||||
def raster(text, glyphen, abstand=1, wortabstand=3):
|
||||
punkte, x = [], 0
|
||||
for i, z in enumerate(text):
|
||||
if z == ' ':
|
||||
x += wortabstand
|
||||
continue
|
||||
g = glyphen[z]
|
||||
for y, zeile in enumerate(g):
|
||||
for dx, c in enumerate(zeile):
|
||||
if c == '#':
|
||||
punkte.append((x + dx, y, i))
|
||||
x += len(g[0]) + abstand
|
||||
return punkte, x - abstand, len(next(iter(glyphen.values())))
|
||||
|
||||
|
||||
def verlauf(h, w, oben, unten):
|
||||
t = np.linspace(0, 1, h)[:, None, None]
|
||||
return np.broadcast_to(np.array(oben, float) * (1 - t) + np.array(unten, float) * t, (h, w, 3)).copy()
|
||||
|
||||
|
||||
def rgba(arr, maske):
|
||||
a = np.dstack([np.clip(arr, 0, 255), maske]).astype(np.uint8)
|
||||
return Image.fromarray(a, 'RGBA')
|
||||
|
||||
|
||||
# ---------- Schriftzug ----------
|
||||
|
||||
def schriftzug(b, flach=False):
|
||||
"""'AERO MC' aus Blöcken der Größe b (Pixel): Vorderseite, Tiefe nach unten, Kontur."""
|
||||
punkte, breite, hoehe = raster('AERO MC', GLYPHEN, 1, 2)
|
||||
tiefe = 0 if flach else max(2, int(b * 0.55))
|
||||
k = 0 if flach else max(2, b // 5)
|
||||
W, H = breite * b + 2 * k, hoehe * b + tiefe + 2 * k
|
||||
front = np.zeros((H, W), bool)
|
||||
blau = np.zeros((H, W), bool)
|
||||
kante = np.zeros((H, W), float)
|
||||
belegt = {(x, y) for x, y, _ in punkte}
|
||||
rnd = np.random.RandomState(7)
|
||||
rauschen = np.zeros((H, W))
|
||||
zell = max(1, b // 4)
|
||||
for bx, by, i in punkte:
|
||||
x0, y0 = k + bx * b, k + by * b
|
||||
front[y0:y0 + b, x0:x0 + b] = True
|
||||
if i >= 5:
|
||||
blau[y0:y0 + b, x0:x0 + b] = True
|
||||
# Stein-Struktur in kleinen Zellen
|
||||
for yy in range(0, b, zell):
|
||||
for xx in range(0, b, zell):
|
||||
rauschen[y0 + yy:y0 + yy + zell, x0 + xx:x0 + xx + zell] = rnd.randint(-9, 6)
|
||||
if (bx, by - 1) not in belegt:
|
||||
kante[y0:y0 + max(1, b // 6), x0:x0 + b] = 34 # helle Oberkante
|
||||
if (bx + 1, by) not in belegt:
|
||||
kante[y0:y0 + b, x0 + b - max(1, b // 7):x0 + b] -= 26 # dunklere rechte Kante
|
||||
hell = verlauf(H, W, (255, 255, 255), (176, 190, 214))
|
||||
mc = verlauf(H, W, (170, 236, 255), (34, 108, 232))
|
||||
farbe = np.where(blau[..., None], mc, hell) + (rauschen + kante)[..., None]
|
||||
if flach:
|
||||
return rgba(farbe, front * 255)
|
||||
seite = np.zeros((H, W), bool)
|
||||
for t in range(1, tiefe + 1):
|
||||
seite[t:, :] |= front[:-t, :]
|
||||
seite &= ~front
|
||||
blau_seite = np.zeros((H, W), bool)
|
||||
for t in range(1, tiefe + 1):
|
||||
blau_seite[t:, :] |= blau[:-t, :]
|
||||
seite_farbe = np.where(blau_seite[..., None], verlauf(H, W, (30, 84, 190), (14, 44, 120)),
|
||||
verlauf(H, W, (112, 126, 156), (66, 78, 104)))
|
||||
alles = Image.fromarray(((front | seite) * 255).astype(np.uint8))
|
||||
kontur = np.array(alles.filter(ImageFilter.MaxFilter(2 * k + 1))) > 0
|
||||
bild = np.zeros((H, W, 3))
|
||||
bild[kontur] = KONTUR
|
||||
bild[seite] = seite_farbe[seite]
|
||||
bild[front] = farbe[front]
|
||||
return rgba(bild, kontur * 255)
|
||||
|
||||
|
||||
def pyramide(w, h):
|
||||
"""Blaue Pyramide (linke Seite hell, rechte dunkel, leuchtender Grat)."""
|
||||
w, h = int(w), int(h)
|
||||
links = Image.new('L', (w, h), 0)
|
||||
rechts = Image.new('L', (w, h), 0)
|
||||
spitze, mitte = (w * 0.5, 0), (w * 0.56, h)
|
||||
ImageDraw.Draw(links).polygon([spitze, (0, h), mitte], fill=255)
|
||||
ImageDraw.Draw(rechts).polygon([spitze, mitte, (w, h)], fill=255)
|
||||
hell = verlauf(h, w, (150, 232, 255), (40, 126, 236))
|
||||
dunkel = verlauf(h, w, (52, 130, 236), (14, 48, 140))
|
||||
l, r = np.array(links) > 0, np.array(rechts) > 0
|
||||
bild = np.zeros((h, w, 3))
|
||||
bild[l] = hell[l]
|
||||
bild[r] = dunkel[r]
|
||||
img = rgba(bild, np.maximum(np.array(links), np.array(rechts)))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.line([spitze, mitte], fill=(215, 248, 255, 255), width=max(2, w // 70))
|
||||
d.line([spitze, (0, h)], fill=(120, 215, 255, 255), width=max(1, w // 140))
|
||||
return img
|
||||
|
||||
|
||||
def leuchten(bild, radius, farbe=(70, 190, 255), staerke=0.75):
|
||||
a = bild.split()[3].filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(radius))
|
||||
a = a.point(lambda v: int(v * staerke))
|
||||
g = Image.new('RGBA', bild.size, farbe + (0,))
|
||||
g.putalpha(a)
|
||||
return g
|
||||
|
||||
|
||||
def logo(b, mit_leuchten=True):
|
||||
"""Pyramide hinter dem Schriftzug, wie im Entwurf. Rückgabe: Bild, Unterkante des Schriftzugs."""
|
||||
text = schriftzug(b)
|
||||
pw, ph = text.width * 0.46, text.height * 1.55
|
||||
pyr = pyramide(pw, ph)
|
||||
oben = int(ph - text.height * 0.62) # so weit ragt die Pyramide über den Text
|
||||
rand = int(b * 1.2) if mit_leuchten else 0
|
||||
W, H = text.width + 2 * rand, oben + text.height + 2 * rand
|
||||
bild = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
px = rand + (text.width - pyr.width) // 2 + int(text.width * 0.03)
|
||||
schicht = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
schicht.alpha_composite(pyr, (px, rand))
|
||||
schicht.alpha_composite(text, (rand, rand + oben))
|
||||
if mit_leuchten:
|
||||
bild.alpha_composite(leuchten(schicht, b * 0.9))
|
||||
bild.alpha_composite(schicht)
|
||||
return bild, rand + oben + text.height
|
||||
|
||||
|
||||
def einpassen(bild, w, h):
|
||||
f = min(w / bild.width, h / bild.height, 1.0)
|
||||
return bild.resize((max(1, int(bild.width * f)), max(1, int(bild.height * f))), Image.LANCZOS) if f < 1 else bild
|
||||
|
||||
|
||||
# ---------- Titel-Texturen ----------
|
||||
|
||||
def titel_art():
|
||||
"""Pyramide, Schriftzug und Untertitel zusammen (Titel im Hauptmenü)."""
|
||||
art, unterkante = logo(15)
|
||||
unter, _ = untertitel_bild(5)
|
||||
bild = Image.new('RGBA', (max(art.width, unter.width), unterkante + 8 + unter.height), (0, 0, 0, 0))
|
||||
bild.alpha_composite(art, ((bild.width - art.width) // 2, 0))
|
||||
bild.alpha_composite(unter, ((bild.width - unter.width) // 2, unterkante + 8))
|
||||
return bild
|
||||
|
||||
|
||||
def titel(alt):
|
||||
"""
|
||||
Der Agent erweitert den gezeichneten Streifen auf 64 Punkte Höhe (sonst 44).
|
||||
neu (ab 1.20): 256×64-Textur, ganz genutzt.
|
||||
alt (bis 1.19.4): Stücke (0,0,155,64) und (0,65,155,64) im 256×256-Atlas,
|
||||
sichtbare Mitte bei x=137 des 310 breiten Streifens.
|
||||
"""
|
||||
art = einpassen(titel_art(), (274 if alt else 256) * 4 - 8, 64 * 4)
|
||||
if not alt:
|
||||
t = Image.new('RGBA', (1024, 256), (0, 0, 0, 0))
|
||||
t.alpha_composite(art, ((1024 - art.width) // 2, 256 - art.height))
|
||||
return t
|
||||
streifen = Image.new('RGBA', (310 * 4, 64 * 4), (0, 0, 0, 0))
|
||||
streifen.alpha_composite(art, (137 * 4 - art.width // 2, 256 - art.height))
|
||||
t = Image.new('RGBA', (1024, 1024), (0, 0, 0, 0))
|
||||
t.alpha_composite(streifen.crop((0, 0, 620, 256)), (0, 0))
|
||||
t.alpha_composite(streifen.crop((620, 0, 1240, 256)), (0, 65 * 4))
|
||||
return t
|
||||
|
||||
|
||||
def untertitel_bild(skala, farbe=(236, 244, 255), schatten=(8, 16, 40)):
|
||||
punkte, breite, hoehe = raster('DEIN MINECRAFT ERLEBNIS', KLEIN, 1, 3)
|
||||
img = Image.new('RGBA', ((breite + 1) * skala, (hoehe + 1) * skala), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
for dx, dy, f in ((1, 1, schatten), (0, 0, farbe)):
|
||||
for x, y, _ in punkte:
|
||||
d.rectangle(((x + dx) * skala, (y + dy) * skala, (x + dx + 1) * skala - 1, (y + dy + 1) * skala - 1), fill=f + (255,))
|
||||
return img, breite
|
||||
|
||||
|
||||
def edition(breite_max):
|
||||
img, breite = untertitel_bild(4)
|
||||
if breite + 1 > breite_max:
|
||||
raise SystemExit(f'Untertitel zu breit: {breite} > {breite_max}')
|
||||
t = Image.new('RGBA', (512, 64), (0, 0, 0, 0))
|
||||
t.alpha_composite(img, ((breite_max * 4 - img.width) // 2, (56 - img.height) // 2))
|
||||
return t
|
||||
|
||||
|
||||
# ---------- stilisierte Block-Landschaft (Abendstimmung am Bergsee) ----------
|
||||
# Eine Szene, zwei Ansichten: als Rundum-Panorama (Menü) und als flacher
|
||||
# Ausschnitt nach vorne (Ladebildschirm). Gleicher Zufallswert -> gleiche Berge.
|
||||
|
||||
def _mix(a, b, t):
|
||||
return np.array(a, np.float32) * (1 - t) + np.array(b, np.float32) * t
|
||||
|
||||
|
||||
def _profil(phi, seed, schritt_rad):
|
||||
"""periodisches Höhenprofil 0..1, blockig gestuft"""
|
||||
r = random.Random(seed)
|
||||
stufe = np.floor(phi / schritt_rad) * schritt_rad + schritt_rad / 2
|
||||
werte = np.zeros_like(phi)
|
||||
for k in range(1, 16):
|
||||
werte += r.uniform(0.25, 1.0) / k ** 1.05 * np.sin(k * stufe + r.uniform(0, 6.3))
|
||||
return (werte + 2.2) / 4.4
|
||||
|
||||
|
||||
def szene(W, H, horizont, start, spanne, seed=2026):
|
||||
rnd = random.Random(seed)
|
||||
ppd = W / math.degrees(spanne) # Pixel je Grad
|
||||
blk = max(4, int(round(ppd * 0.9))) # Blockgröße der Silhouetten
|
||||
schritt = math.radians(blk / ppd) # ein Block als Winkel
|
||||
phi = (start + np.arange(W, dtype=np.float32) / W * spanne).astype(np.float32)
|
||||
y = np.arange(H, dtype=np.float32)[:, None]
|
||||
grad = (horizont - y) / ppd # Höhe über dem Horizont in Grad
|
||||
|
||||
sonne = math.pi + 0.66
|
||||
dphi = np.angle(np.exp(1j * (phi - sonne))).astype(np.float32)[None, :]
|
||||
|
||||
# Himmel: Blau oben, hell am Horizont, warmer Abendschein um die Sonne
|
||||
t = np.clip(grad / 75, 0, 1)[..., None]
|
||||
himmel = np.where(t < 0.3, _mix((168, 202, 238), (66, 124, 210), t / 0.3), _mix((66, 124, 210), (16, 36, 96), (t - 0.3) / 0.7))
|
||||
warm = (np.exp(-dphi ** 2 / 0.55) * np.exp(-(np.clip(grad, 0, None) / 14) ** 2))[..., None]
|
||||
himmel = himmel * (1 - 0.8 * warm) + np.array([255, 178, 120], np.float32) * 0.8 * warm
|
||||
bild = np.broadcast_to(himmel, (H, W, 3)).astype(np.float32).copy()
|
||||
|
||||
def maske_hoehe(h_grad):
|
||||
return (grad >= 0) & (grad <= h_grad[None, :])
|
||||
|
||||
def stufen(h_grad):
|
||||
return np.floor(h_grad * ppd / blk) * blk / ppd
|
||||
|
||||
# Sonne (eckig, wie im Spiel) mit Schein
|
||||
s_x = (sonne - start) / spanne * W
|
||||
s_y = horizont - 12 * ppd
|
||||
s_r = 2.6 * ppd
|
||||
rr = np.sqrt(((np.arange(W) - s_x) / ppd)[None, :] ** 2 + ((y - s_y) / ppd) ** 2)
|
||||
bild += (np.exp(-(rr / 6) ** 2) * 0.55)[..., None] * np.array([255, 210, 150], np.float32)
|
||||
quadrat = (np.abs(np.arange(W) - s_x)[None, :] < s_r) & (np.abs(y - s_y) < s_r)
|
||||
bild[quadrat] = (255, 244, 205)
|
||||
|
||||
# Wolken (flach und blockig)
|
||||
wolken = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(wolken)
|
||||
for _ in range(18):
|
||||
p = rnd.uniform(0, 2 * math.pi)
|
||||
for versatz in (-2 * math.pi, 0, 2 * math.pi):
|
||||
cx = (p + versatz - start) / spanne * W
|
||||
if -W * 0.2 < cx < W * 1.2:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
cy = horizont - rnd.uniform(20, 42) * ppd
|
||||
s = blk
|
||||
nah_sonne = abs(math.atan2(math.sin(p - sonne), math.cos(p - sonne))) < 0.7
|
||||
farbe = (255, 222, 196) if nah_sonne else (232, 240, 252)
|
||||
for _ in range(rnd.randint(2, 4)):
|
||||
w, h = rnd.randint(6, 16) * s, rnd.randint(1, 2) * s
|
||||
ox, oy = rnd.randint(-4, 4) * s, rnd.randint(-1, 0) * s
|
||||
d.rectangle((cx + ox, cy + oy, cx + ox + w, cy + oy + h), fill=farbe + (170,))
|
||||
d.rectangle((cx + ox, cy + oy + h - max(2, s // 3), cx + ox + w, cy + oy + h), fill=tuple(int(c * 0.82) for c in farbe) + (170,))
|
||||
|
||||
# ferne Berge mit Schnee
|
||||
h_fern = stufen(6 + 22 * _profil(phi, seed + 1, schritt))
|
||||
m = maske_hoehe(h_fern)
|
||||
tb = np.clip(grad / 28, 0, 1)[..., None]
|
||||
fels = _mix((46, 66, 112), (96, 118, 168), tb)
|
||||
schnee = (grad > h_fern[None, :] - 3 * blk / ppd) & (h_fern[None, :] > 15)
|
||||
fels = np.where(schnee[..., None], np.array([236, 242, 255], np.float32), fels)
|
||||
sonnenseite = (np.exp(-dphi ** 2 / 0.6))[..., None] * 0.35
|
||||
fels = fels * (1 - sonnenseite) + np.array([255, 190, 150], np.float32) * sonnenseite
|
||||
bild[m] = np.broadcast_to(fels, bild.shape)[m]
|
||||
# bewaldete Hügel
|
||||
h_huegel = stufen(2 + 7 * _profil(phi, seed + 2, schritt))
|
||||
m = maske_hoehe(h_huegel)
|
||||
huegel = _mix((26, 70, 58), (44, 98, 74), np.clip(grad / 7, 0, 1)[..., None])
|
||||
bild[m] = np.broadcast_to(huegel, bild.shape)[m]
|
||||
|
||||
img = Image.fromarray(np.clip(bild, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
|
||||
img.alpha_composite(wolken)
|
||||
# Fichten am Ufer
|
||||
wald = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(wald)
|
||||
s = max(3, blk // 2)
|
||||
for _ in range(int(W / s * 0.55)):
|
||||
x = rnd.randrange(W)
|
||||
hoehe = rnd.randint(6, 13)
|
||||
dunkel = rnd.choice(((14, 44, 34), (18, 52, 40), (12, 38, 32)))
|
||||
d.rectangle((x - s // 2, horizont - 2 * s, x + s // 2, horizont), fill=(40, 28, 20, 255))
|
||||
for stufe in range(hoehe):
|
||||
breite = max(0, (hoehe - stufe) // 2 - (stufe % 2))
|
||||
y0 = horizont - (stufe + 2) * s
|
||||
d.rectangle((x - breite * s - s // 2, y0, x + breite * s + s // 2, y0 + s), fill=dunkel + (255,))
|
||||
d.rectangle((x + breite * s - s // 2, y0, x + breite * s + s // 2, y0 + s), fill=tuple(c + 14 for c in dunkel) + (255,))
|
||||
img.alpha_composite(wald)
|
||||
bild = np.array(img.convert('RGB'), np.float32)
|
||||
|
||||
# See: Spiegelung, nach unten dunkler, Wellen und Sonnenglitzern
|
||||
unten = H - horizont
|
||||
zeilen = np.clip(horizont - 1 - np.arange(unten), 0, H - 1)
|
||||
spiegel = bild[zeilen]
|
||||
tiefe = np.linspace(0, 1, unten, dtype=np.float32)[:, None, None]
|
||||
wellen = ((np.sin(np.arange(unten, dtype=np.float32)[:, None] * (0.55 / max(1, ppd / 10)) + np.sin(phi[None, :] * 37) * 1.6) > 0.86) * 14)[..., None]
|
||||
see = spiegel * (0.62 - 0.32 * tiefe) + np.array([10, 30, 62], np.float32) * (0.45 + 0.55 * tiefe) + wellen * np.array([0.5, 0.8, 1.0], np.float32)
|
||||
glitzer = (np.exp(-(dphi[0] / 0.07) ** 2)[None, :] * (np.sin(np.arange(unten)[:, None] * 1.7 + phi[None, :] * 900) > 0.6) * np.exp(-tiefe[..., 0] * 3))[..., None]
|
||||
see += glitzer * np.array([255, 200, 140], np.float32) * 0.8
|
||||
bild[horizont:] = see
|
||||
|
||||
# leuchtende Pixel (AeroMC)
|
||||
img = Image.fromarray(np.clip(bild, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
|
||||
pixel = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(pixel)
|
||||
for _ in range(int(W / 60)):
|
||||
x = rnd.randrange(W)
|
||||
yy = horizont - rnd.uniform(-8, 30) * ppd
|
||||
g = rnd.choice((0.6, 0.8, 1.0, 1.3)) * ppd
|
||||
d.rectangle((x, yy, x + g, yy + g), fill=(90, 210, 255, rnd.randint(150, 235)))
|
||||
img.alpha_composite(leuchten(pixel, max(4, ppd * 0.8), (60, 170, 255), 1.0))
|
||||
img.alpha_composite(pixel)
|
||||
return img.convert('RGB')
|
||||
|
||||
|
||||
def landschaft(W=4096, H=2048):
|
||||
"""Rundum-Panorama (equirektangular): Spalte W/2 = Blick nach vorne."""
|
||||
return szene(W, H, H // 2, 0.0, 2 * math.pi)
|
||||
|
||||
|
||||
def landschaft_flach(W=1920, H=1080):
|
||||
"""Ausschnitt nach vorne (ca. 110°) für den Ladebildschirm."""
|
||||
spanne = math.radians(110)
|
||||
return szene(W, H, int(H * 0.6), math.pi - spanne / 2, spanne)
|
||||
|
||||
|
||||
def wuerfelseiten(equi, groesse=1024):
|
||||
"""Equirektangulares Panorama -> 6 Würfelseiten (0 vorne, 1 rechts, 2 hinten, 3 links, 4 oben, 5 unten)."""
|
||||
q = np.array(equi.convert('RGB'), float)
|
||||
H, W = q.shape[:2]
|
||||
a = (np.arange(groesse) + 0.5) / groesse * 2 - 1
|
||||
u, v = np.meshgrid(a, a)
|
||||
eins = np.ones_like(u)
|
||||
richtungen = [(u, -v, eins), (eins, -v, -u), (-u, -v, -eins), (-eins, -v, u), (u, eins, v), (u, -eins, -v)]
|
||||
seiten = []
|
||||
for dx, dy, dz in richtungen:
|
||||
lon = np.arctan2(dx, dz)
|
||||
lat = np.arctan2(dy, np.sqrt(dx * dx + dz * dz))
|
||||
x = (lon / (2 * math.pi) + 0.5) * W - 0.5
|
||||
y = (0.5 - lat / math.pi) * H - 0.5
|
||||
x0 = np.floor(x).astype(int); y0 = np.clip(np.floor(y).astype(int), 0, H - 2)
|
||||
fx, fy = (x - x0)[..., None], np.clip(y - y0, 0, 1)[..., None]
|
||||
x1 = (x0 + 1) % W; x0 %= W
|
||||
p = (q[y0, x0] * (1 - fx) * (1 - fy) + q[y0, x1] * fx * (1 - fy) + q[y0 + 1, x0] * (1 - fx) * fy + q[y0 + 1, x1] * fx * fy)
|
||||
seiten.append(Image.fromarray(np.clip(p, 0, 255).astype(np.uint8), 'RGB'))
|
||||
return seiten
|
||||
|
||||
|
||||
def seiten_aus_bild(bild, g=1024):
|
||||
"""
|
||||
Aus einem flachen Bild (16:9) einen nahtlosen Würfel:
|
||||
– vorne = linke Bildhälfte, rechts = rechte Hälfte, hinten/links = dieselben
|
||||
gespiegelt, so stoßen alle senkrechten Kanten passend aneinander;
|
||||
– oben/unten: die vier Seiten werden in ein Rundum-Panorama umgerechnet, dort
|
||||
nach unten (Wasser) bzw. oben (Himmel) gespiegelt fortgesetzt und weich
|
||||
auslaufen gelassen, daraus dann die obere und untere Würfelseite gerechnet.
|
||||
So gehen die Kanten nahtlos über – keine einfarbigen Flächen.
|
||||
"""
|
||||
b = bild.convert('RGB').resize((2 * g, g), Image.LANCZOS)
|
||||
vorne, rechts = b.crop((0, 0, g, g)), b.crop((g, 0, 2 * g, g))
|
||||
hinten, links = rechts.transpose(Image.FLIP_LEFT_RIGHT), vorne.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
seiten = [np.array(s, np.float32) for s in (vorne, rechts, hinten, links)]
|
||||
|
||||
# 1) Seiten -> equirektangulares Panorama (oben/unten zunächst leer)
|
||||
W, H = 4 * g, 2 * g
|
||||
lon = ((np.arange(W) + 0.5) / W - 0.5) * 2 * math.pi
|
||||
lat = (0.5 - (np.arange(H) + 0.5) / H) * math.pi
|
||||
LON, LAT = np.meshgrid(lon, lat)
|
||||
dx, dy, dz = np.cos(LAT) * np.sin(LON), np.sin(LAT), np.cos(LAT) * np.cos(LON)
|
||||
equi = np.zeros((H, W, 3), np.float32)
|
||||
gueltig = np.zeros((H, W), bool)
|
||||
seitlich = np.abs(dy) < np.maximum(np.abs(dx), np.abs(dz))
|
||||
# (Maske, s, u, v) passend zu wuerfelseiten(): vorne (u,-v,1), rechts (1,-v,-u), hinten (-u,-v,-1), links (-1,-v,u)
|
||||
faelle = [
|
||||
(seitlich & (dz >= np.abs(dx)), dz, dx, -dy),
|
||||
(seitlich & (dx > np.abs(dz)), dx, -dz, -dy),
|
||||
(seitlich & (-dz >= np.abs(dx)), -dz, -dx, -dy),
|
||||
(seitlich & (-dx > np.abs(dz)), -dx, dz, -dy),
|
||||
]
|
||||
for (maske, s, u, v), seite in zip(faelle, seiten):
|
||||
su, sv = u[maske] / s[maske], v[maske] / s[maske]
|
||||
px = np.clip(((su + 1) / 2 * g).astype(int), 0, g - 1)
|
||||
py = np.clip(((sv + 1) / 2 * g).astype(int), 0, g - 1)
|
||||
equi[maske] = seite[py, px]
|
||||
gueltig |= maske
|
||||
|
||||
# 2) Lücken spiegeln: unten das Wasser (dunkler werdend), oben der Himmel
|
||||
zeilen = np.arange(H)[:, None]
|
||||
unterste = np.where(gueltig.any(0), H - 1 - np.argmax(gueltig[::-1], axis=0), H // 2)
|
||||
oberste = np.where(gueltig.any(0), np.argmax(gueltig, axis=0), H // 2)
|
||||
spalten = np.arange(W)[None, :]
|
||||
unten = zeilen > unterste[None, :]
|
||||
oben = zeilen < oberste[None, :]
|
||||
quelle_unten = np.clip(2 * unterste[None, :] - zeilen, oberste[None, :], H - 1)
|
||||
quelle_oben = np.broadcast_to(np.clip(oberste[None, :] + 2, 0, H - 1), (H, W)) # Himmel: oberste Zeile nach oben verlängern
|
||||
gespiegelt = equi.copy()
|
||||
gespiegelt[unten] = equi[quelle_unten, np.broadcast_to(spalten, (H, W))][unten]
|
||||
gespiegelt[oben] = equi[quelle_oben, np.broadcast_to(spalten, (H, W))][oben]
|
||||
# zum Pol hin weich in eine ruhige Farbe auslaufen lassen (keine Kreuznaht im Würfel)
|
||||
abstand_unten = np.clip((zeilen - unterste[None, :]) / (H * 0.34), 0, 1)[..., None] ** 1.3
|
||||
abstand_oben = np.clip((oberste[None, :] - zeilen) / (H * 0.34), 0, 1)[..., None] ** 1.3
|
||||
# Wasserfarbe aus der Bildmitte (am unteren Rand stehen Pflanzen), Himmel vom oberen Rand
|
||||
roh = np.array(b, np.float32)
|
||||
wasser = np.median(roh[int(g * 0.62):int(g * 0.82), int(g * 0.5):int(g * 1.5)].reshape(-1, 3), axis=0)
|
||||
himmel = equi[np.clip(oberste + 4, 0, H - 1), np.arange(W)].mean(0)
|
||||
gespiegelt = np.where(unten[..., None], gespiegelt * (1 - abstand_unten) * 0.9 + wasser * 0.8 * abstand_unten, gespiegelt)
|
||||
gespiegelt = np.where(oben[..., None], gespiegelt * (1 - abstand_oben) + himmel * abstand_oben, gespiegelt)
|
||||
bild_equi = Image.fromarray(np.clip(gespiegelt, 0, 255).astype(np.uint8), 'RGB')
|
||||
weich = bild_equi.filter(ImageFilter.GaussianBlur(16))
|
||||
maske_luecke = Image.fromarray(((unten | oben) * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(6))
|
||||
bild_equi = Image.composite(weich, bild_equi, maske_luecke)
|
||||
|
||||
# 3) daraus oben/unten; die vier Seiten bleiben die scharfen Originale
|
||||
neu = wuerfelseiten(bild_equi, g)
|
||||
return [vorne, rechts, hinten, links, neu[4], neu[5]]
|
||||
|
||||
|
||||
def quelle(name):
|
||||
for endung in ('png', 'jpg', 'jpeg', 'webp'):
|
||||
p = os.path.join(QUELLE, f'{name}.{endung}')
|
||||
if os.path.exists(p):
|
||||
return Image.open(p).convert('RGB')
|
||||
return None
|
||||
|
||||
|
||||
def fuellen(bild, w, h):
|
||||
f = max(w / bild.width, h / bild.height)
|
||||
b = bild.resize((int(bild.width * f + 0.5), int(bild.height * f + 0.5)), Image.LANCZOS)
|
||||
x, y = (b.width - w) // 2, (b.height - h) // 2
|
||||
return b.crop((x, y, x + w, y + h))
|
||||
|
||||
|
||||
# ---------- Ladebildschirm (Vollbild) ----------
|
||||
|
||||
def ladebild(mit_text=True):
|
||||
W, H = 1920, 1080
|
||||
eigenes = quelle('hintergrund')
|
||||
if eigenes is not None:
|
||||
grund = fuellen(eigenes, W, H)
|
||||
else: # gleiche Landschaft wie im Menü, als Blick nach vorne
|
||||
grund = landschaft_flach(W, H)
|
||||
bild = grund.convert('RGBA')
|
||||
# leichte Abdunklung zur Mitte und unten, damit Logo und Balken lesbar sind
|
||||
schatten = Image.new('L', (W, H), 0)
|
||||
ImageDraw.Draw(schatten).ellipse((W * 0.18, H * 0.12, W * 0.82, H * 0.72), fill=95)
|
||||
schatten = schatten.filter(ImageFilter.GaussianBlur(120))
|
||||
unten = Image.fromarray((np.clip((np.arange(H) - H * 0.62) / (H * 0.38), 0, 1) * 120).astype(np.uint8)[:, None].repeat(W, 1))
|
||||
dunkel = Image.new('RGBA', (W, H), (4, 8, 20, 0))
|
||||
dunkel.putalpha(ImageChops.lighter(schatten, unten))
|
||||
bild.alpha_composite(dunkel)
|
||||
art, _ = logo(30)
|
||||
art = einpassen(art, int(W * 0.62), int(H * 0.56))
|
||||
lx, ly = (W - art.width) // 2, int(H * 0.40 - art.height / 2)
|
||||
bild.alpha_composite(art, (lx, ly))
|
||||
unter, _ = untertitel_bild(6)
|
||||
bild.alpha_composite(unter, ((W - unter.width) // 2, ly + art.height + 6))
|
||||
if not mit_text: # Neustart-Bildschirm des Launchers setzt Text und Balken selbst darüber
|
||||
return bild.convert('RGB')
|
||||
# "Wird geladen …" unter dem Balken (Minecraft zeichnet den Balken bei 83,25 % der Höhe)
|
||||
schrift = ImageFont.truetype(SCHRIFT, 34)
|
||||
text = 'Wird geladen …'
|
||||
tb = ImageDraw.Draw(bild).textbbox((0, 0), text, font=schrift)
|
||||
tw, th = tb[2] - tb[0], tb[3] - tb[1]
|
||||
kasten = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||
kx, ky = (W - tw) // 2, int(H * 0.885)
|
||||
ImageDraw.Draw(kasten).rounded_rectangle((kx - 26, ky - 14, kx + tw + 26, ky + th + 22), radius=10, fill=(6, 12, 28, 175))
|
||||
bild.alpha_composite(kasten)
|
||||
ImageDraw.Draw(bild).text((kx - tb[0], ky - tb[1] + 2), text, font=schrift, fill=(236, 244, 255, 255))
|
||||
return bild.convert('RGB')
|
||||
|
||||
|
||||
def als_logotextur(bild):
|
||||
"""
|
||||
Minecraft zeichnet das Logo additiv auf den Hintergrund und teilt es: linke
|
||||
Hälfte aus der oberen Texturhälfte, rechte aus der unteren. Darum: Nachtblau
|
||||
abziehen (wird beim Zeichnen wieder addiert) und die Hälften gestreckt stapeln.
|
||||
"""
|
||||
a = np.clip(np.array(bild, float) - np.array(NACHTBLAU, float), 0, 255).astype(np.uint8)
|
||||
b = Image.fromarray(a, 'RGB')
|
||||
w, h = b.size
|
||||
t = Image.new('RGB', (2048, 2048))
|
||||
t.paste(b.crop((0, 0, w // 2, h)).resize((2048, 1024), Image.LANCZOS), (0, 0))
|
||||
t.paste(b.crop((w // 2, 0, w, h)).resize((2048, 1024), Image.LANCZOS), (0, 1024))
|
||||
return t.convert('RGBA')
|
||||
|
||||
|
||||
# ---------- Knöpfe und Symbole (wie im Entwurf) ----------
|
||||
|
||||
SYMBOL_ZEICHEN = ['', '', '', '', '', ''] # Spitzhacke, Personen, Globus, Zahnrad, Ein/Aus, Puzzle (Mods)
|
||||
|
||||
|
||||
def knopf(hell, f):
|
||||
"""Knopf 200×20 Punkte in f-facher Auflösung: dunkles Schiefer bzw. leuchtendes Blau."""
|
||||
w, h = 200 * f, 20 * f
|
||||
if hell:
|
||||
oben, unten, rand, licht = (64, 166, 248), (20, 92, 200), (130, 232, 255), (170, 225, 255)
|
||||
else:
|
||||
oben, unten, rand, licht = (46, 56, 74), (24, 30, 42), (124, 142, 172), (78, 92, 118)
|
||||
a = verlauf(h, w, oben, unten)
|
||||
bild = rgba(a, np.full((h, w), 255, np.uint8))
|
||||
d = ImageDraw.Draw(bild)
|
||||
s = max(1, f // 2)
|
||||
d.rectangle((0, 0, w - 1, h - 1), outline=(6, 10, 20, 255), width=s) # dunkle Außenkante
|
||||
d.rectangle((s, s, w - 1 - s, h - 1 - s), outline=rand + (255,), width=s) # heller Rand
|
||||
d.line((2 * s, 2 * s, w - 1 - 2 * s, 2 * s), fill=licht + (255,), width=s) # Lichtkante oben
|
||||
for x0, y0 in ((0, 0), (w - s, 0), (0, h - s), (w - s, h - s)): # Ecken leicht abgerundet
|
||||
d.rectangle((x0, y0, x0 + s - 1, y0 + s - 1), fill=(0, 0, 0, 0))
|
||||
return bild
|
||||
|
||||
|
||||
def puzzle(farbe=(238, 246, 255, 255), groesse=64):
|
||||
"""Puzzleteil (Symbol für Mods)."""
|
||||
b = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(b)
|
||||
d.rounded_rectangle((8, 18, 46, 56), radius=5, fill=farbe)
|
||||
d.ellipse((19, 4, 35, 22), fill=farbe) # Nase oben
|
||||
d.ellipse((42, 29, 60, 45), fill=farbe) # Nase rechts
|
||||
d.ellipse((4, 29, 18, 43), fill=(0, 0, 0, 0)) # Kerbe links
|
||||
return b if groesse == 64 else b.resize((groesse, groesse), Image.LANCZOS)
|
||||
|
||||
|
||||
def symbole():
|
||||
"""6 Symbole, je 16×16 (aus 64×64 verkleinert), weiß – als Schriftzeichen."""
|
||||
groesse, f = 64, 4
|
||||
leinwand = Image.new('RGBA', (16 * 6, 16), (0, 0, 0, 0))
|
||||
farbe = (238, 246, 255, 255)
|
||||
|
||||
def neu():
|
||||
return Image.new('RGBA', (groesse, groesse), (0, 0, 0, 0))
|
||||
|
||||
bilder = []
|
||||
b = neu(); d = ImageDraw.Draw(b) # Spitzhacke: aufrecht zeichnen, dann kippen
|
||||
d.line((32, 16, 32, 62), fill=farbe, width=10)
|
||||
d.arc((2, 12, 62, 56), 198, 342, fill=farbe, width=12)
|
||||
d.polygon([(4, 30), (0, 40), (10, 34)], fill=farbe); d.polygon([(60, 30), (64, 40), (54, 34)], fill=farbe)
|
||||
bilder.append(b.rotate(-40, resample=Image.BICUBIC))
|
||||
b = neu(); d = ImageDraw.Draw(b) # zwei Personen
|
||||
d.ellipse((30, 8, 50, 28), fill=farbe); d.pieslice((22, 30, 58, 70), 180, 360, fill=farbe)
|
||||
d.ellipse((10, 14, 28, 32), fill=farbe); d.pieslice((2, 34, 36, 68), 180, 360, fill=farbe)
|
||||
bilder.append(b)
|
||||
b = neu(); d = ImageDraw.Draw(b) # Globus
|
||||
d.ellipse((4, 4, 60, 60), outline=farbe, width=8)
|
||||
d.ellipse((20, 4, 44, 60), outline=farbe, width=7)
|
||||
d.line((4, 32, 60, 32), fill=farbe, width=7)
|
||||
d.arc((10, -10, 54, 26), 30, 150, fill=farbe, width=3); d.arc((10, 38, 54, 74), 210, 330, fill=farbe, width=3)
|
||||
bilder.append(b)
|
||||
b = neu(); d = ImageDraw.Draw(b) # Zahnrad
|
||||
for k in range(8):
|
||||
w = k * math.pi / 4
|
||||
cx, cy = 32 + math.cos(w) * 23, 32 + math.sin(w) * 23
|
||||
pts = [(cx + math.cos(w + p) * 7, cy + math.sin(w + p) * 7) for p in (0.8, 2.35, 3.9, 5.5)]
|
||||
d.polygon(pts, fill=farbe)
|
||||
d.ellipse((12, 12, 52, 52), fill=farbe)
|
||||
d.ellipse((26, 26, 38, 38), fill=(0, 0, 0, 0))
|
||||
bilder.append(b)
|
||||
b = neu(); d = ImageDraw.Draw(b) # Ein/Aus
|
||||
d.arc((6, 8, 58, 60), -60, 240, fill=farbe, width=10)
|
||||
d.line((32, 2, 32, 30), fill=farbe, width=10)
|
||||
bilder.append(b)
|
||||
bilder.append(puzzle(farbe))
|
||||
for i, b in enumerate(bilder):
|
||||
leinwand.alpha_composite(b.resize((16, 16), Image.LANCZOS), (16 * i, 0))
|
||||
return leinwand
|
||||
|
||||
|
||||
def sprachdateien():
|
||||
"""Symbole vor die Beschriftungen der Menüknöpfe setzen (de_de und en_us)."""
|
||||
import json
|
||||
sp, mp, rl, op, qu, md = SYMBOL_ZEICHEN
|
||||
texte = {
|
||||
'de_de': {'menu.singleplayer': 'Einzelspieler', 'menu.multiplayer': 'Mehrspieler', 'menu.online': 'Minecraft Realms',
|
||||
'menu.options': 'Optionen …', 'menu.quit': 'Spiel beenden'},
|
||||
'en_us': {'menu.singleplayer': 'Singleplayer', 'menu.multiplayer': 'Multiplayer', 'menu.online': 'Minecraft Realms',
|
||||
'menu.options': 'Options...', 'menu.quit': 'Quit Game'},
|
||||
}
|
||||
zeichen = {'menu.singleplayer': sp, 'menu.multiplayer': mp, 'menu.online': rl, 'menu.options': op, 'menu.quit': qu}
|
||||
aus = {}
|
||||
for sprache, t in texte.items():
|
||||
aus[sprache] = json.dumps({k: zeichen[k] + ' ' + v for k, v in t.items()}, ensure_ascii=True, indent=2)
|
||||
mods = {
|
||||
'de_de': {'menu.sendFeedback': md + ' Mods verwalten …', 'menu.feedback': md + ' Mods & Feedback …'},
|
||||
'en_us': {'menu.sendFeedback': md + ' Manage Mods...', 'menu.feedback': md + ' Mods & Feedback...'},
|
||||
}
|
||||
for sprache, t in mods.items():
|
||||
aus['mods_' + sprache] = json.dumps(t, ensure_ascii=True, indent=2)
|
||||
schrift = json.dumps({'providers': [{'type': 'bitmap', 'file': 'aeromc:font/symbole.png', 'ascent': 7, 'height': 8,
|
||||
'chars': [''.join(SYMBOL_ZEICHEN)]}]}, ensure_ascii=True, indent=2)
|
||||
return aus, schrift
|
||||
|
||||
|
||||
def knopf_dateien():
|
||||
import json
|
||||
knopf(False, 2).save(os.path.join(ZIEL, 'knopf.png'))
|
||||
knopf(True, 2).save(os.path.join(ZIEL, 'knopf_hell.png'))
|
||||
knopf(False, 4).save(os.path.join(ZIEL, 'knopf_4x.png'))
|
||||
knopf(True, 4).save(os.path.join(ZIEL, 'knopf_hell_4x.png'))
|
||||
meta = json.dumps({'gui': {'scaling': {'type': 'nine_slice', 'width': 200, 'height': 20, 'border': 3}}}, indent=2)
|
||||
with open(os.path.join(ZIEL, 'knopf.png.mcmeta'), 'w', encoding='utf-8') as f:
|
||||
f.write(meta)
|
||||
symbole().save(os.path.join(ZIEL, 'symbole.png'))
|
||||
puzzle(groesse=40).save(os.path.join(ZIEL, 'mods_knopf.png')) # Symbol-Knopf im Pausemenü (26.x)
|
||||
sprachen, schrift = sprachdateien()
|
||||
for sprache, text in sprachen.items():
|
||||
with open(os.path.join(ZIEL, f'lang_{sprache}.json'), 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
with open(os.path.join(ZIEL, 'font_default.json'), 'w', encoding='utf-8') as f:
|
||||
f.write(schrift)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(ZIEL, exist_ok=True)
|
||||
titel(False).save(os.path.join(ZIEL, 'titel_neu.png'))
|
||||
titel(True).save(os.path.join(ZIEL, 'titel_alt.png'))
|
||||
# Der Untertitel steckt jetzt im Titel-Logo – edition.png bleibt leer
|
||||
leer = Image.new('RGBA', (512, 64), (0, 0, 0, 0))
|
||||
leer.save(os.path.join(ZIEL, 'edition_neu.png'))
|
||||
leer.save(os.path.join(ZIEL, 'edition_alt.png'))
|
||||
|
||||
equi, flach = quelle('panorama'), quelle('hintergrund')
|
||||
if equi is not None:
|
||||
seiten = wuerfelseiten(equi) # echtes Rundum-Panorama
|
||||
elif flach is not None:
|
||||
seiten = seiten_aus_bild(flach) # flaches Bild, nahtlos gespiegelt
|
||||
else:
|
||||
seiten = wuerfelseiten(landschaft()) # gezeichnete Block-Landschaft
|
||||
for i, s in enumerate(seiten):
|
||||
s.save(os.path.join(ZIEL, f'panorama_{i}.png'), optimize=True)
|
||||
|
||||
lade = ladebild()
|
||||
lade.save(os.path.join(QUELLE if os.path.isdir(QUELLE) else ZIEL, '_vorschau_ladebildschirm.png'))
|
||||
als_logotextur(lade).save(os.path.join(ZIEL, 'ladelogo.png'), optimize=True)
|
||||
# derselbe Ladebildschirm ohne Text – für den Neustart über den Mod-Schalter
|
||||
ladebild(mit_text=False).save(os.path.join(ZIEL, 'neustart.jpg'), quality=88)
|
||||
|
||||
# 1.14/1.15: festes 256×256-Feld in der Mitte, Logo auf durchsichtigem Grund
|
||||
alt = Image.new('RGBA', (1024, 1024), (0, 0, 0, 0))
|
||||
art, _ = logo(26)
|
||||
art = einpassen(art, 980, 600)
|
||||
alt.alpha_composite(art, ((1024 - art.width) // 2, (1024 - art.height) // 2))
|
||||
alt.save(os.path.join(ZIEL, 'ladelogo_alt.png'))
|
||||
|
||||
knopf_dateien()
|
||||
|
||||
symbol = Image.open(LOGO).convert('RGBA')
|
||||
symbol.resize((128, 128), Image.LANCZOS).save(os.path.join(ZIEL, 'pack.png'))
|
||||
for n in (16, 32, 48, 64, 128, 256):
|
||||
with open(os.path.join(ZIEL, f'icon_{n}.rgba'), 'wb') as f:
|
||||
f.write(symbol.resize((n, n), Image.LANCZOS).tobytes())
|
||||
print('Fertig:', ZIEL, '| Hintergrund:', 'eigenes Bild' if quelle('hintergrund') else 'Panorama-Ausschnitt',
|
||||
'| Panorama:', 'eigenes Panorama' if quelle('panorama') else ('aus dem Hintergrundbild' if quelle('hintergrund') else 'Block-Landschaft'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* pruefe-version.js – läuft vor jedem Build (prebuild)
|
||||
* ----------------------------------------------------
|
||||
* Bricht ab, wenn die Version aus package.json schon als Release in Gitea
|
||||
* veröffentlicht ist. Ein neuer Build derselben Version ergäbe andere Dateien
|
||||
* mit neuer Signatur – die bereits veröffentlichten EXEs würden dann nicht mehr
|
||||
* dazu passen und kein Launcher nähme das Update an.
|
||||
*
|
||||
* Ausnahme (z. B. Testbuild, der nie hochgeladen wird): AEROMC_GLEICHE_VERSION=1
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases?limit=50';
|
||||
const version = require(path.join(__dirname, '..', 'package.json')).version;
|
||||
|
||||
(async () => {
|
||||
if (process.env.AEROMC_GLEICHE_VERSION === '1') return;
|
||||
let releases;
|
||||
try {
|
||||
const res = await fetch(API, { headers: { 'User-Agent': 'AeroMC-Release', Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
releases = await res.json();
|
||||
} catch (err) {
|
||||
console.warn(`\n! Gitea nicht erreichbar (${err.message}) – Versionsprüfung übersprungen.`);
|
||||
return;
|
||||
}
|
||||
const norm = (v) => String(v || '').trim().replace(/^v/i, '');
|
||||
const treffer = (releases || []).find((r) => !r.draft && norm(r.tag_name) === version);
|
||||
if (treffer) {
|
||||
console.error(`\n✖ Version ${version} ist bereits in Gitea veröffentlicht (${treffer.published_at}).`);
|
||||
console.error(' Bitte zuerst die Versionsnummer in package.json erhöhen – sonst passen die');
|
||||
console.error(' veröffentlichten Dateien nicht mehr zur Signatur.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`» Version ${version} ist noch nicht veröffentlicht – Build läuft.`);
|
||||
})();
|
||||
@@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const readline = require('readline');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { signaturText, pruefeSignatur, holeSignatur, SIGNATUR_API } = require('../src/updater');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const ORDNER = path.join(os.homedir(), '.aeromc-release');
|
||||
const OEFFENTLICH = path.join(ROOT, 'src', 'update-schluessel.pem');
|
||||
const PRIVAT = process.env.AEROMC_SIGN_KEY || path.join(ORDNER, 'release-key.pem');
|
||||
const ZUGANG = path.join(ORDNER, 'wordpress.json');
|
||||
const DATEIEN = { setup: 'AeroMC-Setup.exe', portable: 'AeroMC-Portable.exe' };
|
||||
const WP_STANDARD = new URL(SIGNATUR_API).origin;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schlüssel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function schluesselAnlegen() {
|
||||
if (fs.existsSync(PRIVAT)) {
|
||||
console.log(`Es gibt bereits einen Release-Schlüssel: ${PRIVAT}`);
|
||||
console.log('Er wird nicht überschrieben – sonst nähmen bestehende Launcher keine Updates mehr an.');
|
||||
return;
|
||||
}
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
||||
fs.mkdirSync(path.dirname(PRIVAT), { recursive: true });
|
||||
fs.writeFileSync(PRIVAT, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
|
||||
fs.writeFileSync(OEFFENTLICH, publicKey.export({ type: 'spki', format: 'pem' }));
|
||||
console.log(`Privater Schlüssel: ${PRIVAT} (SICHERN, niemals weitergeben!)`);
|
||||
console.log(`Öffentlicher Schlüssel: ${OEFFENTLICH} (wird in den Launcher eingebaut)`);
|
||||
}
|
||||
|
||||
|
||||
function dpapi(skript, eingabe) {
|
||||
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript],
|
||||
{ input: eingabe, encoding: 'utf8', windowsHide: true }).trim();
|
||||
}
|
||||
function verschluesseln(text) {
|
||||
return dpapi('$t = [Console]::In.ReadToEnd(); ConvertTo-SecureString $t -AsPlainText -Force | ConvertFrom-SecureString', text);
|
||||
}
|
||||
function entschluesseln(hex) {
|
||||
return dpapi('$h = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $h; '
|
||||
+ '[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', hex);
|
||||
}
|
||||
|
||||
function frage(text, verdeckt) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
if (verdeckt) {
|
||||
rl._writeToOutput = (s) => { if (s.includes(text)) process.stdout.write(text); else if (s === '\r\n' || s === '\n') process.stdout.write(s); };
|
||||
}
|
||||
rl.question(text, (antwort) => { rl.close(); if (verdeckt) process.stdout.write('\n'); resolve(antwort.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
function ladeZugang() {
|
||||
if (!fs.existsSync(ZUGANG)) return null;
|
||||
const z = JSON.parse(fs.readFileSync(ZUGANG, 'utf8').replace(/^/, ''));
|
||||
return { seite: z.seite, benutzer: z.benutzer, passwort: entschluesseln(z.passwort) };
|
||||
}
|
||||
|
||||
function kopfzeilen(zugang) {
|
||||
const daten = Buffer.from(`${zugang.benutzer}:${zugang.passwort}`).toString('base64');
|
||||
return {
|
||||
Authorization: 'Basic ' + daten,
|
||||
// Ersatz für Server, die "Authorization" nicht an PHP weitergeben (siehe signaturen.php)
|
||||
'X-AeroMC-Auth': daten,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'AeroMC-Release',
|
||||
};
|
||||
}
|
||||
|
||||
// Übersetzt die Diagnose des Plugins (ab 1.5.4) in eine klare Meldung
|
||||
function zugangsFehler(d, benutzer) {
|
||||
if (!d || !d.version) {
|
||||
return 'Das Plugin auf der Seite ist zu alt – bitte "AeroMC Freunde" 1.5.4 oder neuer installieren.';
|
||||
}
|
||||
if (!d.anwendungspasswoerter) return 'Anwendungspasswörter sind auf der Seite abgeschaltet (z. B. durch ein Sicherheits-Plugin).';
|
||||
if (!d.authorization_kommt_an && !d.eigener_kopf_kommt_an) {
|
||||
return 'Der Server leitet keine Anmeldedaten an WordPress weiter (beide Header fehlen) – das muss der Hoster freigeben.';
|
||||
}
|
||||
switch (d.grund) {
|
||||
case 'invalid_username': return `Den Benutzer "${benutzer}" gibt es nicht (Benutzername, nicht E-Mail-Adresse).`;
|
||||
case 'incorrect_password':
|
||||
return 'Falsches Anwendungspasswort. Achtung: Gemeint ist NICHT das Login-Passwort, sondern das unter '
|
||||
+ 'Benutzer → Profil → "Anwendungspasswörter" erzeugte (Form "abcd efgh ijkl …").';
|
||||
case 'application_passwords_disabled':
|
||||
case 'application_passwords_disabled_for_user':
|
||||
return `Für "${benutzer}" sind Anwendungspasswörter abgeschaltet.`;
|
||||
default: break;
|
||||
}
|
||||
if (d.benutzer && !d.darf_hochladen) {
|
||||
return `Anmeldung als "${d.benutzer}" klappt, aber der Benutzer hat nicht die Rolle "AeroMC Release" (Benutzer bearbeiten → Rolle).`;
|
||||
}
|
||||
return 'Anmeldung abgelehnt' + (d.grund ? ` (${d.grund})` : '') + '.';
|
||||
}
|
||||
|
||||
async function pruefeZugang(zugang) {
|
||||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur/zugang`, { headers: kopfzeilen(zugang) });
|
||||
if (res.status === 404) throw new Error('Die Seite kennt die Signatur-Schnittstelle nicht – ist das Plugin "AeroMC Freunde" (ab 1.5.4) installiert und aktiviert?');
|
||||
let d = null;
|
||||
try { d = await res.json(); } catch { /* keine JSON-Antwort */ }
|
||||
if (res.ok && d && d.ok) return d;
|
||||
if (res.status === 401 || res.status === 403) throw new Error(zugangsFehler(d, zugang.benutzer));
|
||||
throw new Error('WordPress antwortet mit HTTP ' + res.status);
|
||||
}
|
||||
|
||||
async function wordpressEinrichten() {
|
||||
console.log('WordPress-Zugang für das automatische Hochladen der Signaturen einrichten.');
|
||||
console.log('Vorher in WordPress: Benutzer mit der Rolle "AeroMC Release" anlegen, dann in dessen Profil');
|
||||
console.log('unter "Anwendungspasswörter" ein Passwort erzeugen (Name z. B. "AeroMC Build").\n');
|
||||
const seite = (await frage(`Adresse der WordPress-Seite [${WP_STANDARD}]: `)) || WP_STANDARD;
|
||||
if (!/^https:\/\//i.test(seite)) throw new Error('Bitte eine https://-Adresse angeben.');
|
||||
const benutzer = await frage('WordPress-Benutzername: ');
|
||||
const passwort = await frage('Anwendungspasswort (Eingabe bleibt unsichtbar): ', true);
|
||||
if (!benutzer || !passwort) throw new Error('Benutzername und Passwort werden gebraucht.');
|
||||
const zugang = { seite: seite.replace(/\/+$/, ''), benutzer, passwort };
|
||||
const antwort = await pruefeZugang(zugang);
|
||||
fs.mkdirSync(ORDNER, { recursive: true });
|
||||
fs.writeFileSync(ZUGANG, JSON.stringify({ seite: zugang.seite, benutzer, passwort: verschluesseln(passwort) }, null, 2), { mode: 0o600 });
|
||||
console.log(`\n✔ Zugang geprüft (angemeldet als ${antwort.benutzer}) und gespeichert: ${ZUGANG}`);
|
||||
console.log(' Das Passwort ist mit Windows-DPAPI verschlüsselt.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sha256(datei) {
|
||||
const h = crypto.createHash('sha256');
|
||||
const fd = fs.openSync(datei, 'r');
|
||||
const buf = Buffer.alloc(4 * 1024 * 1024);
|
||||
let n;
|
||||
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n));
|
||||
fs.closeSync(fd);
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
function signieren(art) {
|
||||
const name = DATEIEN[art];
|
||||
if (!name) throw new Error(`Unbekannte Art "${art}" – erwartet: setup, portable, schluessel, wordpress oder hochladen.`);
|
||||
const datei = path.join(DIST, name);
|
||||
if (!fs.existsSync(datei)) throw new Error(`${datei} fehlt – erst bauen.`);
|
||||
if (!fs.existsSync(PRIVAT)) {
|
||||
throw new Error(`Kein Release-Schlüssel unter ${PRIVAT}. Einmalig "node scripts/sign-release.js schluessel" ausführen.`);
|
||||
}
|
||||
const privat = crypto.createPrivateKey(fs.readFileSync(PRIVAT, 'utf8'));
|
||||
|
||||
const eingebaut = crypto.createPublicKey(fs.readFileSync(OEFFENTLICH, 'utf8')).export({ type: 'spki', format: 'der' });
|
||||
const passend = crypto.createPublicKey(privat).export({ type: 'spki', format: 'der' });
|
||||
if (!eingebaut.equals(passend)) {
|
||||
throw new Error('Der private Schlüssel passt nicht zu src/update-schluessel.pem – Signatur wäre für den Launcher wertlos.');
|
||||
}
|
||||
|
||||
const version = require(path.join(ROOT, 'package.json')).version;
|
||||
const hash = sha256(datei);
|
||||
const sig = {
|
||||
format: 'AeroMC-Update/1',
|
||||
datei: name,
|
||||
version,
|
||||
sha256: hash,
|
||||
signatur: crypto.sign(null, signaturText(name, version, hash), privat).toString('base64'),
|
||||
};
|
||||
pruefeSignatur(sig, name, version, hash);
|
||||
fs.writeFileSync(datei + '.sig', JSON.stringify(sig, null, 2) + '\n');
|
||||
console.log(`\n» ${name} ${version} signiert`);
|
||||
return sig;
|
||||
}
|
||||
|
||||
async function hochladen(sig) {
|
||||
const zugang = ladeZugang();
|
||||
if (!zugang) {
|
||||
throw new Error('Kein WordPress-Zugang eingerichtet – Signatur NICHT hochgeladen.\n'
|
||||
+ ' Einmalig ausführen: node scripts/sign-release.js wordpress\n'
|
||||
+ ` danach: node scripts/sign-release.js hochladen ${sig.datei === DATEIEN.setup ? 'setup' : 'portable'}`);
|
||||
}
|
||||
// Nie die Signatur einer schon veröffentlichten Datei überschreiben: Dann passte
|
||||
// die EXE im Gitea-Release nicht mehr, und kein Launcher nähme das Update an.
|
||||
const vorhanden = await holeSignatur(sig.datei, sig.version);
|
||||
if (vorhanden && vorhanden.sha256 !== sig.sha256 && process.env.AEROMC_SIG_ERSETZEN !== '1') {
|
||||
throw new Error(`Für ${sig.datei} ${sig.version} liegt schon eine andere Signatur auf WordPress – `
|
||||
+ 'diese Version ist vermutlich bereits veröffentlicht. Bitte die Versionsnummer in package.json erhöhen.\n'
|
||||
+ ' (Nur wenn die Version sicher NICHT veröffentlicht ist: AEROMC_SIG_ERSETZEN=1 setzen.)');
|
||||
}
|
||||
if (vorhanden && vorhanden.sha256 === sig.sha256) {
|
||||
console.log('» Signatur liegt bereits unverändert auf WordPress.');
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur`, {
|
||||
method: 'POST', headers: kopfzeilen(zugang), body: JSON.stringify(sig),
|
||||
});
|
||||
let antwort = {};
|
||||
try { antwort = await res.json(); } catch { /* leer */ }
|
||||
if (!res.ok || !antwort.ok) {
|
||||
throw new Error(`Hochladen fehlgeschlagen (HTTP ${res.status}): ${antwort.message || 'unbekannter Fehler'}`);
|
||||
}
|
||||
// Gegenprobe: liefert die öffentliche Schnittstelle genau das, was der Launcher braucht?
|
||||
const zurueck = await holeSignatur(sig.datei, sig.version);
|
||||
if (!zurueck || zurueck.signatur !== sig.signatur || zurueck.sha256 !== sig.sha256) {
|
||||
throw new Error('Signatur wurde hochgeladen, die Seite liefert aber etwas anderes zurück (Cache?).');
|
||||
}
|
||||
console.log(`» Signatur auf ${zugang.seite} hochgeladen und geprüft${antwort.geprueft ? ' (vom Server gegengeprüft)' : ''} – ins Gitea-Release kommt nur die EXE.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [art, zusatz] = process.argv.slice(2);
|
||||
if (art === 'schluessel') return schluesselAnlegen();
|
||||
if (art === 'wordpress') return wordpressEinrichten();
|
||||
if (art === 'hochladen') {
|
||||
const name = DATEIEN[zusatz];
|
||||
if (!name) throw new Error('Erwartet: hochladen setup|portable');
|
||||
const sigDatei = path.join(DIST, name + '.sig');
|
||||
if (!fs.existsSync(sigDatei)) throw new Error(`${sigDatei} fehlt – erst bauen und signieren.`);
|
||||
const sig = JSON.parse(fs.readFileSync(sigDatei, 'utf8'));
|
||||
pruefeSignatur(sig, name, sig.version, sha256(path.join(DIST, name)));
|
||||
return hochladen(sig);
|
||||
}
|
||||
return hochladen(signieren(art));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n✖ ' + err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
@@ -1,58 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* AeroMC – CurseForge-Weiterleitung
|
||||
* ---------------------------------
|
||||
* Hält den CurseForge-API-Schlüssel und stellt die Anfragen stellvertretend
|
||||
* für den Launcher. So bleibt der Schlüssel auf dem Server: in der verteilten
|
||||
* .exe wäre er für jeden auslesbar, und CurseForge untersagt die Weitergabe
|
||||
* an Dritte ausdrücklich.
|
||||
*
|
||||
* Wege:
|
||||
* GET /health – Lebenszeichen, ohne Schlüsselangaben
|
||||
* POST /v1/mods/files – Dateiliste auflösen (Rumpf: { fileIds: [...] })
|
||||
* GET /v1/download?url=... – Mod-Datei durchreichen (CDN verlangt seit
|
||||
* Juli 2026 ebenfalls einen Schlüssel)
|
||||
*
|
||||
* Einstellungen über Umgebungsvariablen, siehe .env.example.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const KEY = (process.env.CF_API_KEY || '').trim();
|
||||
const PORT = Number(process.env.PORT || 8420);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
/*
|
||||
* Ein oder mehrere gültige Token, durch Komma getrennt.
|
||||
*
|
||||
* Mehrere deshalb, weil das Token in ausgelieferten Paketen steckt: Beim
|
||||
* Wechsel trägst du eine Zeit lang altes und neues ein, damit ältere
|
||||
* Installationen weiterlaufen, bis alle das Update haben. Danach das alte
|
||||
* streichen.
|
||||
*/
|
||||
|
||||
const TOKENS = new Set(
|
||||
String(process.env.CF_PROXY_TOKEN || '')
|
||||
.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
);
|
||||
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
|
||||
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512);
|
||||
// Anzahl der Reverse-Proxies davor. 0 = keiner, 1 = z. B. Nginx Proxy Manager,
|
||||
// 2 = z. B. Cloudflare vor dem Proxy Manager.
|
||||
|
||||
const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
|
||||
|
||||
const CF_API = 'https://api.curseforge.com/v1';
|
||||
|
||||
// Ohne User-Agent weisen Dienste hinter Cloudflare Anfragen gerne mit 403 ab.
|
||||
const UA = 'AeroMC-Launcher-Proxy/1.0 (+https://git.viper.ipv64.net)';
|
||||
|
||||
function cfKopfzeilen(weitere) {
|
||||
return Object.assign({ 'x-api-key': KEY, Accept: 'application/json', 'User-Agent': UA }, weitere || {});
|
||||
}
|
||||
|
||||
// Nur diese Hosts dürfen durchgereicht werden. Ohne diese Liste wäre der
|
||||
// Server ein offenes Tor in dein Netz – jemand könnte ihn Adressen im
|
||||
// Heimnetz abrufen lassen und die Antwort mitlesen.
|
||||
const ERLAUBTE_HOSTS = new Set([
|
||||
'edge.forgecdn.net',
|
||||
'mediafilez.forgecdn.net',
|
||||
|
||||
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 207 B |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"type": "bitmap",
|
||||
"file": "aeromc:font/symbole.png",
|
||||
"ascent": 7,
|
||||
"height": 8,
|
||||
"chars": [
|
||||
"\ue900\ue901\ue902\ue903\ue904\ue905"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"gui": {
|
||||
"scaling": {
|
||||
"type": "nine_slice",
|
||||
"width": 200,
|
||||
"height": 20,
|
||||
"border": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 676 B |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 203 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"menu.singleplayer": "\ue900 Einzelspieler",
|
||||
"menu.multiplayer": "\ue901 Mehrspieler",
|
||||
"menu.online": "\ue902 Minecraft Realms",
|
||||
"menu.options": "\ue903 Optionen \u2026",
|
||||
"menu.quit": "\ue904 Spiel beenden"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"menu.singleplayer": "\ue900 Singleplayer",
|
||||
"menu.multiplayer": "\ue901 Multiplayer",
|
||||
"menu.online": "\ue902 Minecraft Realms",
|
||||
"menu.options": "\ue903 Options...",
|
||||
"menu.quit": "\ue904 Quit Game"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"menu.sendFeedback": "\ue905 Mods verwalten \u2026",
|
||||
"menu.feedback": "\ue905 Mods & Feedback \u2026"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"menu.sendFeedback": "\ue905 Manage Mods...",
|
||||
"menu.feedback": "\ue905 Mods & Feedback..."
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 661 KiB |
@@ -0,0 +1,33 @@
|
||||
Mehr als nur Minecraft!
|
||||
Mehr als nur Minecraft!
|
||||
Mehr als nur Minecraft!
|
||||
Mehr als nur Minecraft!
|
||||
Gemeinsam mehr bauen!
|
||||
Dein Minecraft-Erlebnis!
|
||||
Gestartet mit AeroMC!
|
||||
Jetzt mit eigenen Umhängen!
|
||||
Freunde inklusive!
|
||||
Blockig, aber schön!
|
||||
Auch in Nachtlila!
|
||||
Turbo dank Leistungs-Paket!
|
||||
17 Erfolge warten!
|
||||
Mit Garderobe!
|
||||
Ein Klick, und los!
|
||||
Mods? Ein Klick!
|
||||
Diamanten nicht inklusive!
|
||||
Achtung, Creeper!
|
||||
Handgepixelt!
|
||||
Heute schon gebaut?
|
||||
Deine Welt, dein Stil!
|
||||
Spielzeit läuft mit!
|
||||
Läuft wie geschmiert!
|
||||
Kein Lag, nur Spaß!
|
||||
Lade Freunde ein!
|
||||
Schon ein Backup gemacht?
|
||||
Hallo Nachbar!
|
||||
Nur noch ein Block!
|
||||
Bitte nicht die Lava trinken!
|
||||
Jetzt auch auf Deutsch!
|
||||
Mit 100 % mehr Blau!
|
||||
Schlaf gut, Steve!
|
||||
Endlich Wochenende!
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="49.5" fill="#fff" stroke="#222" stroke-width="0.6"/>
|
||||
<circle cx="50" cy="50" r="43.8" fill="#fff" stroke="#c80000" stroke-width="11.2"/>
|
||||
<line x1="23.4" y1="23.4" x2="76.6" y2="76.6" stroke="#c80000" stroke-width="11.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -1,218 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* crashhelp.js – Absturz-Assistent
|
||||
* ---------------------------------
|
||||
* Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts
|
||||
* und erkennt die häufigsten Ursachen. Jeder Fund bekommt eine verständliche
|
||||
* Erklärung und – wo möglich – eine Aktion, die der Launcher mit einem Klick
|
||||
* ausführen kann:
|
||||
*
|
||||
* { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen
|
||||
* { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht)
|
||||
* { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern
|
||||
* { typ: 'java-auto' } passendes Java automatisch wählen lassen
|
||||
* { typ: 'mods-ordner' } Mods-Ordner öffnen
|
||||
*
|
||||
* Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen.
|
||||
*/
|
||||
|
||||
// Mod-IDs, die keine installierbaren Mods sind
|
||||
const KEINE_MODS = new Set(['minecraft', 'java', 'fabricloader', 'fabric-loader', 'forge', 'neoforge', 'quilt_loader', 'quilt-loader', 'fml', 'mixinextras']);
|
||||
|
||||
// Mod-ID -> Modrinth-Projekt, wo sich beides unterscheidet
|
||||
const PROJEKT_FUER_ID = {
|
||||
fabric: 'fabric-api',
|
||||
'fabric-api-base': 'fabric-api',
|
||||
quilted_fabric_api: 'qsl',
|
||||
cloth_config: 'cloth-config',
|
||||
'cloth-config2': 'cloth-config',
|
||||
architectury: 'architectury-api',
|
||||
geckolib3: 'geckolib',
|
||||
};
|
||||
|
||||
// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …)
|
||||
const javaAusKlassenversion = (v) => Number(v) - 44;
|
||||
|
||||
function projektFuer(modId) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
// installierte Mod zu einer ID/einem Namen finden (für "abschalten")
|
||||
function findeMod(mods, modId, name) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
const kurz = id.replace(/[-_]/g, '');
|
||||
const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (mods || []).find((m) => {
|
||||
if (m.enabled === false) return false;
|
||||
const datei = String(m.filename || '').toLowerCase();
|
||||
const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz)))
|
||||
|| (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n)));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* text: zusammengeführter Text aus Bericht, Log und Konsole
|
||||
* ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] }
|
||||
* -> [{ schluessel, titel, text, aktion? }]
|
||||
*/
|
||||
function analysiere(text, ctx = {}) {
|
||||
const t = String(text || '');
|
||||
const funde = [];
|
||||
const schonDa = new Set();
|
||||
const fund = (schluessel, titel, erklaerung, aktion) => {
|
||||
if (schonDa.has(schluessel)) return;
|
||||
schonDa.add(schluessel);
|
||||
funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null });
|
||||
};
|
||||
const mods = ctx.mods || [];
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Fabric/Quilt) ---
|
||||
// "Mod 'Mod Menu' (modmenu) 7.2.2 requires any version of fabric-api, which is missing!"
|
||||
// neuere Fassung: "... requires any version of mod 'Fabric API' (fabric-api), which is missing!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version ([^\n,]+?)|any version) of (?:mod '([^']+)' \(([\w.-]+)\)|([\w.-]+)), which is missing/gi)) {
|
||||
const [, name, , version, fehltName, fehltIdNeu, fehltIdAlt] = m;
|
||||
const fehlt = fehltIdNeu || fehltIdAlt;
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
const anzeige = fehltName || fehlt;
|
||||
fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`,
|
||||
`Der Mod „${name}“ funktioniert nur zusammen mit „${anzeige}“${version ? ` (Version ${version.trim()})` : ''} – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: anzeige });
|
||||
}
|
||||
// Lösungsvorschlag von Fabric: "- Install fabric-api, any version."
|
||||
for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) {
|
||||
const fehlt = m[1];
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`,
|
||||
`Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
}
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Forge/NeoForge) ---
|
||||
// "Mod ID: 'architectury', Requested by: 'rei', Expected range: '[9.1.12,)', Actual version: '[MISSING]'"
|
||||
for (const m of t.matchAll(/Mod ID: '([\w.-]+)', Requested by: '([\w.-]+)', Expected range: '([^']*)', Actual version: '([^']*)'/gi)) {
|
||||
const [, fehlt, von, bereich, ist] = m;
|
||||
if (fehlt.toLowerCase() === 'minecraft') {
|
||||
const mod = findeMod(mods, von);
|
||||
fund(`mcversion:${von}`, `„${von}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`Der Mod „${von}“ ist für Minecraft ${bereich} gemacht, deine Instanz hat ${ctx.version || ist}.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || von } : null);
|
||||
continue;
|
||||
}
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
if (/MISSING/i.test(ist)) {
|
||||
fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`,
|
||||
`Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
} else {
|
||||
fund(`alt:${fehlt}`, `„${fehlt}“ ist zu alt oder zu neu`,
|
||||
`„${von}“ braucht „${fehlt}“ in der Version ${bereich}, installiert ist ${ist}. Aktualisiere „${fehlt}“ im Inhalts-Dialog („Nach Updates suchen“).`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mod für eine andere Minecraft-Version (Fabric) ---
|
||||
// "Mod 'X' (x) 1.0 requires version 1.20.4 of minecraft, but only the wrong version is present: 1.21!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version |any version of )?([^\n]*?) of (?:mod )?'?minecraft'?/gi)) {
|
||||
const [, name, id, fuer] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`mcversion:${id}`, `„${name}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`„${name}“ ist für Minecraft ${fuer.replace(/^version\s+/i, '').trim() || 'eine andere Version'} gemacht, deine Instanz hat ${ctx.version || 'eine andere'}. Abschalten oder eine passende Version des Mods installieren.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// Lösungsvorschlag: "- Remove mod 'X' (x)."
|
||||
for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) {
|
||||
const [, name, id] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`entfernen:${id}`, `„${name}“ verursacht den Fehler`,
|
||||
`Der Mod-Loader schlägt vor, „${name}“ zu entfernen – er verträgt sich nicht mit den anderen Mods oder dieser Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// "Mod 'A' (a) is incompatible with any version of mod 'B' (b)"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? is incompatible with [^\n]*?mod '([^']+)' \(([\w.-]+)\)/gi)) {
|
||||
const [, a, idA, b] = m;
|
||||
const mod = findeMod(mods, idA, a);
|
||||
fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`,
|
||||
`Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null);
|
||||
}
|
||||
|
||||
// --- Doppelte Mods ---
|
||||
if (/Duplicate mods found|Found duplicate mods|is provided by multiple mods|Mod ID ['"]?[\w.-]+['"]? is (?:provided|present) multiple times/i.test(t)) {
|
||||
fund('doppelt', 'Ein Mod ist doppelt installiert',
|
||||
'Mindestens ein Mod liegt zweimal im Mods-Ordner (z. B. zwei Versionen desselben Mods). Lösche die ältere Datei.',
|
||||
{ typ: 'mods-ordner' });
|
||||
}
|
||||
|
||||
// --- Falsche Java-Version ---
|
||||
const klasse = t.match(/UnsupportedClassVersionError[\s\S]{0,300}?class file version (\d+)\.\d+/i);
|
||||
const javaPflicht = t.match(/requires (?:at least )?Java (\d+)|Java (\d+) (?:or (?:newer|later|above)) is required|Unsupported Java detected \((\d+)/i);
|
||||
if (klasse || javaPflicht) {
|
||||
const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0);
|
||||
fund('java', noetig ? `Diese Instanz braucht Java ${noetig}` : 'Falsche Java-Version',
|
||||
`Minecraft oder ein Mod wurde für ${noetig ? `Java ${noetig}` : 'eine andere Java-Version'} gebaut, gestartet wurde mit einer älteren. AeroMC kann das passende Java automatisch wählen und bei Bedarf herunterladen.`,
|
||||
{ typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Zu wenig Arbeitsspeicher ---
|
||||
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 2048;
|
||||
const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6));
|
||||
const neu = Math.min(grenze, jetzt + 2048);
|
||||
fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher',
|
||||
`Der Instanz waren ${Math.round(jetzt / 102.4) / 10} GB zugewiesen – zu wenig für diese Mods oder Welt.`
|
||||
+ (neu > jetzt ? ` Empfohlen: ${Math.round(neu / 102.4) / 10} GB.` : ' Mehr lässt dein PC kaum zu – weniger Mods oder eine kleinere Sichtweite helfen.'),
|
||||
neu > jetzt ? { typ: 'ram', mb: neu } : null);
|
||||
}
|
||||
if (/Could not reserve enough space for (?:\d+KB )?object heap|Invalid maximum heap size|Initial heap size set to a larger value than the maximum/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 4096;
|
||||
const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5)));
|
||||
fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt',
|
||||
`Java konnte die eingestellten ${Math.round(jetzt / 102.4) / 10} GB nicht reservieren – entweder ist gerade nicht genug frei oder es läuft ein 32-Bit-Java.`,
|
||||
neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Mixin-Fehler: ein bestimmter Mod passt nicht ---
|
||||
for (const m of t.matchAll(/Mixin (?:apply|transformation)[^\n]*? (?:for|from) mod ([\w.-]+) failed|from mod ([\w.-]+) failed injection check|mixins?\.json:[^\n]*? from mod ([\w.-]+)/gi)) {
|
||||
const id = m[1] || m[2] || m[3];
|
||||
if (!id || KEINE_MODS.has(id.toLowerCase())) continue;
|
||||
const mod = findeMod(mods, id);
|
||||
fund(`mixin:${id}`, `„${mod ? (mod.title || id) : id}“ passt nicht zu dieser Version`,
|
||||
`Der Mod „${id}“ greift tief in Minecraft ein und ist mit dieser Minecraft-Version oder einem anderen Mod nicht kompatibel. Schalte ihn ab oder installiere eine passende Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null);
|
||||
}
|
||||
|
||||
// --- Grafiktreiber ---
|
||||
if (/Pixel format not accelerated|WGL: The driver does not appear to support OpenGL|GLFW error 65542|OpenGL 3\.2 (?:is )?not supported|Couldn't set pixel format|No OpenGL context found/i.test(t)) {
|
||||
fund('treiber', 'Problem mit dem Grafiktreiber',
|
||||
'Minecraft konnte OpenGL nicht starten. Meist hilft es, den Grafiktreiber direkt vom Hersteller (NVIDIA, AMD oder Intel) zu aktualisieren. Bei Laptops: in den Windows-Grafikeinstellungen Java auf „Hohe Leistung“ stellen.');
|
||||
}
|
||||
|
||||
// --- Bekannte Unverträglichkeiten anhand der installierten Mods ---
|
||||
const aktive = mods.filter((m) => m.enabled !== false);
|
||||
const hat = (re) => aktive.find((m) => re.test(String(m.filename || '') + ' ' + String(m.title || '')));
|
||||
const optifine = hat(/optifine|optifabric/i);
|
||||
const sodium = hat(/sodium|embeddium|rubidium/i);
|
||||
if (optifine && sodium) {
|
||||
fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert',
|
||||
'OptiFine und Sodium (bzw. Embeddium/Rubidium) machen dasselbe und vertragen sich nicht. Schalte OptiFine ab – für Shader gibt es Iris als Ergänzung zu Sodium.',
|
||||
{ typ: 'mod-deaktivieren', dateiname: optifine.filename, name: optifine.title || 'OptiFine' });
|
||||
}
|
||||
|
||||
// --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges ---
|
||||
if (!funde.length) {
|
||||
const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im);
|
||||
if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) {
|
||||
const name = verdacht[1].split(/[,(]/)[0].trim();
|
||||
const mod = findeMod(mods, '', name);
|
||||
fund('verdacht', `Vermutlich verursacht von „${name}“`,
|
||||
`Laut Absturzbericht steckt wahrscheinlich „${verdacht[1].trim()}“ dahinter. Schalte den Mod testweise ab und starte erneut.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || name } : null);
|
||||
}
|
||||
}
|
||||
|
||||
return funde;
|
||||
}
|
||||
|
||||
module.exports = { analysiere, projektFuer };
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* crashhelp.js – Absturz-Assistent
|
||||
* ---------------------------------
|
||||
* Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts
|
||||
* und erkennt die häufigsten Ursachen. Jeder Fund bekommt eine verständliche
|
||||
* Erklärung und – wo möglich – eine Aktion, die der Launcher mit einem Klick
|
||||
* ausführen kann:
|
||||
*
|
||||
* { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen
|
||||
* { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht)
|
||||
* { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern
|
||||
* { typ: 'java-auto' } passendes Java automatisch wählen lassen
|
||||
* { typ: 'mods-ordner' } Mods-Ordner öffnen
|
||||
*
|
||||
* Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen.
|
||||
*/
|
||||
|
||||
// Mod-IDs, die keine installierbaren Mods sind
|
||||
const KEINE_MODS = new Set(['minecraft', 'java', 'fabricloader', 'fabric-loader', 'forge', 'neoforge', 'quilt_loader', 'quilt-loader', 'fml', 'mixinextras']);
|
||||
|
||||
// Mod-ID -> Modrinth-Projekt, wo sich beides unterscheidet
|
||||
const PROJEKT_FUER_ID = {
|
||||
fabric: 'fabric-api',
|
||||
'fabric-api-base': 'fabric-api',
|
||||
quilted_fabric_api: 'qsl',
|
||||
cloth_config: 'cloth-config',
|
||||
'cloth-config2': 'cloth-config',
|
||||
architectury: 'architectury-api',
|
||||
geckolib3: 'geckolib',
|
||||
};
|
||||
|
||||
// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …)
|
||||
const javaAusKlassenversion = (v) => Number(v) - 44;
|
||||
|
||||
function projektFuer(modId) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
// installierte Mod zu einer ID/einem Namen finden (für "abschalten")
|
||||
function findeMod(mods, modId, name) {
|
||||
const id = String(modId || '').toLowerCase();
|
||||
const kurz = id.replace(/[-_]/g, '');
|
||||
const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (mods || []).find((m) => {
|
||||
if (m.enabled === false) return false;
|
||||
const datei = String(m.filename || '').toLowerCase();
|
||||
const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz)))
|
||||
|| (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n)));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* text: zusammengeführter Text aus Bericht, Log und Konsole
|
||||
* ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] }
|
||||
* -> [{ schluessel, titel, text, aktion? }]
|
||||
*/
|
||||
function analysiere(text, ctx = {}) {
|
||||
const t = String(text || '');
|
||||
const funde = [];
|
||||
const schonDa = new Set();
|
||||
const fund = (schluessel, titel, erklaerung, aktion) => {
|
||||
if (schonDa.has(schluessel)) return;
|
||||
schonDa.add(schluessel);
|
||||
funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null });
|
||||
};
|
||||
const mods = ctx.mods || [];
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Fabric/Quilt) ---
|
||||
// "Mod 'Mod Menu' (modmenu) 7.2.2 requires any version of fabric-api, which is missing!"
|
||||
// neuere Fassung: "... requires any version of mod 'Fabric API' (fabric-api), which is missing!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version ([^\n,]+?)|any version) of (?:mod '([^']+)' \(([\w.-]+)\)|([\w.-]+)), which is missing/gi)) {
|
||||
const [, name, , version, fehltName, fehltIdNeu, fehltIdAlt] = m;
|
||||
const fehlt = fehltIdNeu || fehltIdAlt;
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
const anzeige = fehltName || fehlt;
|
||||
fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`,
|
||||
`Der Mod „${name}“ funktioniert nur zusammen mit „${anzeige}“${version ? ` (Version ${version.trim()})` : ''} – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: anzeige });
|
||||
}
|
||||
// Lösungsvorschlag von Fabric: "- Install fabric-api, any version."
|
||||
for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) {
|
||||
const fehlt = m[1];
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`,
|
||||
`Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
}
|
||||
|
||||
// --- Fehlende Abhängigkeiten (Forge/NeoForge) ---
|
||||
// "Mod ID: 'architectury', Requested by: 'rei', Expected range: '[9.1.12,)', Actual version: '[MISSING]'"
|
||||
for (const m of t.matchAll(/Mod ID: '([\w.-]+)', Requested by: '([\w.-]+)', Expected range: '([^']*)', Actual version: '([^']*)'/gi)) {
|
||||
const [, fehlt, von, bereich, ist] = m;
|
||||
if (fehlt.toLowerCase() === 'minecraft') {
|
||||
const mod = findeMod(mods, von);
|
||||
fund(`mcversion:${von}`, `„${von}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`Der Mod „${von}“ ist für Minecraft ${bereich} gemacht, deine Instanz hat ${ctx.version || ist}.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || von } : null);
|
||||
continue;
|
||||
}
|
||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||
if (/MISSING/i.test(ist)) {
|
||||
fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`,
|
||||
`Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`,
|
||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||
} else {
|
||||
fund(`alt:${fehlt}`, `„${fehlt}“ ist zu alt oder zu neu`,
|
||||
`„${von}“ braucht „${fehlt}“ in der Version ${bereich}, installiert ist ${ist}. Aktualisiere „${fehlt}“ im Inhalts-Dialog („Nach Updates suchen“).`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mod für eine andere Minecraft-Version (Fabric) ---
|
||||
// "Mod 'X' (x) 1.0 requires version 1.20.4 of minecraft, but only the wrong version is present: 1.21!"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version |any version of )?([^\n]*?) of (?:mod )?'?minecraft'?/gi)) {
|
||||
const [, name, id, fuer] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`mcversion:${id}`, `„${name}“ passt nicht zu dieser Minecraft-Version`,
|
||||
`„${name}“ ist für Minecraft ${fuer.replace(/^version\s+/i, '').trim() || 'eine andere Version'} gemacht, deine Instanz hat ${ctx.version || 'eine andere'}. Abschalten oder eine passende Version des Mods installieren.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// Lösungsvorschlag: "- Remove mod 'X' (x)."
|
||||
for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) {
|
||||
const [, name, id] = m;
|
||||
const mod = findeMod(mods, id, name);
|
||||
fund(`entfernen:${id}`, `„${name}“ verursacht den Fehler`,
|
||||
`Der Mod-Loader schlägt vor, „${name}“ zu entfernen – er verträgt sich nicht mit den anderen Mods oder dieser Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||
}
|
||||
// "Mod 'A' (a) is incompatible with any version of mod 'B' (b)"
|
||||
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? is incompatible with [^\n]*?mod '([^']+)' \(([\w.-]+)\)/gi)) {
|
||||
const [, a, idA, b] = m;
|
||||
const mod = findeMod(mods, idA, a);
|
||||
fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`,
|
||||
`Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null);
|
||||
}
|
||||
|
||||
// --- Doppelte Mods ---
|
||||
if (/Duplicate mods found|Found duplicate mods|is provided by multiple mods|Mod ID ['"]?[\w.-]+['"]? is (?:provided|present) multiple times/i.test(t)) {
|
||||
fund('doppelt', 'Ein Mod ist doppelt installiert',
|
||||
'Mindestens ein Mod liegt zweimal im Mods-Ordner (z. B. zwei Versionen desselben Mods). Lösche die ältere Datei.',
|
||||
{ typ: 'mods-ordner' });
|
||||
}
|
||||
|
||||
// --- Falsche Java-Version ---
|
||||
const klasse = t.match(/UnsupportedClassVersionError[\s\S]{0,300}?class file version (\d+)\.\d+/i);
|
||||
const javaPflicht = t.match(/requires (?:at least )?Java (\d+)|Java (\d+) (?:or (?:newer|later|above)) is required|Unsupported Java detected \((\d+)/i);
|
||||
if (klasse || javaPflicht) {
|
||||
const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0);
|
||||
fund('java', noetig ? `Diese Instanz braucht Java ${noetig}` : 'Falsche Java-Version',
|
||||
`Minecraft oder ein Mod wurde für ${noetig ? `Java ${noetig}` : 'eine andere Java-Version'} gebaut, gestartet wurde mit einer älteren. AeroMC kann das passende Java automatisch wählen und bei Bedarf herunterladen.`,
|
||||
{ typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Alte Forge-Version verträgt aktuelles Java 8 nicht ---
|
||||
// Forge 1.16.5 vor 36.2.26 (und ähnlich alte Fassungen) nutzt eine interne
|
||||
// Java-Klasse, die es seit Java 8u321 so nicht mehr gibt.
|
||||
if (/sun\.security\.util\.ManifestEntryVerifier\.<init>/i.test(t) && /forge/i.test(String(ctx.loader || ''))) {
|
||||
fund('forge-alt', 'Diese Forge-Version ist zu alt für das aktuelle Java',
|
||||
'Ältere Forge-Versionen laufen mit aktuellem Java 8 nicht mehr (Fehler „ManifestEntryVerifier“). Eine neuere Forge-Version für dieselbe Minecraft-Version behebt das – Mods und Welten bleiben erhalten.',
|
||||
{ typ: 'forge-aktualisieren' });
|
||||
}
|
||||
|
||||
// --- Zu wenig Arbeitsspeicher ---
|
||||
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 2048;
|
||||
const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6));
|
||||
const neu = Math.min(grenze, jetzt + 2048);
|
||||
fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher',
|
||||
`Der Instanz waren ${Math.round(jetzt / 102.4) / 10} GB zugewiesen – zu wenig für diese Mods oder Welt.`
|
||||
+ (neu > jetzt ? ` Empfohlen: ${Math.round(neu / 102.4) / 10} GB.` : ' Mehr lässt dein PC kaum zu – weniger Mods oder eine kleinere Sichtweite helfen.'),
|
||||
neu > jetzt ? { typ: 'ram', mb: neu } : null);
|
||||
}
|
||||
if (/Could not reserve enough space for (?:\d+KB )?object heap|Invalid maximum heap size|Initial heap size set to a larger value than the maximum/i.test(t)) {
|
||||
const jetzt = Number(ctx.maxMemMb) || 4096;
|
||||
const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5)));
|
||||
fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt',
|
||||
`Java konnte die eingestellten ${Math.round(jetzt / 102.4) / 10} GB nicht reservieren – entweder ist gerade nicht genug frei oder es läuft ein 32-Bit-Java.`,
|
||||
neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' });
|
||||
}
|
||||
|
||||
// --- Mixin-Fehler: ein bestimmter Mod passt nicht ---
|
||||
for (const m of t.matchAll(/Mixin (?:apply|transformation)[^\n]*? (?:for|from) mod ([\w.-]+) failed|from mod ([\w.-]+) failed injection check|mixins?\.json:[^\n]*? from mod ([\w.-]+)/gi)) {
|
||||
const id = m[1] || m[2] || m[3];
|
||||
if (!id || KEINE_MODS.has(id.toLowerCase())) continue;
|
||||
const mod = findeMod(mods, id);
|
||||
fund(`mixin:${id}`, `„${mod ? (mod.title || id) : id}“ passt nicht zu dieser Version`,
|
||||
`Der Mod „${id}“ greift tief in Minecraft ein und ist mit dieser Minecraft-Version oder einem anderen Mod nicht kompatibel. Schalte ihn ab oder installiere eine passende Version.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null);
|
||||
}
|
||||
|
||||
// --- Grafiktreiber ---
|
||||
if (/Pixel format not accelerated|WGL: The driver does not appear to support OpenGL|GLFW error 65542|OpenGL 3\.2 (?:is )?not supported|Couldn't set pixel format|No OpenGL context found/i.test(t)) {
|
||||
fund('treiber', 'Problem mit dem Grafiktreiber',
|
||||
'Minecraft konnte OpenGL nicht starten. Meist hilft es, den Grafiktreiber direkt vom Hersteller (NVIDIA, AMD oder Intel) zu aktualisieren. Bei Laptops: in den Windows-Grafikeinstellungen Java auf „Hohe Leistung“ stellen.');
|
||||
}
|
||||
|
||||
// --- Bekannte Unverträglichkeiten anhand der installierten Mods ---
|
||||
const aktive = mods.filter((m) => m.enabled !== false);
|
||||
const hat = (re) => aktive.find((m) => re.test(String(m.filename || '') + ' ' + String(m.title || '')));
|
||||
const optifine = hat(/optifine|optifabric/i);
|
||||
const sodium = hat(/sodium|embeddium|rubidium/i);
|
||||
if (optifine && sodium) {
|
||||
fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert',
|
||||
'OptiFine und Sodium (bzw. Embeddium/Rubidium) machen dasselbe und vertragen sich nicht. Schalte OptiFine ab – für Shader gibt es Iris als Ergänzung zu Sodium.',
|
||||
{ typ: 'mod-deaktivieren', dateiname: optifine.filename, name: optifine.title || 'OptiFine' });
|
||||
}
|
||||
|
||||
// --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges ---
|
||||
if (!funde.length) {
|
||||
const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im);
|
||||
if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) {
|
||||
const name = verdacht[1].split(/[,(]/)[0].trim();
|
||||
const mod = findeMod(mods, '', name);
|
||||
fund('verdacht', `Vermutlich verursacht von „${name}“`,
|
||||
`Laut Absturzbericht steckt wahrscheinlich „${verdacht[1].trim()}“ dahinter. Schalte den Mod testweise ab und starte erneut.`,
|
||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || name } : null);
|
||||
}
|
||||
}
|
||||
|
||||
return funde;
|
||||
}
|
||||
|
||||
module.exports = { analysiere, projektFuer };
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
/* ===================== Design-Auswahl in den Optionen ===================== */
|
||||
.design-raster{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
||||
.settings-card-wide .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
@media (max-width:900px){.settings-card-wide .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.settings-card .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
@media (max-width:900px){.settings-card .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.design-wahl{display:flex;flex-direction:column;gap:5px;padding:6px;border:1px solid var(--border);border-radius:8px;background:none;color:var(--text);font-family:inherit;font-size:12px;cursor:pointer;text-align:left}
|
||||
.design-wahl:hover{border-color:var(--sel-br)}
|
||||
.design-wahl.on{border-color:var(--sel-br);box-shadow:0 0 0 1px var(--sel-br);background:var(--sel)}
|
||||
@@ -94,6 +94,10 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card.sel{background:linear-gradient(180deg,rgba(var(--p-akzent),.24),rgba(var(--p-tief),.14));border-color:rgba(var(--p-akzent2),.55);box-shadow:0 10px 28px -14px rgba(var(--p-akzent),.95),0 0 0 1px rgba(var(--p-hell),.08) inset}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card.sel .card-name{background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));color:#fff;border-radius:999px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card-icon-img{border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card.laeuft::after{border-color:rgb(var(--p-akzent2));--accent:rgb(var(--p-akzent2))}
|
||||
:root.ui-animiert:is([data-design="aero"],[data-design="lila"]) .lichtpunkte{display:block}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .lichtpunkte i{background:rgb(var(--p-hell));box-shadow:0 0 calc(var(--g) * 3) 1px rgba(var(--p-akzent2),.85)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg .funke{box-shadow:0 0 5px 2px rgb(var(--p-hell)),0 0 14px 4px rgba(var(--p-akzent2),.8)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .card-taste{border-radius:999px;background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .empty h2{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));-webkit-background-clip:text;background-clip:text;color:transparent;font-size:26px}
|
||||
|
||||
@@ -132,7 +136,7 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mods-filters select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .mmc-bar select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .clip-length select,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,:root:is([data-design="aero"],[data-design="lila"]) .path-row select,:root:is([data-design="aero"],[data-design="lila"]) select.s-wert,:root:is([data-design="aero"],[data-design="lila"]) input.s-wert,:root:is([data-design="aero"],[data-design="lila"]) .s-paar input,:root:is([data-design="aero"],[data-design="lila"]) .s-einheit input,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-farbzeile input[type=text],
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .se-feld select{background:var(--input);color:var(--text);border-color:var(--border);border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px}
|
||||
@@ -145,6 +149,11 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) input[type=checkbox],:root:is([data-design="aero"],[data-design="lila"]) input[type=radio],:root:is([data-design="aero"],[data-design="lila"]) input[type=range]{accent-color:rgb(var(--p-akzent))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ms-spalte{background:var(--surface);border-radius:14px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ms-kopf input{background:var(--input);border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes li::marker{color:rgb(var(--p-akzent2))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
|
||||
|
||||
/* Knöpfe – rund wie auf der Webseite */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .btn{border-radius:999px;padding:7px 15px;background:var(--surface);border-color:var(--border);color:var(--text);transition:background .15s,border-color .15s,transform .15s}
|
||||
@@ -190,6 +199,7 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .snav:hover{background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .snav.on{background:linear-gradient(120deg,rgba(var(--p-akzent),.30),rgba(var(--p-tief),.18));border-color:rgba(var(--p-akzent2),.40);box-shadow:none}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-card{background:var(--surface);border-color:var(--border);border-radius:16px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .schalter input:checked+i{background:linear-gradient(120deg,rgb(var(--p-akzent)),rgb(var(--p-akzent2)));box-shadow:0 0 10px -2px rgba(var(--p-akzent),.8)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-kv-row,
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .about-brand{border-bottom-color:var(--border2)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .appinfo-item{background:rgba(var(--p-linie),.04);border-color:var(--border2);border-radius:12px}
|
||||
@@ -197,6 +207,12 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .settings-hero h4{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 70%);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
|
||||
/* Kontextmenü, Hinweis, Bildlaufleisten */
|
||||
/* Erfolg-Meldung: Glas-Karte mit Leuchtrand wie die Webseite */
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg{background:linear-gradient(135deg,rgba(var(--p-akzent),.22),var(--surface) 55%);border:1px solid rgba(var(--p-akzent),.45);border-radius:14px;
|
||||
box-shadow:0 0 0 1px rgba(255,255,255,.04) inset,0 18px 40px -12px rgba(0,0,0,.8),0 0 24px -8px rgba(var(--p-akzent),.7)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-symbol{border-radius:10px;background:rgba(var(--p-akzent),.18)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-kopf{color:rgb(var(--p-hell))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-karte{border-radius:12px;background:var(--surface)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ctx{background:var(--surface);border-color:var(--border);border-radius:12px;box-shadow:0 18px 40px -12px rgba(0,0,0,.8)}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item{border-radius:8px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)}
|
||||
@@ -267,6 +283,10 @@
|
||||
:root[data-design="minecraft"] .toolbar{
|
||||
background:url(assets/design/gras.png) 0 0/32px 8px repeat-x,url(assets/design/schiefer.png) 0 0/64px 64px;
|
||||
image-rendering:pixelated;border-bottom:2px solid #000;padding:11px 8px 6px}
|
||||
:root[data-design="minecraft"] .toolbar{position:relative;isolation:isolate}
|
||||
:root.ui-animiert[data-design="minecraft"] .toolbar::before{content:'';position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:.18;
|
||||
background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='512' height='48' shape-rendering='crispEdges' fill='white'><rect x='20' y='8' width='56' height='8'/><rect x='8' y='16' width='96' height='8'/><rect x='170' y='22' width='40' height='6'/><rect x='158' y='28' width='80' height='8'/><rect x='184' y='36' width='36' height='4'/><rect x='300' y='4' width='72' height='8'/><rect x='288' y='12' width='120' height='8'/><rect x='312' y='20' width='64' height='6'/><rect x='438' y='26' width='48' height='8'/><rect x='426' y='34' width='70' height='6'/></svg>") 0 0/512px 100% repeat-x;animation:wolken-ziehen 70s steps(512) infinite}
|
||||
@keyframes wolken-ziehen{to{background-position:512px 0}}
|
||||
:root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto}
|
||||
:root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff}
|
||||
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
|
||||
@@ -288,7 +308,7 @@
|
||||
:root[data-design="minecraft"] .mods-filters select,
|
||||
:root[data-design="minecraft"] .mmc-bar select,
|
||||
:root[data-design="minecraft"] .clip-length select,
|
||||
:root[data-design="minecraft"] .path-row input,
|
||||
:root[data-design="minecraft"] .path-row input,:root[data-design="minecraft"] .path-row select,:root[data-design="minecraft"] select.s-wert,:root[data-design="minecraft"] input.s-wert,:root[data-design="minecraft"] .s-paar input,:root[data-design="minecraft"] .s-einheit input,
|
||||
:root[data-design="minecraft"] .login-usercode,
|
||||
:root[data-design="minecraft"] .upd-notes,
|
||||
:root[data-design="minecraft"] .se-farbzeile input[type=text],
|
||||
@@ -319,6 +339,11 @@
|
||||
:root[data-design="minecraft"] .card.sel .card-name{background:none;color:#ffff7f;border-radius:0}
|
||||
:root[data-design="minecraft"] .card-taste{border-radius:0;background:#3c8527;box-shadow:inset -1px -1px 0 #1d4d13}
|
||||
:root[data-design="minecraft"] .card-icon-img{border-radius:0}
|
||||
:root[data-design="minecraft"] .card.laeuft::after{border-color:#7fd858;--accent:#7fd858;border-radius:0}
|
||||
/* Abbau-Risse: Download-Fortschritt auf dem Block, beim Fertigwerden springt er kurz */
|
||||
:root[data-design="minecraft"] .card.abbau .card-riss{display:block}
|
||||
:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon,:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon-img{animation:block-bricht .45s cubic-bezier(.3,.7,.4,1)}
|
||||
@keyframes block-bricht{0%{transform:scale(.8)}45%{transform:scale(1.14)}100%{transform:none}}
|
||||
:root[data-design="minecraft"] .empty h2{font-family:var(--pixel);font-weight:400;font-size:24px;color:#fff;text-shadow:3px 3px 0 #3f3f3f}
|
||||
:root[data-design="minecraft"] .empty p{color:#d0d0d0}
|
||||
|
||||
@@ -332,6 +357,8 @@
|
||||
:root[data-design="minecraft"] .hr{background:#000;height:2px;margin:6px 0}
|
||||
:root[data-design="minecraft"] .sbtn{margin-bottom:3px;padding:5px 2px}
|
||||
:root[data-design="minecraft"] .sbtn.play{font-family:var(--pixel);font-size:16px;font-weight:400;padding:6px 4px 7px;text-shadow:var(--mc-schatten)}
|
||||
:root[data-design="minecraft"] .sbtn.play::before,:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play::before{background:#fff;opacity:.28}
|
||||
:root[data-design="minecraft"] .sbtn.play.laedt .play-text{font-size:13px}
|
||||
|
||||
/* Statusleiste */
|
||||
:root[data-design="minecraft"] .statusbar{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-top:2px solid #000}
|
||||
@@ -391,6 +418,10 @@
|
||||
:root[data-design="minecraft"] .settings-hero h4{font-family:var(--pixel);font-size:24px;font-weight:400;text-shadow:3px 3px 0 #3f3f3f}
|
||||
:root[data-design="minecraft"] .settings-hero p{color:#d0d0d0}
|
||||
:root[data-design="minecraft"] .settings-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f}
|
||||
:root[data-design="minecraft"] .schalter i{border-radius:0;background:#1e1e1f;box-shadow:inset 2px 2px 0 #000}
|
||||
:root[data-design="minecraft"] .schalter i::after{border-radius:0;background:#c6c6c6;box-shadow:inset -2px -2px 0 #555,inset 2px 2px 0 #fff}
|
||||
:root[data-design="minecraft"] .schalter input:checked+i{background:#3c8527}
|
||||
:root[data-design="minecraft"] .s-zeile{border-top-color:rgba(0,0,0,.55)}
|
||||
:root[data-design="minecraft"] .settings-card h5{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
||||
:root[data-design="minecraft"] .settings-kv-row,
|
||||
:root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b}
|
||||
@@ -399,6 +430,12 @@
|
||||
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
||||
|
||||
/* Kontextmenü, Hinweis, Bildlaufleisten */
|
||||
/* Erfolg-Meldung wie im Spiel („Erfolg erzielt!“) */
|
||||
:root[data-design="minecraft"] .erfolg{background:#212121;border:2px solid #555;border-radius:0;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #141414,0 10px 24px rgba(0,0,0,.45);color:#fff;font-family:"AeroPixel","Segoe UI",sans-serif}
|
||||
:root[data-design="minecraft"] .erfolg-symbol{border-radius:0;background:#8b8b8b;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #fff}
|
||||
:root[data-design="minecraft"] .erfolg-kopf{color:#ffff55;font-size:14px;font-weight:400;letter-spacing:0;text-transform:none}
|
||||
:root[data-design="minecraft"] .erfolg-name{font-size:14px;font-weight:400}
|
||||
:root[data-design="minecraft"] .erfolg-karte{border-radius:0;border:2px solid #000;background:rgba(0,0,0,.35)}
|
||||
:root[data-design="minecraft"] .ctx{background:#1e1e1f;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a,0 8px 24px rgba(0,0,0,.5)}
|
||||
:root[data-design="minecraft"] .ctx-item{border-radius:0;text-shadow:1px 1px 0 #000}
|
||||
:root[data-design="minecraft"] .ctx-item:hover{background:#3c8527}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Erfolge im Minecraft-Stil („Erfolg erzielt!“).
|
||||
*
|
||||
* Bewertet wird aus dem, was der Launcher ohnehin weiß (Instanzen, Spielzeit,
|
||||
* Starts, Mods), plus ein paar Merkern für einzelne Aktionen (Umhang
|
||||
* hochgeladen, Skin im Editor übernommen, Leistungs-Paket …). Freigeschaltete
|
||||
* Erfolge landen mit Datum in userData/erfolge.json.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STUNDE = 3600;
|
||||
const LISTE = [
|
||||
{ id: 'erster-start', symbol: 'grass', titel: 'Los geht’s!', text: 'Starte zum ersten Mal ein Spiel mit AeroMC.', pruefe: (w) => w.starts >= 1 },
|
||||
{ id: 'starts-10', symbol: 'pickaxe', titel: 'Stammgast', text: 'Starte 10-mal ein Spiel.', pruefe: (w) => w.starts >= 10 },
|
||||
{ id: 'starts-100', symbol: 'gold', titel: 'Ohne mich geht nichts', text: 'Starte 100-mal ein Spiel.', pruefe: (w) => w.starts >= 100 },
|
||||
{ id: 'zeit-1', symbol: 'map', titel: 'Erste Stunde', text: 'Spiele insgesamt eine Stunde.', pruefe: (w) => w.sekunden >= STUNDE },
|
||||
{ id: 'zeit-10', symbol: 'sword', titel: 'Zeit vergeht wie im Flug', text: 'Spiele insgesamt 10 Stunden.', pruefe: (w) => w.sekunden >= 10 * STUNDE },
|
||||
{ id: 'zeit-50', symbol: 'diamond', titel: 'Abenteurer', text: 'Spiele insgesamt 50 Stunden.', pruefe: (w) => w.sekunden >= 50 * STUNDE },
|
||||
{ id: 'zeit-100', symbol: 'star', titel: 'Lebt in Minecraft', text: 'Spiele insgesamt 100 Stunden.', pruefe: (w) => w.sekunden >= 100 * STUNDE },
|
||||
{ id: 'instanzen-5', symbol: 'chest', titel: 'Sammler', text: 'Lege 5 Instanzen an.', pruefe: (w) => w.instanzen >= 5 },
|
||||
{ id: 'instanzen-10', symbol: 'book', titel: 'Archivar', text: 'Lege 10 Instanzen an.', pruefe: (w) => w.instanzen >= 10 },
|
||||
{ id: 'versionen-3', symbol: 'end', titel: 'Zeitreisender', text: 'Spiele 3 verschiedene Minecraft-Versionen.', pruefe: (w) => w.versionen >= 3 },
|
||||
{ id: 'erste-mod', symbol: 'redstone', titel: 'Bastler', text: 'Habe die erste Mod in einer Instanz.', pruefe: (w) => w.mods >= 1 },
|
||||
{ id: 'mods-50', symbol: 'potion', titel: 'Mod-Meister', text: 'Habe 50 Mods in deinen Instanzen.', pruefe: (w) => w.mods >= 50 },
|
||||
{ id: 'modpack', symbol: 'nether', titel: 'Das volle Paket', text: 'Installiere ein Modpack.', pruefe: (w) => w.modpacks >= 1 },
|
||||
{ id: 'freund', symbol: 'creeper', titel: 'Gemeinsam statt einsam', text: 'Füge deinen ersten Freund hinzu.', pruefe: (w) => w.freunde >= 1 },
|
||||
{ id: 'umhang', symbol: 'star', titel: 'Modeschöpfer', text: 'Lade einen eigenen AeroMC-Umhang hoch.', pruefe: (w) => w.merker.umhang },
|
||||
{ id: 'skin-editor', symbol: 'map', titel: 'Pixelkünstler', text: 'Übernimm einen im Skin-Editor gemalten Skin.', pruefe: (w) => w.merker['skin-editor'] },
|
||||
{ id: 'leistung', symbol: 'tnt', titel: 'Turbo', text: 'Installiere das Leistungs-Paket.', pruefe: (w) => w.merker.leistung },
|
||||
];
|
||||
|
||||
let datei = null;
|
||||
function init(userDataDir) { datei = path.join(userDataDir, 'erfolge.json'); }
|
||||
|
||||
function lies() {
|
||||
try { return JSON.parse(fs.readFileSync(datei, 'utf8')); } catch { return null; }
|
||||
}
|
||||
function schreibe(stand) {
|
||||
try { fs.writeFileSync(datei, JSON.stringify(stand, null, 1)); } catch { /* nicht schlimm */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* werte: { starts, sekunden, instanzen, versionen, mods, modpacks, freunde }
|
||||
* merker: zusätzliche Einzel-Aktionen ({ umhang: true } …)
|
||||
* -> { neu: [Erfolge], ersteAuswertung }
|
||||
*/
|
||||
function pruefe(werte, merker = {}) {
|
||||
const vorher = lies();
|
||||
const stand = vorher || { freigeschaltet: {}, merker: {} };
|
||||
Object.assign(stand.merker, merker);
|
||||
const w = Object.assign({ starts: 0, sekunden: 0, instanzen: 0, versionen: 0, mods: 0, modpacks: 0, freunde: 0 }, werte, { merker: stand.merker });
|
||||
const neu = [];
|
||||
const jetzt = new Date().toISOString();
|
||||
for (const e of LISTE) {
|
||||
if (stand.freigeschaltet[e.id]) continue;
|
||||
let erreicht = false;
|
||||
try { erreicht = !!e.pruefe(w); } catch { /* Wert fehlt */ }
|
||||
if (erreicht) { stand.freigeschaltet[e.id] = jetzt; neu.push(oeffentlich(e, jetzt)); }
|
||||
}
|
||||
if (neu.length || !vorher || Object.keys(merker).length) schreibe(stand);
|
||||
return { neu, ersteAuswertung: !vorher };
|
||||
}
|
||||
|
||||
const oeffentlich = (e, am) => ({ id: e.id, symbol: e.symbol, titel: e.titel, text: e.text, am: am || null });
|
||||
|
||||
/** Alle Erfolge mit Datum (null = noch offen). */
|
||||
function liste() {
|
||||
const stand = lies() || { freigeschaltet: {} };
|
||||
return LISTE.map((e) => oeffentlich(e, stand.freigeschaltet[e.id] || null));
|
||||
}
|
||||
|
||||
module.exports = { init, pruefe, liste };
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Garderobe: gespeicherte Skins und Umhänge zum schnellen Wechseln.
|
||||
*
|
||||
* Mojang merkt sich keine früheren Skins – wer einen neuen hochlädt, verliert
|
||||
* den alten. Die Garderobe hält Skins (samt Armform) und eigene AeroMC-Umhänge
|
||||
* als PNG-Dateien im Launcher-Datenordner fest; vor jedem Skinwechsel im
|
||||
* Launcher wird der bisherige Skin automatisch gesichert. Gleiche Dateien
|
||||
* (gleiche Prüfsumme) werden nicht doppelt abgelegt.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const MAX_AUTOMATISCH = 20; // ältere automatische Sicherungen fliegen raus
|
||||
let ordner = null;
|
||||
|
||||
function init(userDataDir) {
|
||||
ordner = path.join(userDataDir, 'garderobe');
|
||||
}
|
||||
|
||||
const indexDatei = () => path.join(ordner, 'garderobe.json');
|
||||
function lies() {
|
||||
try { return JSON.parse(fs.readFileSync(indexDatei(), 'utf8')); } catch { return []; }
|
||||
}
|
||||
function schreibe(liste) {
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
fs.writeFileSync(indexDatei(), JSON.stringify(liste, null, 1));
|
||||
}
|
||||
|
||||
/** Alle Einträge, neueste zuerst, jeweils mit Bild als data:-Adresse. */
|
||||
function liste() {
|
||||
return lies()
|
||||
.filter((e) => fs.existsSync(path.join(ordner, e.datei)))
|
||||
.sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)))
|
||||
.map((e) => Object.assign({}, e, {
|
||||
dataUri: 'data:image/png;base64,' + fs.readFileSync(path.join(ordner, e.datei)).toString('base64'),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Skin/Umhang ab. art: 'skin' | 'umhang', daten: PNG-Buffer.
|
||||
* Gibt es dieselbe Datei schon, wird nur ggf. aus "automatisch" ein fester Eintrag.
|
||||
*/
|
||||
function hinzufuegen({ art, daten, name, variante, automatisch }) {
|
||||
const hash = crypto.createHash('sha1').update(daten).digest('hex');
|
||||
const eintraege = lies();
|
||||
const vorhanden = eintraege.find((e) => e.art === art && e.hash === hash);
|
||||
if (vorhanden) {
|
||||
if (!automatisch && vorhanden.automatisch) {
|
||||
vorhanden.automatisch = false;
|
||||
if (name) vorhanden.name = name;
|
||||
schreibe(eintraege);
|
||||
}
|
||||
return { eintrag: vorhanden, neu: false };
|
||||
}
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
const id = Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
|
||||
const datei = `${art}-${id}.png`;
|
||||
fs.writeFileSync(path.join(ordner, datei), daten);
|
||||
const eintrag = {
|
||||
id, art, hash, datei,
|
||||
name: String(name || '').trim().slice(0, 40) || (art === 'skin' ? 'Skin' : 'Umhang'),
|
||||
variante: art === 'skin' ? (variante === 'slim' ? 'slim' : 'classic') : undefined,
|
||||
automatisch: !!automatisch,
|
||||
erstellt: new Date().toISOString(),
|
||||
};
|
||||
eintraege.push(eintrag);
|
||||
// automatische Sicherungen begrenzen (feste Einträge bleiben immer)
|
||||
const auto = eintraege.filter((e) => e.automatisch).sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)));
|
||||
for (const alt of auto.slice(MAX_AUTOMATISCH)) loescheIntern(eintraege, alt.id);
|
||||
schreibe(eintraege);
|
||||
return { eintrag, neu: true };
|
||||
}
|
||||
|
||||
function loescheIntern(eintraege, id) {
|
||||
const i = eintraege.findIndex((e) => e.id === id);
|
||||
if (i < 0) return false;
|
||||
try { fs.unlinkSync(path.join(ordner, eintraege[i].datei)); } catch { /* schon weg */ }
|
||||
eintraege.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function loeschen(id) {
|
||||
const eintraege = lies();
|
||||
const ok = loescheIntern(eintraege, id);
|
||||
if (ok) schreibe(eintraege);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function umbenennen(id, name) {
|
||||
const eintraege = lies();
|
||||
const e = eintraege.find((x) => x.id === id);
|
||||
if (!e) return false;
|
||||
e.name = String(name || '').trim().slice(0, 40) || e.name;
|
||||
e.automatisch = false; // umbenannt = bewusst behalten
|
||||
schreibe(eintraege);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Eintrag samt Dateipfad (zum Anziehen). */
|
||||
function holen(id) {
|
||||
const e = lies().find((x) => x.id === id);
|
||||
return e ? Object.assign({}, e, { pfad: path.join(ordner, e.datei) }) : null;
|
||||
}
|
||||
|
||||
module.exports = { init, liste, hinzufuegen, loeschen, umbenennen, holen };
|
||||
@@ -38,6 +38,7 @@
|
||||
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
|
||||
<main class="workspace">
|
||||
<div class="list-area">
|
||||
<div id="lichtpunkte" class="lichtpunkte" aria-hidden="true"></div>
|
||||
<canvas id="skin-live" class="skin-live hidden"></canvas>
|
||||
<div id="grid" class="list"></div>
|
||||
<section id="empty" class="empty hidden">
|
||||
@@ -53,8 +54,15 @@
|
||||
<div id="p-icon" class="side-ico">🌱</div>
|
||||
<div id="p-name" class="side-title">Instanz</div>
|
||||
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
|
||||
<div id="p-gesperrt" class="side-gesperrt hidden">
|
||||
<div class="side-gesperrt-titel"><img src="assets/verbot.svg" alt="" /> Instanz gesperrt</div>
|
||||
<div class="side-gesperrt-text">Diese Instanz wurde außerhalb des Launchers verändert und kann nicht mehr gestartet werden.</div>
|
||||
<ul id="p-gesperrt-gruende" class="side-gesperrt-gruende"></ul>
|
||||
<button id="p-wiederherstellen" class="sbtn play">Aus Sicherung wiederherstellen</button>
|
||||
<div class="side-gesperrt-text dim">Die Wiederherstellung wird als neue Instanz angelegt. Die gesperrte bleibt erhalten, bis du sie selbst löschst.</div>
|
||||
</div>
|
||||
<div class="hr"></div>
|
||||
<button id="p-play" class="sbtn play">Starten</button>
|
||||
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
|
||||
<button id="p-offline" class="sbtn">Offline starten</button>
|
||||
<div class="hr"></div>
|
||||
<button id="p-edit" class="sbtn">Instanz bearbeiten</button>
|
||||
@@ -246,87 +254,62 @@
|
||||
<button class="snav on" data-pane="pane-general">Allgemein</button>
|
||||
<button class="snav" data-pane="pane-appearance">Darstellung</button>
|
||||
<button class="snav" data-pane="pane-minecraft">Spiel</button>
|
||||
<button class="snav" data-pane="pane-start">Start & Java</button>
|
||||
<button class="snav" data-pane="pane-start">Java & Erweitert</button>
|
||||
<button class="snav" data-pane="pane-online">Freunde & Umhänge</button>
|
||||
<button class="snav" data-pane="pane-network">Netzwerk</button>
|
||||
<button class="snav" data-pane="pane-accounts">Konten</button>
|
||||
<button class="snav" data-pane="pane-backup">Backup</button>
|
||||
<button class="snav" data-pane="pane-info">Info</button>
|
||||
</nav>
|
||||
<div class="settings-content">
|
||||
<!-- Aufbau: jede Karte enthält Zeilen – links Name + kurze Erklärung, rechts das Bedienelement -->
|
||||
<div id="pane-general" class="spane">
|
||||
<div class="settings-hero">
|
||||
<h4>Allgemein</h4>
|
||||
<p>Globale Launcher-Optionen, Instanzverwaltung und sichtbare Bereiche.</p>
|
||||
<p>Wie sich der Launcher verhält und wo deine Instanzen liegen.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Instanzliste</h5>
|
||||
<label class="field">
|
||||
<span>Instanzen sortieren</span>
|
||||
<select id="s-sortby">
|
||||
<option value="lastPlayed">Nach letztem Start</option>
|
||||
<option value="name">Nach Name</option>
|
||||
</select>
|
||||
</label>
|
||||
<h5>Launcher</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Mit Windows starten</b><small>Startet AeroMC beim Anmelden im Infobereich neben der Uhr. Solange aktiv, schließt das X den Launcher nicht, sondern legt ihn dort ab.</small></div>
|
||||
<label class="schalter"><input id="s-autostart" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Windows-Benachrichtigungen</b><small>Meldet, wenn ein großer Download oder ein Backup im Hintergrund fertig ist – nur, solange der Launcher nicht im Vordergrund ist.</small></div>
|
||||
<label class="schalter"><input id="s-notify" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Bedrock-Bereich anzeigen</b><small id="s-bedrock-hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small></div>
|
||||
<label class="schalter"><input id="s-bedrock" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>Instanzen</h5>
|
||||
<label class="field">
|
||||
<span>Instanz-Verzeichnis</span>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Instanz-Verzeichnis</b><small>Jede Instanz liegt hier als Unterordner mit ihrem Namen.</small></div>
|
||||
<div class="path-row">
|
||||
<input id="s-instances-dir" type="text" readonly />
|
||||
<button id="s-pick-dir" class="btn">Wählen…</button>
|
||||
</div>
|
||||
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Bereiche</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-bedrock" type="checkbox" /> <span>Bedrock-Bereich anzeigen</span>
|
||||
</label>
|
||||
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Benachrichtigungen</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-notify" type="checkbox" /> <span>Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird</span>
|
||||
</label>
|
||||
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Freundesystem</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-friends" type="checkbox" /> <span>Freundesystem aktivieren</span>
|
||||
</label>
|
||||
<small class="hint">Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.</small>
|
||||
<label class="checkbox-field" style="margin-top:10px">
|
||||
<input id="s-friends-server" type="checkbox" /> <span>Server, auf dem ich spiele, für Freunde sichtbar</span>
|
||||
</label>
|
||||
<small class="hint">Ausgeschaltet sehen Freunde nur, dass du spielst – nicht wo. „Beitreten“ ist dann bei dir nicht möglich, Einladungen kannst du aber weiterhin verschicken.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Autostart</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-autostart" type="checkbox" /> <span>Mit Windows starten (im Infobereich)</span>
|
||||
</label>
|
||||
<small class="hint">Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden – über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.</small>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Sortierung</b><small>Reihenfolge der Instanzen in der Übersicht.</small></div>
|
||||
<select id="s-sortby" class="s-wert">
|
||||
<option value="lastPlayed">Nach letztem Start</option>
|
||||
<option value="name">Nach Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-appearance" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Darstellung</h4>
|
||||
<p>Optik der Oberfläche und visuelle Extras für die Instanzansicht.</p>
|
||||
<p>Design, Bewegung und der Skin im Hintergrund.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>Design</h5>
|
||||
<div class="field">
|
||||
<select id="s-theme" class="hidden">
|
||||
@@ -356,130 +339,132 @@
|
||||
</div>
|
||||
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Icons</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-iso3d" type="checkbox" /> <span>Block-Symbole räumlich darstellen (3D)</span>
|
||||
</label>
|
||||
<h5>Bewegung & Effekte</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Startanimation</b><small>Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf. Gilt ab dem nächsten Start.</small></div>
|
||||
<label class="schalter"><input id="s-start-animation" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Bewegte Oberfläche</b><small>Die Skin-Figur atmet und schaut zum Mauszeiger, Fenster wachsen aus dem Knopf, Designs blenden weich über.</small></div>
|
||||
<label class="schalter"><input id="s-ui-animationen" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Räumliche Block-Symbole</b><small>Zeigt die Block-Symbole der Instanzen in 3D.</small></div>
|
||||
<label class="schalter"><input id="s-iso3d" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Animationen</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-start-animation" type="checkbox" /> <span>Startanimation beim Öffnen des Launchers</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-ui-animationen" type="checkbox" /> <span>Bewegte Oberfläche</span>
|
||||
</label>
|
||||
<small class="hint">Startanimation: Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (ab dem nächsten Start, nicht im Infobereich). Bewegte Oberfläche: Die Skin-Figur atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden ein, Designs blenden weich über.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Skin-Hintergrund</h5>
|
||||
<label class="field">
|
||||
<span>Quelle <small class="opt">(optional)</small></span>
|
||||
<select id="s-bgskin"><option value="">Aus</option></select>
|
||||
<small class="hint">Zeigt den Minecraft-Skin des gewählten Kontos als Hintergrund im Instanzbereich.</small>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Pose</span>
|
||||
<select id="s-bgskin-pose">
|
||||
<option value="walking">Laufend</option>
|
||||
<option value="standing">Stehend</option>
|
||||
<option value="legends">Heldenhaft</option>
|
||||
<option value="bedwars">Kampfhaltung</option>
|
||||
<option value="hoverboard">Schwebend</option>
|
||||
<option value="sitting">Sitzend</option>
|
||||
<option value="spooky">Unheimlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Armform</span>
|
||||
<select id="s-bgskin-model" title="Bei „Automatisch“ wird die Armform übernommen, die beim Konto eingestellt ist">
|
||||
<option value="auto">Automatisch (vom Konto)</option>
|
||||
<option value="classic">Normal (breite Arme, Steve)</option>
|
||||
<option value="slim">Schmal (Alex)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Deckkraft: <span id="s-bgskin-val">55</span> %</span>
|
||||
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
|
||||
</label>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Quelle</b><small>Zeigt den Skin eines Kontos groß im Instanzbereich.</small></div>
|
||||
<select id="s-bgskin" class="s-wert"><option value="">Aus</option></select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Pose</b></div>
|
||||
<select id="s-bgskin-pose" class="s-wert">
|
||||
<option value="walking">Laufend</option>
|
||||
<option value="standing">Stehend</option>
|
||||
<option value="legends">Heldenhaft</option>
|
||||
<option value="bedwars">Kampfhaltung</option>
|
||||
<option value="hoverboard">Schwebend</option>
|
||||
<option value="sitting">Sitzend</option>
|
||||
<option value="spooky">Unheimlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Armform</b><small>„Automatisch“ übernimmt die Einstellung des Kontos.</small></div>
|
||||
<select id="s-bgskin-model" class="s-wert">
|
||||
<option value="auto">Automatisch (vom Konto)</option>
|
||||
<option value="classic">Normal (breite Arme, Steve)</option>
|
||||
<option value="slim">Schmal (Alex)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Deckkraft</b></div>
|
||||
<div class="s-wert s-regler"><input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" /><span><span id="s-bgskin-val">55</span> %</span></div>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Konten durchwechseln</b><small>Bei 2 oder mehr angemeldeten Konten wechselt der Skin der Reihe nach.</small></div>
|
||||
<label class="schalter"><input id="s-bgskin-cycle" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wechsel alle</b></div>
|
||||
<div class="s-wert s-einheit"><input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" /><span>Sekunden</span></div>
|
||||
</div>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-bgskin-cycle" type="checkbox" /> <span>Bei mehreren angemeldeten Konten automatisch durchwechseln</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Wechsel alle <small class="opt">(Sekunden)</small></span>
|
||||
<input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" />
|
||||
<small class="hint">Läuft nur, wenn 2 oder mehr Konten angemeldet sind, und beginnt nach dem letzten Konto wieder von vorn.</small>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-minecraft" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Spiel</h4>
|
||||
<p>Standardwerte für neue und bestehende Instanzen ohne eigene Überschreibung.</p>
|
||||
<p>Standardwerte für alle Instanzen ohne eigene Einstellung – und was beim Spielen passiert.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Speicher</h5>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Standard-RAM min. (MB)</span>
|
||||
<input id="s-min-mem" type="number" min="512" step="256" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Standard-RAM max. (MB)</span>
|
||||
<input id="s-max-mem" type="number" min="512" step="256" />
|
||||
</label>
|
||||
<h5>Leistung & Fenster</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Arbeitsspeicher</b><small>Mindestens / höchstens, in MB.</small></div>
|
||||
<div class="s-wert s-paar"><input id="s-min-mem" type="number" min="512" step="256" aria-label="RAM min. (MB)" /><span>–</span><input id="s-max-mem" type="number" min="512" step="256" aria-label="RAM max. (MB)" /></div>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Maximiert starten</b><small>Minecraft öffnet sich bildschirmfüllend.</small></div>
|
||||
<label class="schalter"><input id="s-mc-max" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Fenstergröße</b><small>Breite × Höhe, wenn nicht maximiert.</small></div>
|
||||
<div class="s-wert s-paar"><input id="s-mc-width" type="number" min="320" step="10" aria-label="Fensterbreite" /><span>×</span><input id="s-mc-height" type="number" min="240" step="10" aria-label="Fensterhöhe" /></div>
|
||||
</div>
|
||||
<small class="hint">Gilt für alle Instanzen ohne eigene RAM-Einstellung.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Fenster</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-mc-max" type="checkbox" /> <span>Minecraft maximiert starten</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Fensterbreite</span><input id="s-mc-width" type="number" min="320" step="10" /></label>
|
||||
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
|
||||
<h5>AeroMC-Look</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>AeroMC-Look im Spiel</b><small>Ladebildschirm und Hauptmenü im AeroMC-Look: Landschaftsbild, großes AeroMC-Logo, Knöpfe mit Symbolen und eigene Sprüche, dazu Fenstertitel und -symbol. Ab Minecraft 1.14, gilt ab dem nächsten Start – eigene Ressourcenpakete haben Vorrang.</small></div>
|
||||
<label class="schalter"><input id="s-aeromc-design" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Mods</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-auto-mod-updates" type="checkbox" /> <span>Vor jedem Start nach Mod-Updates suchen und installieren</span>
|
||||
</label>
|
||||
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
|
||||
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Mods im Spiel verwalten</b><small>Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“: Mods per Klick zwischen „Aktiv“ und „Deaktiviert“ verschieben. Das Spiel läuft weiter, die Änderung gilt ab dem nächsten Start (oder direkt mit „Übernehmen & neu starten“). Nur Instanzen mit Mod-Loader.</small></div>
|
||||
<label class="schalter"><input id="s-mod-schalter" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</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>
|
||||
<h5>Konsole</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Während des Spiels anzeigen</b></div>
|
||||
<label class="schalter"><input id="s-console-launch" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Nach dem Beenden schließen</b></div>
|
||||
<label class="schalter"><input id="s-console-autoclose" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Bei einem Absturz anzeigen</b></div>
|
||||
<label class="schalter"><input id="s-console-crash" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Zeilen merken</b><small>Ältere Zeilen fallen aus der Konsole heraus.</small></div>
|
||||
<input id="s-console-lines" class="s-wert s-zahl" type="number" min="1000" step="1000" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-start" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Start & Java</h4>
|
||||
<p>Runtime-Auswahl, Startverhalten und globale Befehle für alle Instanzen.</p>
|
||||
<h4>Java & Erweitert</h4>
|
||||
<p>Welches Java startet und was zusätzlich vor oder nach dem Spiel läuft.</p>
|
||||
</div>
|
||||
<div class="settings-grid settings-grid-stack">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Java</h5>
|
||||
<label class="field">
|
||||
<span>Java (java.exe)</span>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Java-Programm</b><small id="s-java-status">–</small></div>
|
||||
<div class="path-row">
|
||||
<input id="s-java" type="text" placeholder="leer = java aus PATH" />
|
||||
<button id="s-pick-java" class="btn">Wählen…</button>
|
||||
@@ -488,102 +473,100 @@
|
||||
<select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
|
||||
<button id="s-scan-java" class="btn">Erkennen</button>
|
||||
</div>
|
||||
<small id="s-java-status" class="hint">–</small>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-auto-java" type="checkbox" /> <span>Fehlende Java-Version automatisch herunterladen</span>
|
||||
</label>
|
||||
<small class="hint">Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.</small>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Fehlendes Java herunterladen</b><small>Holt beim Start die passende Version von Eclipse Temurin, wenn keine geeignete gefunden wird (rund 45 MB je Version).</small></div>
|
||||
<label class="schalter"><input id="s-auto-java" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Konsole</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-launch" type="checkbox" /> <span>Konsole anzeigen, während das Spiel läuft</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-autoclose" type="checkbox" /> <span>Konsole automatisch schließen, nachdem das Spiel beendet wurde</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-crash" type="checkbox" /> <span>Konsole anzeigen, wenn das Spiel abstürzt</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Historie-Limit (Zeilen)</span>
|
||||
<input id="s-console-lines" type="number" min="1000" step="1000" />
|
||||
</label>
|
||||
<h5>Befehle</h5>
|
||||
<small class="hint s-vorab">Für Fortgeschrittene. Befehle aus dem Bearbeiten-Dialog einer Instanz haben Vorrang.</small>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Vor dem Start</b></div>
|
||||
<input id="s-global-pre" class="s-wert s-breit" type="text" placeholder="läuft vor jedem Spielstart" />
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Nach dem Beenden</b></div>
|
||||
<input id="s-global-post" class="s-wert s-breit" type="text" placeholder="läuft nach jedem Beenden" />
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wrapper</b><small>Startet Java über dieses Programm.</small></div>
|
||||
<input id="s-wrapper" class="s-wert s-breit" type="text" placeholder="z. B. prime-run" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="pane-online" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Freunde & Umhänge</h4>
|
||||
<p>Was andere AeroMC-Spieler von dir sehen.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card">
|
||||
<h5>Globale Befehle</h5>
|
||||
<label class="field">
|
||||
<span>Pre-Launch-Befehl</span>
|
||||
<input id="s-global-pre" type="text" placeholder="läuft vor jedem Spielstart" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Post-Exit-Befehl</span>
|
||||
<input id="s-global-post" type="text" placeholder="läuft nach jedem Beenden" />
|
||||
</label>
|
||||
<small class="hint">Instanzspezifische Befehle aus dem Bearbeiten-Dialog haben Vorrang.</small>
|
||||
<h5>Freundesystem</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Freundesystem aktivieren</b><small>Aus: niemand kann dich finden oder hinzufügen. Bestehende Freunde bleiben, sehen dich aber als offline.</small></div>
|
||||
<label class="schalter"><input id="s-friends" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Server für Freunde sichtbar</b><small>Aus: Freunde sehen nur, dass du spielst, nicht wo – „Beitreten“ geht dann nicht, Einladungen schon.</small></div>
|
||||
<label class="schalter"><input id="s-friends-server" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Externe Programme</h5>
|
||||
<label class="field">
|
||||
<span>Wrapper-Befehl</span>
|
||||
<input id="s-wrapper" type="text" placeholder="z. B. prime-run (leer = kein Wrapper)" />
|
||||
</label>
|
||||
<small class="hint">Startet Java über dieses Programm. Java-Pfad und Argumente werden automatisch angehängt.</small>
|
||||
<section class="settings-card">
|
||||
<h5>Umhänge</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>AeroMC-Umhänge im Spiel</b><small>Zeigt deinen Umhang und den anderer AeroMC-Spieler – in jeder Version, mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small></div>
|
||||
<label class="schalter"><input id="s-aeromc-capes" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-network" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Netzwerk</h4>
|
||||
<p>Verbindungsoptionen für Downloads und externe Dienste.</p>
|
||||
<p>Proxy für Downloads und der Zugang zu CurseForge.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>Proxy</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-proxy-enabled" type="checkbox" /> <span>HTTP-Proxy für Downloads verwenden</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
|
||||
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>HTTP-Proxy verwenden</b><small>Nur nötig, wenn dein Netzwerk Downloads nur über einen Proxy erlaubt.</small></div>
|
||||
<label class="schalter"><input id="s-proxy-enabled" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
|
||||
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
||||
<div class="s-felder">
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
|
||||
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
|
||||
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<section class="settings-card">
|
||||
<h5>CurseForge</h5>
|
||||
<label class="field">
|
||||
<span>Eigener API-Key <small class="opt">(optional)</small></span>
|
||||
<small class="hint s-vorab">Nur für CurseForge-Modpacks und -Mods nötig. Modrinth funktioniert immer ohne.</small>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Eigener API-Key</b><small>Von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder ein Organisationsschlüssel. Nach dem Eintragen speichern.</small></div>
|
||||
<div class="path-row">
|
||||
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
|
||||
<button type="button" id="s-cf-test" class="btn">Prüfen</button>
|
||||
</div>
|
||||
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
|
||||
<small id="s-cf-test-status" class="hint"></small>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Weiterleitung <small class="opt">(optional)</small></span>
|
||||
<input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Token <small class="opt">(falls verlangt)</small></span>
|
||||
<input id="s-cf-proxy-token" type="password" />
|
||||
</label>
|
||||
</div>
|
||||
<small class="hint">Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.</small>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Weiterleitung</b><small>Ein Server, der den Schlüssel für dich hält.</small></div>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Adresse</span><input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" /></label>
|
||||
<label class="field"><span>Token <small class="opt">(falls verlangt)</small></span><input id="s-cf-proxy-token" type="password" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-accounts" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Konten</h4>
|
||||
@@ -604,66 +587,58 @@
|
||||
<div id="pane-backup" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Backup</h4>
|
||||
<p>Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.</p>
|
||||
<p>Sicherungen aller Instanzen und deiner Welten.</p>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Globales Backup</h5>
|
||||
<small class="hint">Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.</small>
|
||||
<div class="path-row" style="margin-top:14px">
|
||||
<section class="settings-card">
|
||||
<h5>Jetzt sichern</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Komplett-Backup</b><small>Alle Instanzen und Launcher-Einstellungen als ZIP unter Dokumente\AeroMC Launcher\Backups. Konten und Anmeldedaten werden nicht gesichert.</small></div>
|
||||
<button id="s-backup-global" class="btn btn-primary">Backup erstellen</button>
|
||||
<span id="s-backup-status" class="hint">Noch kein Backup erstellt.</span>
|
||||
</div>
|
||||
<small id="s-backup-status" class="hint s-status">Noch kein Backup erstellt.</small>
|
||||
<div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true">
|
||||
<div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div>
|
||||
<div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Automatisch sichern</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-autobackup" type="checkbox" /> <span>Sicherungen nach Zeitplan erstellen</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Wie oft</span>
|
||||
<select id="s-autobackup-every">
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Wie viele behalten</span>
|
||||
<input id="s-autobackup-keep" type="number" min="1" max="50" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-autobackup-launch" type="checkbox" /> <span>Zusätzlich vor jedem Spielstart sichern</span>
|
||||
</label>
|
||||
<small class="hint">Eine Sicherung umfasst <em>alle</em> Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.</small>
|
||||
<small id="s-autobackup-last" class="hint">Noch keine automatische Sicherung.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card">
|
||||
<h5>Welten</h5>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-auto-world" type="checkbox" /> <span>Nach jeder Spielsitzung die zuletzt gespielte Welt sichern</span>
|
||||
</label>
|
||||
<small class="hint">Läuft im Hintergrund, sobald Minecraft beendet ist. Pro Welt bleiben die letzten 5 Stände erhalten – unter Dokumente\AeroMC Launcher\Welten-Sicherungen\<Instanz>\Automatisch.</small>
|
||||
</section>
|
||||
|
||||
<section class="settings-card settings-card-wide">
|
||||
<h5>Wiederherstellen</h5>
|
||||
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small>
|
||||
<label class="field backup-select-field">
|
||||
<span>Verfügbares Backup</span>
|
||||
<select id="s-restore-backup"></select>
|
||||
</label>
|
||||
<div class="path-row" style="margin-top:14px">
|
||||
<button id="s-restore-global" class="btn">Backup wiederherstellen</button>
|
||||
<span id="s-restore-status" class="hint">Noch kein Backup wiederhergestellt.</span>
|
||||
<h5>Automatisch sichern</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Nach Zeitplan sichern</b><small id="s-autobackup-last">Noch keine automatische Sicherung.</small></div>
|
||||
<label class="schalter"><input id="s-autobackup" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wie oft</b></div>
|
||||
<select id="s-autobackup-every" class="s-wert">
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Wie viele behalten</b><small>Ältere automatische Sicherungen werden gelöscht.</small></div>
|
||||
<input id="s-autobackup-keep" class="s-wert s-zahl" type="number" min="1" max="50" />
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Zusätzlich vor jedem Start</b><small>Der Start wartet, bis die Sicherung fertig ist – bei vielen Instanzen kann das einige Minuten dauern.</small></div>
|
||||
<label class="schalter"><input id="s-autobackup-launch" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Welt nach dem Spielen sichern</b><small>Sichert nach jeder Sitzung die zuletzt gespielte Welt im Hintergrund. Je Welt bleiben die letzten 5 Stände (Dokumente\AeroMC Launcher\Welten-Sicherungen).</small></div>
|
||||
<label class="schalter"><input id="s-auto-world" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<h5>Wiederherstellen</h5>
|
||||
<small class="hint s-vorab s-warnung">Achtung: Alle vorhandenen Instanzen und die Launcher-Einstellungen werden mit dem Inhalt des Backups überschrieben. Konten bleiben unverändert.</small>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Verfügbares Backup</b></div>
|
||||
<div class="path-row">
|
||||
<select id="s-restore-backup"></select>
|
||||
<button id="s-restore-global" class="btn">Wiederherstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
<small id="s-restore-status" class="hint s-status">Noch kein Backup wiederhergestellt.</small>
|
||||
<div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true">
|
||||
<div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div>
|
||||
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
|
||||
@@ -671,7 +646,6 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-info" class="spane hidden">
|
||||
<div class="settings-hero">
|
||||
<h4>Info</h4>
|
||||
@@ -701,6 +675,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Mod-Schalter (über dem laufenden Spiel) ===================== -->
|
||||
<div id="modal-modschalter" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
<div class="modal-head">
|
||||
<h3>Mods verwalten <span id="ms-instanz" class="ms-instanz"></span></h3>
|
||||
<button class="modal-close" id="ms-x" title="Zurück ins Spiel">✕</button>
|
||||
</div>
|
||||
<div class="modal-body ms-body">
|
||||
<div class="ms-kopf">
|
||||
<input id="ms-suche" type="search" placeholder="Mods durchsuchen …" autocomplete="off" />
|
||||
<span id="ms-hinweis" class="hint"></span>
|
||||
</div>
|
||||
<div class="ms-spalten">
|
||||
<section class="ms-spalte">
|
||||
<div class="ms-titel">Aktive Mods <span id="ms-zahl-an" class="group-count"></span></div>
|
||||
<div id="ms-an" class="ms-liste" role="list"></div>
|
||||
</section>
|
||||
<section class="ms-spalte">
|
||||
<div class="ms-titel">Deaktivierte Mods <span id="ms-zahl-aus" class="group-count"></span></div>
|
||||
<div id="ms-aus" class="ms-liste" role="list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span id="ms-status" class="hint ms-status"></span>
|
||||
<button class="btn" id="ms-fertig">Zurück ins Spiel</button>
|
||||
<button class="btn btn-primary hidden" id="ms-neustart" title="Minecraft speichert, beendet sich und startet mit den neuen Mods direkt wieder – in derselben Welt bzw. auf demselben Server">Übernehmen & neu starten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Bestätigung ===================== -->
|
||||
<div id="modal-confirm" class="modal hidden">
|
||||
<div class="modal-card modal-sm">
|
||||
@@ -821,6 +826,7 @@
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="m-open-folder" class="btn" data-ic="folder">mods-Ordner</button>
|
||||
<button id="m-leistung" class="btn" title="Bewährte Performance-Mods passend zu Version und Loader installieren (Sodium, Lithium, FerriteCore …)">⚡ Leistungs-Paket</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
</div>
|
||||
@@ -1115,10 +1121,19 @@
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="stats-tiles" class="stats-tiles"></div>
|
||||
<div class="friends-section-title">Spielzeit pro Instanz</div>
|
||||
<div id="stats-list" class="stats-list" role="list"></div>
|
||||
<p id="stats-unplayed" class="hint"></p>
|
||||
<div class="mods-tabs stats-tabs">
|
||||
<button class="mtab on" data-stats-reiter="spielzeit">Spielzeit</button>
|
||||
<button class="mtab" data-stats-reiter="erfolge">Erfolge <span id="erfolge-zahl"></span></button>
|
||||
</div>
|
||||
<div id="stats-reiter-spielzeit">
|
||||
<div id="stats-tiles" class="stats-tiles"></div>
|
||||
<div class="friends-section-title">Spielzeit pro Instanz</div>
|
||||
<div id="stats-list" class="stats-list" role="list"></div>
|
||||
<p id="stats-unplayed" class="hint"></p>
|
||||
</div>
|
||||
<div id="stats-reiter-erfolge" class="hidden">
|
||||
<div id="erfolge-liste" class="erfolge-liste"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="spacer"></div>
|
||||
@@ -1136,13 +1151,20 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="friends-disabled-hint" class="hint hidden">Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.</p>
|
||||
<div id="friends-content">
|
||||
<div class="field-row">
|
||||
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
|
||||
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
|
||||
<div id="friends-content" class="freunde-raster">
|
||||
<div class="freunde-spalte">
|
||||
<div class="field-row">
|
||||
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
|
||||
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
|
||||
</div>
|
||||
<div id="friends-requests-box"></div>
|
||||
<div id="friends-list-box"></div>
|
||||
</div>
|
||||
<!-- 3D-Figur des ausgewählten Freundes (Skin + Umhang), neben der Liste -->
|
||||
<div id="freund-buehne" class="freund-buehne hidden">
|
||||
<canvas id="freund-figur"></canvas>
|
||||
<div class="freund-buehne-text"><b id="freund-buehne-name"></b><span id="freund-buehne-status"></span></div>
|
||||
</div>
|
||||
<div id="friends-requests-box"></div>
|
||||
<div id="friends-list-box"></div>
|
||||
</div>
|
||||
<!-- Unterhaltung mit einem Freund -->
|
||||
<div id="friends-chat" class="friends-chat hidden">
|
||||
@@ -1242,6 +1264,10 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="skin-konto" class="hint">–</p>
|
||||
<div id="skin-armform-hinweis" class="skin-armform-hinweis hidden">
|
||||
<span id="skin-armform-text"></span>
|
||||
<button id="skin-armform-fix" class="btn btn-mini btn-primary"></button>
|
||||
</div>
|
||||
<div class="skin-vorschau">
|
||||
<div class="skin-spalte">
|
||||
<div class="skin-label">Aktuell</div>
|
||||
@@ -1291,6 +1317,18 @@
|
||||
</div>
|
||||
<small id="skin-cape-status" class="hint">Den AeroMC-Umhang sehen im Spiel alle, die AeroMC nutzen oder den Mod „AeroMC Umhänge“ (Fabric/Quilt) installiert haben – dort hat er Vorrang vor dem offiziellen.</small>
|
||||
</div>
|
||||
|
||||
<div class="skin-garderobe">
|
||||
<div class="garderobe-kopf">
|
||||
<div class="skin-label">Garderobe <small class="opt">(gespeicherte Skins und Umhänge – mit einem Klick wieder anziehen)</small></div>
|
||||
<div class="garderobe-knoepfe">
|
||||
<button id="gard-skin-speichern" class="btn btn-mini" title="Den Skin, den du gerade trägst, in der Garderobe ablegen">Aktuellen Skin speichern</button>
|
||||
<button id="gard-umhang-speichern" class="btn btn-mini" title="Deinen eigenen AeroMC-Umhang in der Garderobe ablegen">Aktuellen Umhang speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="garderobe-liste" class="garderobe-liste"></div>
|
||||
<small class="hint">Vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch hier gesichert – Mojang selbst merkt sich keine alten Skins.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
|
||||
@@ -1851,6 +1889,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erfolg-Meldungen im Minecraft-Stil (oben rechts) -->
|
||||
<div id="erfolg-meldungen" class="erfolg-meldungen"></div>
|
||||
|
||||
<!-- Konto-Menü (Klick auf den Kontoknopf oben rechts) -->
|
||||
<div id="konto-menu" class="ctx konto-menu hidden" role="menu"></div>
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* integritaet.js – Manipulationsschutz für Instanzen
|
||||
* --------------------------------------------------
|
||||
* Für jede Instanz hält der Launcher ein "Siegel" fest:
|
||||
* - die Startdaten aus instance.json, mit denen sich Code ausführen ließe
|
||||
* (Java-Pfad, JVM-Argumente, Befehle vor dem Start / nach dem Beenden)
|
||||
* - den SHA-256 jeder Mod-Datei im mods-Ordner
|
||||
* Das Siegel ist mit einem geheimen Schlüssel unterschrieben (HMAC), der per
|
||||
* Windows-DPAPI verschlüsselt im Datenordner liegt.
|
||||
*
|
||||
* Als Manipulation gilt:
|
||||
* - eine bekannte Mod-Datei hat bei gleichem Namen einen anderen Inhalt
|
||||
* (so arbeitet Jar-infizierende Schadsoftware wie "Fractureiser")
|
||||
* - die Startdaten wurden außerhalb des Launchers geändert
|
||||
* - das Siegel selbst wurde verändert
|
||||
* Keine Manipulation: neue, gelöschte, aktivierte oder deaktivierte Mods – das
|
||||
* ist der normale Weg, Mods per Explorer hinzuzufügen oder zu entfernen.
|
||||
*
|
||||
* Eine manipulierte Instanz wird gesperrt: Sie bleibt erhalten, lässt sich aber
|
||||
* nicht mehr starten, kopieren, exportieren oder teilen. Der Spieler kann sie
|
||||
* aus einer sauberen Sicherung als neue Instanz wiederherstellen und muss die
|
||||
* gesperrte selbst löschen.
|
||||
*
|
||||
* Ehrlich gesagt: Ein Schadprogramm, das gezielt AeroMC angreift und unter
|
||||
* demselben Windows-Konto läuft, kann auch den DPAPI-Schlüssel benutzen. Gegen
|
||||
* allgemeine Jar-Infektionen, fremde Tools und Hände am Dateisystem hilft es.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ENC = 'enc:v1:';
|
||||
const MOD_ENDUNGEN = /\.(jar|zip|litemod)(\.disabled)?$/i;
|
||||
|
||||
let datenOrdner = null;
|
||||
let store = null;
|
||||
let schluessel = null;
|
||||
let zustand = { version: 1, instanzen: {} };
|
||||
// Pfad -> { groesse, zeit, hash }: nur für die schnelle Prüfung beim Launcher-Start
|
||||
let hashCache = new Map();
|
||||
|
||||
function zustandsDatei() { return path.join(datenOrdner, 'integritaet.json'); }
|
||||
function schluesselDatei() { return path.join(datenOrdner, 'integritaet.key'); }
|
||||
|
||||
function init(userData, tresor, storeModul) {
|
||||
datenOrdner = userData;
|
||||
store = storeModul;
|
||||
schluessel = ladeSchluessel(tresor);
|
||||
if (!schluessel) return;
|
||||
let roh = null;
|
||||
try { roh = JSON.parse(fs.readFileSync(zustandsDatei(), 'utf8')); } catch { /* erster Start oder Datei fehlt */ }
|
||||
zustand = { version: 1, instanzen: (roh && roh.instanzen && typeof roh.instanzen === 'object') ? roh.instanzen : {} };
|
||||
// Siegel und Sperre sind getrennt unterschrieben: Wer nur die Sperre aus der
|
||||
// Datei löscht, bekommt sie zurück – der gute Stand (für die Prüfung der
|
||||
// Sicherungen) bleibt dabei gültig. Wer am Siegel selbst dreht, verliert ihn.
|
||||
const jetzt = () => new Date().toISOString();
|
||||
for (const [id, e] of Object.entries(zustand.instanzen)) {
|
||||
if (!e || e.mac !== siegelMac(id, e)) {
|
||||
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||
felder: {}, dateien: {},
|
||||
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde verändert.'] },
|
||||
});
|
||||
} else if (e.sperrMac !== sperrMac(id, e.gesperrt)) {
|
||||
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||
felder: e.felder, dateien: e.dateien,
|
||||
gesperrt: (e.gesperrt && e.gesperrt.seit) ? e.gesperrt
|
||||
: { seit: jetzt(), gruende: ['Die Sperre dieser Instanz wurde von Hand aufgehoben.'] },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Liste aller versiegelten Instanzen: ein gelöschter Eintrag fällt auf
|
||||
if (roh && Array.isArray(roh.ids) && roh.idsMac === listenMac(roh.ids)) {
|
||||
for (const id of roh.ids) {
|
||||
if (!zustand.instanzen[id]) {
|
||||
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||
felder: {}, dateien: {},
|
||||
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde entfernt.'] },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
speichere();
|
||||
}
|
||||
|
||||
function ladeSchluessel(tresor) {
|
||||
const verfuegbar = (() => { try { return !!(tresor && tresor.available()); } catch { return false; } })();
|
||||
if (fs.existsSync(schluesselDatei())) {
|
||||
try {
|
||||
const inhalt = fs.readFileSync(schluesselDatei(), 'utf8').trim();
|
||||
const roh = inhalt.startsWith(ENC) ? tresor.decrypt(inhalt.slice(ENC.length)) : inhalt;
|
||||
const s = Buffer.from(roh, 'hex');
|
||||
if (s.length === 32) return s;
|
||||
throw new Error('falsche Länge');
|
||||
} catch (err) {
|
||||
// Vorhanden, aber nicht lesbar (z. B. Datenordner von einem anderen PC
|
||||
// kopiert). Nicht einfach neu anlegen – dann sähen ALLE Siegel verändert
|
||||
// aus und jede Instanz wäre gesperrt. Schutz bleibt dann aus.
|
||||
console.warn('Integritäts-Schlüssel nicht lesbar – Manipulationsschutz inaktiv:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const neu = crypto.randomBytes(32);
|
||||
const text = verfuegbar ? ENC + tresor.encrypt(neu.toString('hex')) : neu.toString('hex');
|
||||
fs.mkdirSync(datenOrdner, { recursive: true });
|
||||
fs.writeFileSync(schluesselDatei(), text);
|
||||
return neu;
|
||||
}
|
||||
|
||||
function speichere() {
|
||||
const tmp = zustandsDatei() + '.tmp';
|
||||
const ids = Object.keys(zustand.instanzen).sort();
|
||||
fs.writeFileSync(tmp, JSON.stringify(Object.assign({}, zustand, { ids, idsMac: listenMac(ids) }), null, 1));
|
||||
fs.renameSync(tmp, zustandsDatei());
|
||||
}
|
||||
|
||||
function sortiert(obj) {
|
||||
return Object.keys(obj || {}).sort().map((k) => [k, obj[k]]);
|
||||
}
|
||||
|
||||
function hmac(daten) {
|
||||
return crypto.createHmac('sha256', schluessel).update(JSON.stringify(daten)).digest('hex');
|
||||
}
|
||||
function siegelMac(id, e) { return hmac(['siegel', id, sortiert(e.felder), sortiert(e.dateien)]); }
|
||||
function sperrMac(id, gesperrt) { return hmac(['sperre', id, gesperrt || null]); }
|
||||
function listenMac(ids) { return hmac(['liste', ids]); }
|
||||
|
||||
function versiegeltEintrag(id, e) {
|
||||
const eintrag = { felder: e.felder, dateien: e.dateien, gesperrt: e.gesperrt || null, stand: new Date().toISOString() };
|
||||
eintrag.mac = siegelMac(id, eintrag);
|
||||
eintrag.sperrMac = sperrMac(id, eintrag.gesperrt);
|
||||
return eintrag;
|
||||
}
|
||||
|
||||
// Die sicherheitsrelevanten Startdaten einer Instanz
|
||||
function startDaten(inst) {
|
||||
const j = (inst && inst.java) || {};
|
||||
return {
|
||||
javaPfad: String(j.path || ''),
|
||||
jvmArgumente: String(j.extraArgs || ''),
|
||||
vorStart: String((inst && inst.preLaunchCommand) || ''),
|
||||
nachEnde: String((inst && inst.postExitCommand) || ''),
|
||||
};
|
||||
}
|
||||
|
||||
const FELD_NAMEN = {
|
||||
javaPfad: 'Java-Pfad', jvmArgumente: 'JVM-Argumente', vorStart: 'Befehl vor dem Start', nachEnde: 'Befehl nach dem Beenden',
|
||||
};
|
||||
|
||||
// Alle Mod-Dateien (auch Unterordner wie mods/1.12.2/). Schlüssel ohne
|
||||
// ".disabled" und in Kleinbuchstaben – Umschalten ist keine Veränderung.
|
||||
function modDateien(gameDir) {
|
||||
const basis = path.join(gameDir, 'mods');
|
||||
const ergebnis = {};
|
||||
const lauf = (ordner, tiefe) => {
|
||||
let eintraege;
|
||||
try { eintraege = fs.readdirSync(ordner, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of eintraege) {
|
||||
const voll = path.join(ordner, e.name);
|
||||
if (e.isDirectory()) { if (tiefe < 2) lauf(voll, tiefe + 1); continue; }
|
||||
if (!e.isFile() || !MOD_ENDUNGEN.test(e.name)) continue;
|
||||
const rel = path.relative(basis, voll).replace(/\\/g, '/');
|
||||
ergebnis[rel.replace(/\.disabled$/i, '').toLowerCase()] = voll;
|
||||
}
|
||||
};
|
||||
lauf(basis, 0);
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
function hashDatei(datei) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash('sha256');
|
||||
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
// schnell=true: unveränderte Größe+Zeit -> Hash aus dem Zwischenspeicher
|
||||
async function hashe(datei, schnell) {
|
||||
const st = fs.statSync(datei);
|
||||
const c = hashCache.get(datei);
|
||||
if (schnell && c && c.groesse === st.size && c.zeit === st.mtimeMs) return c.hash;
|
||||
const hash = await hashDatei(datei);
|
||||
hashCache.set(datei, { groesse: st.size, zeit: st.mtimeMs, hash });
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function aktuellerStand(id, schnell) {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return null;
|
||||
const dateien = {};
|
||||
for (const [schl, voll] of Object.entries(modDateien(store.gameDir(id)))) {
|
||||
try { dateien[schl] = await hashe(voll, schnell); } catch { /* gesperrt/weg -> beim nächsten Mal */ }
|
||||
}
|
||||
return { felder: startDaten(inst), dateien };
|
||||
}
|
||||
|
||||
function status(id) {
|
||||
const e = zustand.instanzen[id];
|
||||
return e && e.gesperrt ? { gesperrt: true, seit: e.gesperrt.seit, gruende: e.gesperrt.gruende } : { gesperrt: false };
|
||||
}
|
||||
|
||||
function istGesperrt(id) { return status(id).gesperrt; }
|
||||
|
||||
function sperre(id, gruende) {
|
||||
if (!schluessel) return;
|
||||
const e = zustand.instanzen[id] || { felder: {}, dateien: {} };
|
||||
// das letzte gute Siegel bleibt stehen – daran wird eine Sicherung gemessen
|
||||
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||
felder: e.felder, dateien: e.dateien,
|
||||
gesperrt: { seit: new Date().toISOString(), gruende },
|
||||
});
|
||||
speichere();
|
||||
}
|
||||
|
||||
/*
|
||||
* Prüft eine Instanz gegen ihr Siegel.
|
||||
* -> { status: 'ok' | 'neu' | 'gesperrt', gruende?, neuGesperrt? }
|
||||
* Ohne Befund werden neue und entfernte Mods ins Siegel übernommen.
|
||||
*/
|
||||
async function pruefe(id, { schnell = false } = {}) {
|
||||
if (!schluessel) return { status: 'ok' };
|
||||
const vorhanden = zustand.instanzen[id];
|
||||
if (vorhanden && vorhanden.gesperrt) return { status: 'gesperrt', gruende: vorhanden.gesperrt.gruende };
|
||||
const jetzt = await aktuellerStand(id, schnell);
|
||||
if (!jetzt) return { status: 'ok' };
|
||||
if (!vorhanden) {
|
||||
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||
speichere();
|
||||
return { status: 'neu' };
|
||||
}
|
||||
const gruende = [];
|
||||
for (const [k, name] of Object.entries(FELD_NAMEN)) {
|
||||
if ((vorhanden.felder[k] || '') !== jetzt.felder[k]) gruende.push(`${name} wurde außerhalb des Launchers geändert.`);
|
||||
}
|
||||
for (const [datei, hash] of Object.entries(jetzt.dateien)) {
|
||||
if (vorhanden.dateien[datei] && vorhanden.dateien[datei] !== hash) gruende.push(`Mod-Datei verändert: ${datei}`);
|
||||
}
|
||||
if (gruende.length) {
|
||||
sperre(id, gruende);
|
||||
return { status: 'gesperrt', gruende, neuGesperrt: true };
|
||||
}
|
||||
const geaendert = JSON.stringify(sortiert(vorhanden.dateien)) !== JSON.stringify(sortiert(jetzt.dateien));
|
||||
if (geaendert) {
|
||||
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||
speichere();
|
||||
}
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
// Nach einer Änderung durch den Launcher selbst (Mod-Update, Bearbeiten …)
|
||||
async function versiegele(id) {
|
||||
if (!schluessel) return;
|
||||
if (istGesperrt(id)) return;
|
||||
const jetzt = await aktuellerStand(id, false);
|
||||
if (!jetzt) return;
|
||||
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||
speichere();
|
||||
}
|
||||
|
||||
function entferne(id) {
|
||||
if (!schluessel) return;
|
||||
if (!zustand.instanzen[id]) return;
|
||||
delete zustand.instanzen[id];
|
||||
speichere();
|
||||
}
|
||||
|
||||
function alleGesperrten() {
|
||||
const r = {};
|
||||
for (const [id, e] of Object.entries(zustand.instanzen)) if (e.gesperrt) r[id] = { seit: e.gesperrt.seit, gruende: e.gesperrt.gruende };
|
||||
return r;
|
||||
}
|
||||
|
||||
// Das letzte gute Siegel (für die Prüfung einer Sicherung)
|
||||
function gutesSiegel(id) {
|
||||
const e = zustand.instanzen[id];
|
||||
return e ? { felder: e.felder, dateien: e.dateien } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wiederherstellen aus einer Sicherung (AeroMC-Backup-*.zip, siehe backup.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const unterPfad = (p) => String(p || '').replace(/\\/g, '/');
|
||||
|
||||
/*
|
||||
* Neueste Sicherung, in der die Instanz steckt UND deren Mods zum letzten
|
||||
* guten Siegel passen. Eine Sicherung, die erst nach der Manipulation entstand,
|
||||
* enthält die veränderte Datei und wird übersprungen.
|
||||
* backupPfade: neueste zuerst
|
||||
*/
|
||||
async function sucheSaubereSicherung(id, backupPfade) {
|
||||
const unzipper = require('unzipper');
|
||||
const gut = gutesSiegel(id);
|
||||
const praefix = `instances/${id}/`;
|
||||
const uebersprungen = [];
|
||||
for (const zip of backupPfade) {
|
||||
let verzeichnis;
|
||||
try { verzeichnis = await unzipper.Open.file(zip); } catch { continue; }
|
||||
const eintraege = verzeichnis.files.filter((f) => f.type === 'File' && unterPfad(f.path).startsWith(praefix));
|
||||
if (!eintraege.some((f) => unterPfad(f.path) === praefix + 'instance.json')) continue;
|
||||
let sauber = true;
|
||||
for (const f of eintraege) {
|
||||
const m = unterPfad(f.path).slice(praefix.length).match(/^minecraft\/mods\/(.+)$/i);
|
||||
if (!m || !MOD_ENDUNGEN.test(m[1])) continue;
|
||||
const schl = m[1].replace(/\.disabled$/i, '').toLowerCase();
|
||||
if (!gut || !gut.dateien[schl]) continue;
|
||||
const hash = crypto.createHash('sha256').update(await f.buffer()).digest('hex');
|
||||
if (hash !== gut.dateien[schl]) { sauber = false; break; }
|
||||
}
|
||||
if (sauber) return { zip, eintraege, praefix, uebersprungen };
|
||||
uebersprungen.push(path.basename(zip));
|
||||
}
|
||||
return { zip: null, uebersprungen };
|
||||
}
|
||||
|
||||
/*
|
||||
* Stellt die Instanz als NEUE Instanz "<Name> (wiederhergestellt)" wieder her.
|
||||
* Die gesperrte bleibt unangetastet liegen.
|
||||
*/
|
||||
async function stelleWiederHer(id, backupPfade) {
|
||||
const { pipeline } = require('stream/promises');
|
||||
const fund = await sucheSaubereSicherung(id, backupPfade);
|
||||
if (!fund.zip) {
|
||||
throw new Error(fund.uebersprungen.length
|
||||
? `Keine saubere Sicherung gefunden – ${fund.uebersprungen.length} Sicherung(en) enthalten bereits die veränderten Dateien.`
|
||||
: 'Keine Sicherung mit dieser Instanz gefunden. Sicherungen lassen sich unter Optionen → Sicherung anlegen.');
|
||||
}
|
||||
const alt = store.getInstance(id);
|
||||
const name = `${(alt && alt.name) || id} (wiederhergestellt)`;
|
||||
const neuId = store.uniqueFolderId(name);
|
||||
const ziel = store.instancePath(neuId);
|
||||
fs.mkdirSync(ziel, { recursive: true });
|
||||
for (const f of fund.eintraege) {
|
||||
const rel = unterPfad(f.path).slice(fund.praefix.length);
|
||||
const dest = path.join(ziel, ...rel.split('/'));
|
||||
if (!dest.startsWith(ziel + path.sep)) continue; // nichts außerhalb des Ordners
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
await pipeline(f.stream(), fs.createWriteStream(dest));
|
||||
}
|
||||
const datei = path.join(ziel, 'instance.json');
|
||||
const inst = JSON.parse(fs.readFileSync(datei, 'utf8'));
|
||||
inst.id = neuId;
|
||||
inst.name = name;
|
||||
// Startdaten auf den letzten geprüften Stand setzen
|
||||
const gut = gutesSiegel(id);
|
||||
if (gut && gut.felder && Object.keys(gut.felder).length) {
|
||||
inst.java = Object.assign({}, inst.java, { path: gut.felder.javaPfad || '', extraArgs: gut.felder.jvmArgumente || '' });
|
||||
inst.preLaunchCommand = gut.felder.vorStart || '';
|
||||
inst.postExitCommand = gut.felder.nachEnde || '';
|
||||
}
|
||||
fs.writeFileSync(datei, JSON.stringify(inst, null, 2));
|
||||
await versiegele(neuId);
|
||||
return { id: neuId, name, sicherung: path.basename(fund.zip), uebersprungen: fund.uebersprungen };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init, pruefe, versiegele, sperre, status, istGesperrt, entferne, alleGesperrten, gutesSiegel,
|
||||
sucheSaubereSicherung, stelleWiederHer,
|
||||
startDaten, modDateien, hashDatei, FELD_NAMEN,
|
||||
};
|
||||
@@ -13,6 +13,14 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const unzipper = require('unzipper');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function sha256Datei(datei) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash('sha256');
|
||||
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
const API = 'https://api.adoptium.net/v3';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
@@ -193,6 +201,11 @@ async function ensureJava(major, onProgress) {
|
||||
try {
|
||||
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
|
||||
await ladeDatei(release.url, tmpZip, onProgress);
|
||||
// Adoptium nennt zu jedem Paket die SHA-256 – ohne passende Prüfsumme wird
|
||||
// keine Java-Laufzeit entpackt (sie würde später jedes Spiel ausführen)
|
||||
const soll = String(release.sha256 || '').toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(soll)) throw new Error('Adoptium hat keine Prüfsumme für dieses Java-Paket geliefert – Download verworfen.');
|
||||
if (await sha256Datei(tmpZip) !== soll) throw new Error('Prüfsumme des Java-Downloads stimmt nicht – Datei verworfen (beschädigt oder manipuliert).');
|
||||
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
|
||||
// halbfertiges Java, falls unterwegs etwas schiefgeht
|
||||
const tmpDir = ziel + '.teil';
|
||||
|
||||
@@ -52,11 +52,20 @@ async function downloadIfNeeded(url, dest, sha1) {
|
||||
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
|
||||
}
|
||||
ensureDir(path.dirname(dest));
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
fs.writeFileSync(dest, buf);
|
||||
return { skipped: false, bytes: buf.length };
|
||||
// Mojang (und die Loader) nennen zu jeder Datei die SHA-1 – geschrieben wird
|
||||
// nur, was dazu passt. Ein Fehlversuch wird einmal wiederholt (abgebrochene
|
||||
// Übertragung), dann gilt die Datei als beschädigt oder manipuliert.
|
||||
for (let versuch = 1; ; versuch++) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (sha1 && crypto.createHash('sha1').update(buf).digest('hex') !== String(sha1).toLowerCase()) {
|
||||
if (versuch < 2) continue;
|
||||
throw new Error('Prüfsumme stimmt nicht – Datei verworfen (beschädigt oder manipuliert): ' + path.basename(dest));
|
||||
}
|
||||
fs.writeFileSync(dest, buf);
|
||||
return { skipped: false, bytes: buf.length };
|
||||
}
|
||||
}
|
||||
|
||||
// begrenzte Parallelität für viele kleine Downloads (Assets)
|
||||
@@ -254,8 +263,7 @@ async function prepareAssets(vj, sharedDir, onProgress) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickJava(vj, preferredPath) {
|
||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
||||
let preferred = null;
|
||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; let preferred = null;
|
||||
if (preferredPath && preferredPath.trim()) {
|
||||
const info = services.probeJava(preferredPath);
|
||||
if (info) {
|
||||
@@ -301,10 +309,18 @@ function collectArgs(argList, map) {
|
||||
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
|
||||
function mergeLoader(vj, lp) {
|
||||
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
|
||||
merged.arguments = {
|
||||
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
|
||||
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
|
||||
};
|
||||
if (vj.arguments || lp.arguments) {
|
||||
merged.arguments = {
|
||||
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
|
||||
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
|
||||
};
|
||||
} else if (lp.minecraftArguments) {
|
||||
// altes Format (< 1.13, z. B. Forge 1.12.2): Die Loader-Fassung enthält die
|
||||
// kompletten Spielargumente samt --tweakClass. Früher entstand hier eine
|
||||
// leere "arguments"-Liste – dann fehlten Klassenpfad (-cp) und Natives im
|
||||
// Startbefehl, und Java fand net.minecraft.launchwrapper.Launch nicht.
|
||||
merged.minecraftArguments = lp.minecraftArguments;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -538,6 +554,10 @@ async function prepareAndLaunch(opts, onProgress) {
|
||||
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
||||
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
|
||||
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
|
||||
// Direkt in eine Einzelspieler-Welt (ab 1.20, z. B. nach einem Neustart über den Mod-Schalter)
|
||||
if (!opts.quickPlayServer && opts.quickPlayWelt && !vorQuickPlay(opts.versionId)) {
|
||||
args.push('--quickPlaySingleplayer', String(opts.quickPlayWelt));
|
||||
}
|
||||
if (opts.quickPlayServer) {
|
||||
const adresse = String(opts.quickPlayServer).trim();
|
||||
if (vorQuickPlay(opts.versionId)) {
|
||||
@@ -551,6 +571,14 @@ async function prepareAndLaunch(opts, onProgress) {
|
||||
|
||||
ensureDir(opts.gameDir);
|
||||
|
||||
// AeroMC-Look im Spiel (Ressourcenpaket, siehe spieldesign.js) – braucht das
|
||||
// fertig geladene Client-JAR, aus dem die Original-Knöpfe umgefärbt werden
|
||||
if (opts.vorStart) {
|
||||
try {
|
||||
await opts.vorStart({ gameDir: opts.gameDir, version: opts.versionId, clientJar }, (line) => emit('log', { line: line + '\n' }));
|
||||
} catch { /* darf den Start nie verhindern */ }
|
||||
}
|
||||
|
||||
// Pre-Launch-Befehl (bricht bei Fehler ab)
|
||||
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
|
||||
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
|
||||
|
||||
@@ -1,103 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* loaders.js – Mod-Loader-Profile auflösen
|
||||
* ----------------------------------------
|
||||
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
|
||||
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
|
||||
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
|
||||
*
|
||||
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
|
||||
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
|
||||
* daher hier noch als „in Arbeit" gekennzeichnet.
|
||||
*/
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.5';
|
||||
|
||||
const META = {
|
||||
fabric: 'https://meta.fabricmc.net/v2',
|
||||
quilt: 'https://meta.quiltmc.org/v3',
|
||||
};
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
|
||||
const NEOFORGE_META = 'https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml';
|
||||
|
||||
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
|
||||
function neoforgePrefix(mcVersion) {
|
||||
const p = mcVersion.split('.');
|
||||
return `${p[1]}.${p[2] || '0'}.`;
|
||||
}
|
||||
|
||||
// "group:artifact:version[:classifier]" -> { relPath, url }
|
||||
function mavenToUrl(base, name) {
|
||||
const parts = name.split(':');
|
||||
const group = parts[0].replace(/\./g, '/');
|
||||
const artifact = parts[1];
|
||||
const version = parts[2];
|
||||
const classifier = parts[3] ? '-' + parts[3] : '';
|
||||
const file = `${artifact}-${version}${classifier}.jar`;
|
||||
const relPath = `${group}/${artifact}/${version}/${file}`;
|
||||
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
|
||||
return { relPath, url: `${root}/${relPath}` };
|
||||
}
|
||||
|
||||
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
|
||||
async function listLoaderVersions(loader, mcVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
|
||||
}
|
||||
if (loader === 'forge') {
|
||||
const xml = await fetchText(FORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = mcVersion + '-';
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).reverse();
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
|
||||
}
|
||||
if (loader === 'neoforge') {
|
||||
const xml = await fetchText(NEOFORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = neoforgePrefix(mcVersion);
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).reverse();
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// löst das Profil (mainClass, libraries, arguments) auf
|
||||
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
let lv = loaderVersion;
|
||||
if (!lv) {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
|
||||
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
|
||||
lv = stable.loader.version;
|
||||
}
|
||||
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
|
||||
return { profile, loaderVersion: lv };
|
||||
}
|
||||
if (loader === 'forge' || loader === 'neoforge') {
|
||||
const e = new Error(
|
||||
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
|
||||
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
|
||||
);
|
||||
e.notImplemented = true;
|
||||
throw e;
|
||||
}
|
||||
throw new Error('Unbekannter Loader: ' + loader);
|
||||
}
|
||||
|
||||
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* loaders.js – Mod-Loader-Profile auflösen
|
||||
* ----------------------------------------
|
||||
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
|
||||
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
|
||||
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
|
||||
*
|
||||
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
|
||||
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
|
||||
* daher hier noch als „in Arbeit" gekennzeichnet.
|
||||
*/
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.5';
|
||||
|
||||
const META = {
|
||||
fabric: 'https://meta.fabricmc.net/v2',
|
||||
quilt: 'https://meta.quiltmc.org/v3',
|
||||
};
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
|
||||
const NEOFORGE_META = 'https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml';
|
||||
|
||||
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
|
||||
function neoforgePrefix(mcVersion) {
|
||||
const p = mcVersion.split('.');
|
||||
return `${p[1]}.${p[2] || '0'}.`;
|
||||
}
|
||||
|
||||
// "group:artifact:version[:classifier]" -> { relPath, url }
|
||||
function mavenToUrl(base, name) {
|
||||
const parts = name.split(':');
|
||||
const group = parts[0].replace(/\./g, '/');
|
||||
const artifact = parts[1];
|
||||
const version = parts[2];
|
||||
const classifier = parts[3] ? '-' + parts[3] : '';
|
||||
const file = `${artifact}-${version}${classifier}.jar`;
|
||||
const relPath = `${group}/${artifact}/${version}/${file}`;
|
||||
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
|
||||
return { relPath, url: `${root}/${relPath}` };
|
||||
}
|
||||
|
||||
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
|
||||
// Versionsnummern zahlenweise vergleichen ("36.2.39" > "36.2.4"), neueste zuerst.
|
||||
// Die Maven-Listen von Forge/NeoForge sind nicht verlässlich sortiert – früher
|
||||
// landete bei Forge 1.16.5 dadurch die ÄLTESTE Version (36.0.0) vorn.
|
||||
function neuesteZuerst(a, b) {
|
||||
const ta = String(a).split(/[.\-+]/), tb = String(b).split(/[.\-+]/);
|
||||
for (let i = 0; i < Math.max(ta.length, tb.length); i++) {
|
||||
const x = ta[i] || '0', y = tb[i] || '0';
|
||||
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
||||
if (nx && ny && Number(x) !== Number(y)) return Number(y) - Number(x);
|
||||
if (!(nx && ny) && x !== y) return nx ? -1 : ny ? 1 : y.localeCompare(x); // reine Zahl vor "beta"
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function listLoaderVersions(loader, mcVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
|
||||
}
|
||||
if (loader === 'forge') {
|
||||
const xml = await fetchText(FORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = mcVersion + '-';
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).sort(neuesteZuerst);
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
|
||||
}
|
||||
if (loader === 'neoforge') {
|
||||
const xml = await fetchText(NEOFORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = neoforgePrefix(mcVersion);
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).sort(neuesteZuerst);
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// löst das Profil (mainClass, libraries, arguments) auf
|
||||
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
let lv = loaderVersion;
|
||||
if (!lv) {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
|
||||
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
|
||||
lv = stable.loader.version;
|
||||
}
|
||||
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
|
||||
return { profile, loaderVersion: lv };
|
||||
}
|
||||
if (loader === 'forge' || loader === 'neoforge') {
|
||||
const e = new Error(
|
||||
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
|
||||
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
|
||||
);
|
||||
e.notImplemented = true;
|
||||
throw e;
|
||||
}
|
||||
throw new Error('Unbekannter Loader: ' + loader);
|
||||
}
|
||||
|
||||
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Erkennt Mods, die nicht über AeroMC installiert wurden (von Hand kopiert,
|
||||
* aus MultiMC/CurseForge/Modrinth App übernommen, aus einem Modpack …).
|
||||
*
|
||||
* Solche Dateien haben keine Projekt-Zuordnung, deshalb zeigte der Mod-Katalog
|
||||
* z. B. bei Fabric API „Installieren“, obwohl sie längst im mods-Ordner lag.
|
||||
* Hier wird je Datei die SHA-1-Prüfsumme gebildet und gesammelt bei Modrinth
|
||||
* nachgeschlagen (POST /v2/version_files). Ergebnisse werden zwischengespeichert
|
||||
* (Pfad + Größe + Änderungszeit), damit nicht bei jedem Öffnen neu gerechnet wird.
|
||||
* Ohne Netz bleibt einfach alles wie bisher.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const API = 'https://api.modrinth.com/v2';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
const NICHT_GEFUNDEN_NEU_PRUEFEN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let cacheDatei = null;
|
||||
let cache = null;
|
||||
|
||||
function init(userDataDir) {
|
||||
cacheDatei = path.join(userDataDir, 'mod-erkennung.json');
|
||||
}
|
||||
|
||||
function ladeCache() {
|
||||
if (cache) return cache;
|
||||
try { cache = JSON.parse(fs.readFileSync(cacheDatei, 'utf8')); } catch { cache = {}; }
|
||||
return cache;
|
||||
}
|
||||
function speichereCache() {
|
||||
try { fs.writeFileSync(cacheDatei, JSON.stringify(cache)); } catch { /* nur Zwischenspeicher */ }
|
||||
}
|
||||
|
||||
function sha1(datei) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash('sha1');
|
||||
fs.createReadStream(datei).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function holeJson(url, optionen) {
|
||||
const res = await fetch(url, Object.assign({ headers: { 'User-Agent': UA, 'Content-Type': 'application/json', Accept: 'application/json' } }, optionen));
|
||||
if (!res.ok) throw new Error('Modrinth ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergänzt Einträge ohne projectId um projectId, Titel und Versionsnummer, sofern
|
||||
* Modrinth die Datei kennt. `dir` ist der Ordner, in dem die Dateien liegen.
|
||||
*/
|
||||
async function ergaenze(eintraege, dir) {
|
||||
const offen = eintraege.filter((e) => e && !e.projectId && e.filename && !e.fehlt);
|
||||
if (!offen.length || !cacheDatei) return eintraege;
|
||||
const c = ladeCache();
|
||||
const jetzt = Date.now();
|
||||
const zuFragen = new Map(); // sha1 -> [schlüssel]
|
||||
const schluesselVon = new Map();
|
||||
|
||||
for (const e of offen) {
|
||||
const voll = path.join(dir, e.enabled === false ? e.filename + '.disabled' : e.filename);
|
||||
let st;
|
||||
try { st = fs.statSync(voll); } catch { continue; }
|
||||
const schluessel = `${voll}|${st.size}|${Math.round(st.mtimeMs)}`;
|
||||
schluesselVon.set(e, schluessel);
|
||||
const bekannt = c[schluessel];
|
||||
if (bekannt && (bekannt.projectId || jetzt - bekannt.geprueft < NICHT_GEFUNDEN_NEU_PRUEFEN_MS)) continue;
|
||||
try {
|
||||
const hash = (bekannt && bekannt.sha1) || await sha1(voll);
|
||||
c[schluessel] = { sha1: hash, projectId: null, geprueft: 0 };
|
||||
if (!zuFragen.has(hash)) zuFragen.set(hash, []);
|
||||
zuFragen.get(hash).push(schluessel);
|
||||
} catch { /* Datei gerade gesperrt – beim nächsten Mal */ }
|
||||
}
|
||||
|
||||
if (zuFragen.size) {
|
||||
try {
|
||||
const versionen = await holeJson(`${API}/version_files`, {
|
||||
method: 'POST', body: JSON.stringify({ hashes: [...zuFragen.keys()], algorithm: 'sha1' }),
|
||||
});
|
||||
const projektIds = [...new Set(Object.values(versionen).map((v) => v.project_id))];
|
||||
const titel = {};
|
||||
for (let i = 0; i < projektIds.length; i += 100) {
|
||||
const teil = projektIds.slice(i, i + 100);
|
||||
const projekte = await holeJson(`${API}/projects?ids=${encodeURIComponent(JSON.stringify(teil))}`);
|
||||
for (const p of projekte) titel[p.id] = p.title;
|
||||
}
|
||||
for (const [hash, schluessel] of zuFragen) {
|
||||
const v = versionen[hash];
|
||||
for (const s of schluessel) {
|
||||
c[s] = v
|
||||
? { sha1: hash, projectId: v.project_id, versionId: v.id, versionNumber: v.version_number, title: titel[v.project_id] || null, geprueft: jetzt }
|
||||
: { sha1: hash, projectId: null, geprueft: jetzt };
|
||||
}
|
||||
}
|
||||
speichereCache();
|
||||
} catch {
|
||||
return eintraege.map((e) => anwenden(e, c, schluesselVon));
|
||||
}
|
||||
}
|
||||
return eintraege.map((e) => anwenden(e, c, schluesselVon));
|
||||
}
|
||||
|
||||
function anwenden(e, c, schluesselVon) {
|
||||
const s = schluesselVon.get(e);
|
||||
const t = s && c[s];
|
||||
if (!t || !t.projectId) return e;
|
||||
return Object.assign({}, e, {
|
||||
projectId: t.projectId,
|
||||
title: t.title || e.title,
|
||||
versionNumber: e.versionNumber === 'Importiert' && t.versionNumber ? t.versionNumber : e.versionNumber,
|
||||
erkannt: true, // per Prüfsumme bei Modrinth erkannt, nicht über AeroMC installiert
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, ergaenze };
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* modschalter.js – Mods im laufenden Spiel an- und abschalten
|
||||
* ------------------------------------------------------------
|
||||
* Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“ (siehe
|
||||
* agent/ModsUmbau.java). Ein Klick ruft eine Adresse dieses kleinen, nur lokal
|
||||
* erreichbaren Empfängers auf; der Launcher öffnet daraufhin das Mod-Fenster
|
||||
* über dem Spiel.
|
||||
*
|
||||
* Solange Minecraft läuft, sind die Mod-Dateien gesperrt und geladene Mods
|
||||
* lassen sich ohnehin nicht entladen. Änderungen werden deshalb vorgemerkt
|
||||
* (instance.json -> modsAusstehend) und beim Beenden umgesetzt – das Spiel
|
||||
* läuft weiter, beim nächsten Start gilt der neue Stand.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let server = null;
|
||||
let port = 0;
|
||||
const sitzungen = new Map(); // token -> { instanceId, pid }
|
||||
let beimKlick = null; // (sitzung) => void
|
||||
|
||||
function starteServer() {
|
||||
if (server) return Promise.resolve(port);
|
||||
return new Promise((resolve, reject) => {
|
||||
server = http.createServer((req, res) => {
|
||||
const m = /^\/mods\/([a-f0-9]{32})$/.exec(req.url || '');
|
||||
const s = m && sitzungen.get(m[1]);
|
||||
if (!s) { res.writeHead(404); res.end(); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok');
|
||||
if (beimKlick) { try { beimKlick(s); } catch (err) { console.error('Mod-Schalter:', err); } }
|
||||
});
|
||||
server.on('error', (err) => { server = null; reject(err); });
|
||||
server.listen(0, '127.0.0.1', () => { port = server.address().port; resolve(port); });
|
||||
});
|
||||
}
|
||||
|
||||
/** Neue Sitzung für einen Spielstart -> { token, adresse } (für das Agent-Argument) */
|
||||
async function neueSitzung(instanceId) {
|
||||
await starteServer();
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
sitzungen.set(token, { instanceId, pid: null, token });
|
||||
return { token, adresse: `http://127.0.0.1:${port}/mods/${token}` };
|
||||
}
|
||||
|
||||
function setzePid(token, pid) {
|
||||
const s = sitzungen.get(token);
|
||||
if (s) s.pid = pid;
|
||||
}
|
||||
|
||||
function beende(token) {
|
||||
sitzungen.delete(token);
|
||||
}
|
||||
|
||||
function sitzungFuer(instanceId) {
|
||||
for (const s of sitzungen.values()) if (s.instanceId === instanceId && s.pid) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
function beiKlick(fn) { beimKlick = fn; }
|
||||
|
||||
module.exports = { neueSitzung, setzePid, beende, sitzungFuer, beiKlick };
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self'" />
|
||||
<title>AeroMC</title>
|
||||
<!--
|
||||
Neustart-Bildschirm (Mod-Schalter): liegt über dem Minecraft-Fenster, während
|
||||
das Spiel beendet, die Mods umgestellt und Minecraft wieder gestartet wird –
|
||||
bis man wieder auf dem Server bzw. in der Welt ist. Gleiches Bild wie der
|
||||
Ladebildschirm im Spiel; Balken und Text an derselben Stelle (83,25 % der Höhe).
|
||||
Der Launcher steuert die Seite über setze({ text, prozent }) und ausblenden().
|
||||
-->
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0e1a; cursor: default; user-select: none; }
|
||||
#bild { position: fixed; inset: 0; background: #0a0e1a url('assets/spieldesign/neustart.jpg') center / cover no-repeat; }
|
||||
#ganz { position: fixed; inset: 0; opacity: 0; transition: opacity .35s ease; }
|
||||
#ganz.sichtbar { opacity: 1; }
|
||||
#balken {
|
||||
position: absolute; left: 22%; right: 22%; top: 83.25%; height: max(8px, 1.15vh); transform: translateY(-50%);
|
||||
border: max(1px, .18vh) solid #4fd2ff; padding: max(1px, .18vh); box-sizing: content-box; overflow: hidden;
|
||||
}
|
||||
#fuellung { height: 100%; width: 0; background: #4fd2ff; transition: width .4s ease; }
|
||||
#balken.unbestimmt #fuellung { width: 30%; animation: wandern 1.3s ease-in-out infinite; }
|
||||
@keyframes wandern { 0% { transform: translateX(-110%); } 100% { transform: translateX(370%); } }
|
||||
#text {
|
||||
position: absolute; left: 50%; top: 88.5%; transform: translateX(-50%);
|
||||
padding: .55vh 1.6vh .7vh; border-radius: .9vh; background: rgba(6, 12, 28, .7);
|
||||
color: #ecf4ff; font: 600 max(13px, 3.1vh) 'Segoe UI', sans-serif; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ganz">
|
||||
<div id="bild"></div>
|
||||
<div id="balken" class="unbestimmt"><div id="fuellung"></div></div>
|
||||
<div id="text">Mods werden übernommen …</div>
|
||||
</div>
|
||||
<script>
|
||||
function setze(stand) {
|
||||
if (stand.text) document.getElementById('text').textContent = stand.text;
|
||||
const balken = document.getElementById('balken');
|
||||
const hatProzent = typeof stand.prozent === 'number';
|
||||
balken.classList.toggle('unbestimmt', !hatProzent);
|
||||
document.getElementById('fuellung').style.width = hatProzent ? Math.max(2, Math.min(100, stand.prozent)) + '%' : '';
|
||||
}
|
||||
function ausblenden() { document.getElementById('ganz').classList.remove('sichtbar'); }
|
||||
requestAnimationFrame(() => document.getElementById('ganz').classList.add('sichtbar'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,418 +1,455 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* preload.js – stellt dem Renderer eine schmale, sichere API bereit.
|
||||
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
||||
// damit man sie auch ohne offene DevTools sieht
|
||||
window.addEventListener('error', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
|
||||
});
|
||||
|
||||
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
|
||||
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
|
||||
let letzterKlick = null;
|
||||
window.addEventListener('pointerdown', (e) => {
|
||||
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
|
||||
if (!t || !t.getBoundingClientRect) return;
|
||||
const r = t.getBoundingClientRect();
|
||||
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
|
||||
}, true);
|
||||
function frischerKlick() {
|
||||
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
|
||||
const { x, y, width, height } = letzterKlick;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Einstellungen
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch),
|
||||
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
||||
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
||||
scanJava: () => ipcRenderer.invoke('java:scan'),
|
||||
listJava: () => ipcRenderer.invoke('java:list'),
|
||||
|
||||
// Minecraft-Versionen (Mojang)
|
||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
||||
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
|
||||
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
|
||||
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
|
||||
|
||||
// Mods (Modrinth)
|
||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
|
||||
// art: 'mod' | 'resourcepack' | 'shader'
|
||||
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
|
||||
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
|
||||
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
|
||||
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
|
||||
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
|
||||
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
|
||||
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
||||
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
||||
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
||||
crashAnalyze: (id, seit) => ipcRenderer.invoke('crash:analyze', id, seit),
|
||||
crashFix: (id, aktion) => ipcRenderer.invoke('crash:fix', id, aktion),
|
||||
crashRelaunch: (id) => ipcRenderer.invoke('crash:relaunch', id),
|
||||
shareCreate: (id) => ipcRenderer.invoke('share:create', id),
|
||||
shareFetch: (code) => ipcRenderer.invoke('share:fetch', code),
|
||||
shareInstall: (code) => ipcRenderer.invoke('share:install', code),
|
||||
onShareProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('share:progress', l);
|
||||
return () => ipcRenderer.removeListener('share:progress', l);
|
||||
},
|
||||
onAutoBackup: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:auto', l);
|
||||
return () => ipcRenderer.removeListener('backup:auto', l);
|
||||
},
|
||||
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
|
||||
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
||||
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
||||
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
||||
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
|
||||
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
||||
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
||||
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
||||
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
||||
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
||||
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
||||
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
||||
onModUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:updateProgress', l);
|
||||
return () => ipcRenderer.removeListener('mods:updateProgress', l);
|
||||
},
|
||||
|
||||
// Instanzen
|
||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
||||
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
||||
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
|
||||
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
||||
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
||||
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
||||
openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id),
|
||||
openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id),
|
||||
|
||||
// Welten & Ressourcenpakete
|
||||
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
||||
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
|
||||
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
|
||||
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
|
||||
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
|
||||
worldCopyTo: (id, worldName, zielId) => ipcRenderer.invoke('world:copyTo', id, worldName, zielId),
|
||||
friendsSendScreenshot: (otherUuid, instanceId, datei, text) => ipcRenderer.invoke('friends:sendScreenshot', otherUuid, instanceId, datei, text),
|
||||
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
||||
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
||||
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
||||
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
||||
onBackupProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:progress', l);
|
||||
return () => ipcRenderer.removeListener('backup:progress', l);
|
||||
},
|
||||
importInstance: () => ipcRenderer.invoke('instances:import'),
|
||||
createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
|
||||
serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
|
||||
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
|
||||
onLaunchRequest: (cb) => {
|
||||
const l = (_e, payload) => cb(payload);
|
||||
ipcRenderer.on('instance:launch-request', l);
|
||||
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
||||
},
|
||||
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist –
|
||||
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
|
||||
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
|
||||
notifyReady: () => ipcRenderer.send('renderer:ready'),
|
||||
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
||||
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
|
||||
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
|
||||
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
|
||||
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
|
||||
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
|
||||
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
||||
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
||||
onLaunchProgress: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('launch:progress', listener);
|
||||
return () => ipcRenderer.removeListener('launch:progress', listener);
|
||||
},
|
||||
|
||||
// eigenes Instanz-Bild
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pickImage'),
|
||||
setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath),
|
||||
clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id),
|
||||
instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id),
|
||||
fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p),
|
||||
|
||||
// Microsoft-Login (Phase 3) + Mehrkonten
|
||||
authStatus: () => ipcRenderer.invoke('auth:status'),
|
||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
||||
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
||||
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
||||
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
||||
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
|
||||
capeInfo: () => ipcRenderer.invoke('cape:info'),
|
||||
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
|
||||
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
|
||||
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
|
||||
capeRemove: () => ipcRenderer.invoke('cape:remove'),
|
||||
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
|
||||
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
|
||||
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
|
||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||
authHealth: () => ipcRenderer.invoke('auth:health'),
|
||||
authRecheck: () => ipcRenderer.invoke('auth:recheck'),
|
||||
onAuthHealth: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:health', listener);
|
||||
return () => ipcRenderer.removeListener('auth:health', listener);
|
||||
},
|
||||
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
|
||||
onAccountsChanged: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('accounts:changed', listener);
|
||||
return () => ipcRenderer.removeListener('accounts:changed', listener);
|
||||
},
|
||||
onAuthRefreshed: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('auth:refreshed', listener);
|
||||
return () => ipcRenderer.removeListener('auth:refreshed', listener);
|
||||
},
|
||||
onAuthCode: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:code', listener);
|
||||
return () => ipcRenderer.removeListener('auth:code', listener);
|
||||
},
|
||||
|
||||
// Dialoge / App
|
||||
// MultiMC-Import
|
||||
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
|
||||
onImportFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('import:fortschritt', l);
|
||||
return () => ipcRenderer.removeListener('import:fortschritt', l);
|
||||
},
|
||||
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
|
||||
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
|
||||
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
|
||||
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
|
||||
onMmcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mmc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||
},
|
||||
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
||||
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
||||
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
||||
onMrProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('mr:progress', l);
|
||||
return () => ipcRenderer.removeListener('mr:progress', l);
|
||||
},
|
||||
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||
onCfProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('cf:progress', l);
|
||||
return () => ipcRenderer.removeListener('cf:progress', l);
|
||||
},
|
||||
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
|
||||
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
|
||||
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
|
||||
onMcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mc:progress', l);
|
||||
},
|
||||
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
|
||||
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
|
||||
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
|
||||
onTechnicProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technic:progress', l);
|
||||
return () => ipcRenderer.removeListener('technic:progress', l);
|
||||
},
|
||||
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
|
||||
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
|
||||
onTechnicPlatformProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technicPlatform:progress', l);
|
||||
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
|
||||
},
|
||||
|
||||
// Bedrock (Windows-Store-Ausgabe)
|
||||
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
||||
bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'),
|
||||
bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'),
|
||||
bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind),
|
||||
bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name),
|
||||
bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind),
|
||||
bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'),
|
||||
bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir),
|
||||
|
||||
// echte Minecraft-Icons
|
||||
iconsList: () => ipcRenderer.invoke('icons:list'),
|
||||
iconsExtract: () => ipcRenderer.invoke('icons:extract'),
|
||||
|
||||
// Updates
|
||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
||||
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
|
||||
onUpdateAvailable: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:available', l);
|
||||
return () => ipcRenderer.removeListener('update:available', l);
|
||||
},
|
||||
// Ergebnis des automatischen Checks beim Start – auch wenn kein Update
|
||||
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
|
||||
// verfügbarem Update feuert)
|
||||
onUpdateChecked: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:checked', l);
|
||||
return () => ipcRenderer.removeListener('update:checked', l);
|
||||
},
|
||||
onUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:progress', l);
|
||||
return () => ipcRenderer.removeListener('update:progress', l);
|
||||
},
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||
appInfo: () => ipcRenderer.invoke('app:info'),
|
||||
appLicense: () => ipcRenderer.invoke('app:license'),
|
||||
|
||||
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
||||
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
|
||||
popoutHide: () => ipcRenderer.send('popout:hide'),
|
||||
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
||||
onPopoutReopen: (cb) => {
|
||||
const l = (_e, params) => cb(params);
|
||||
ipcRenderer.on('popout:reopen', l);
|
||||
return () => ipcRenderer.removeListener('popout:reopen', l);
|
||||
},
|
||||
onSettingsChanged: (cb) => {
|
||||
const l = (_e, s) => cb(s);
|
||||
ipcRenderer.on('settings:changed', l);
|
||||
return () => ipcRenderer.removeListener('settings:changed', l);
|
||||
},
|
||||
onInstancesChanged: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('instances:changed', l);
|
||||
return () => ipcRenderer.removeListener('instances:changed', l);
|
||||
},
|
||||
onSkinChanged: (cb) => {
|
||||
const l = (_e, info) => cb(info || {});
|
||||
ipcRenderer.on('skin:changed', l);
|
||||
return () => ipcRenderer.removeListener('skin:changed', l);
|
||||
},
|
||||
updateGetPending: () => ipcRenderer.invoke('update:getPending'),
|
||||
|
||||
// Spielaufzeichnung
|
||||
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
|
||||
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
|
||||
recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
|
||||
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
|
||||
recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
|
||||
recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
|
||||
recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
|
||||
recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
|
||||
|
||||
// Freundesystem
|
||||
friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
|
||||
friendsList: () => ipcRenderer.invoke('friends:list'),
|
||||
friendsRequests: () => ipcRenderer.invoke('friends:requests'),
|
||||
friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
|
||||
friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
|
||||
friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
|
||||
friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
|
||||
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
||||
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
|
||||
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
|
||||
friendsMessages: (otherUuid) => ipcRenderer.invoke('friends:messages', otherUuid),
|
||||
friendsSendMessage: (otherUuid, text) => ipcRenderer.invoke('friends:sendMessage', otherUuid, text),
|
||||
friendsUnreadByFriend: () => ipcRenderer.invoke('friends:unreadByFriend'),
|
||||
onFriendsMessage: (cb) => {
|
||||
const l = (_e, n) => cb(n);
|
||||
ipcRenderer.on('friends:message', l);
|
||||
return () => ipcRenderer.removeListener('friends:message', l);
|
||||
},
|
||||
crashReport: (id, seit, wahl) => ipcRenderer.invoke('crash:report', id, seit, wahl),
|
||||
clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId),
|
||||
clipSave: (data) => ipcRenderer.invoke('clip:save', data),
|
||||
onClipHotkey: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('clip:hotkey', l);
|
||||
return () => ipcRenderer.removeListener('clip:hotkey', l);
|
||||
},
|
||||
onUiToast: (cb) => {
|
||||
const l = (_e, t) => cb(t);
|
||||
ipcRenderer.on('ui:toast', l);
|
||||
return () => ipcRenderer.removeListener('ui:toast', l);
|
||||
},
|
||||
friendsJoin: (server, version) => ipcRenderer.invoke('friends:join', server, version),
|
||||
friendsJoinNew: (server, version) => ipcRenderer.invoke('friends:joinNew', server, version),
|
||||
friendsMyServer: () => ipcRenderer.invoke('friends:myServer'),
|
||||
friendsInvite: (targetUuid) => ipcRenderer.invoke('friends:invite', targetUuid),
|
||||
friendsInvites: () => ipcRenderer.invoke('friends:invites'),
|
||||
friendsDismissInvite: (id) => ipcRenderer.invoke('friends:dismissInvite', id),
|
||||
onFriendsInvite: (cb) => {
|
||||
const l = (_e, einladung) => cb(einladung);
|
||||
ipcRenderer.on('friends:invite', l);
|
||||
return () => ipcRenderer.removeListener('friends:invite', l);
|
||||
},
|
||||
onFriendsRequestCount: (cb) => {
|
||||
const l = (_e, zahl) => cb(zahl);
|
||||
ipcRenderer.on('friends:requestCount', l);
|
||||
return () => ipcRenderer.removeListener('friends:requestCount', l);
|
||||
},
|
||||
recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
|
||||
onRecordingServerDetected: (cb) => {
|
||||
const l = (_e, payload) => cb(payload);
|
||||
ipcRenderer.on('recording:serverDetected', l);
|
||||
return () => ipcRenderer.removeListener('recording:serverDetected', l);
|
||||
},
|
||||
recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId),
|
||||
recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId),
|
||||
recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel),
|
||||
recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid),
|
||||
recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'),
|
||||
onGameAudioChunk: (cb) => {
|
||||
const l = (_e, chunk) => cb(chunk);
|
||||
ipcRenderer.on('recording:gameAudioChunk', l);
|
||||
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
|
||||
},
|
||||
recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
|
||||
onRecordCloseAttempt: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('record:closeAttempt', l);
|
||||
return () => ipcRenderer.removeListener('record:closeAttempt', l);
|
||||
},
|
||||
});
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* preload.js – stellt dem Renderer eine schmale, sichere API bereit.
|
||||
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||
|
||||
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
||||
// damit man sie auch ohne offene DevTools sieht
|
||||
window.addEventListener('error', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
|
||||
});
|
||||
|
||||
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
|
||||
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
|
||||
let letzterKlick = null;
|
||||
window.addEventListener('pointerdown', (e) => {
|
||||
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
|
||||
if (!t || !t.getBoundingClientRect) return;
|
||||
const r = t.getBoundingClientRect();
|
||||
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
|
||||
}, true);
|
||||
function frischerKlick() {
|
||||
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
|
||||
const { x, y, width, height } = letzterKlick;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Einstellungen
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch),
|
||||
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
||||
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
||||
scanJava: () => ipcRenderer.invoke('java:scan'),
|
||||
listJava: () => ipcRenderer.invoke('java:list'),
|
||||
|
||||
// Minecraft-Versionen (Mojang)
|
||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
||||
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
|
||||
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
|
||||
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
|
||||
|
||||
// Mods (Modrinth)
|
||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
|
||||
// art: 'mod' | 'resourcepack' | 'shader'
|
||||
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
|
||||
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
|
||||
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
|
||||
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
|
||||
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
|
||||
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
|
||||
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
||||
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
||||
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
||||
crashAnalyze: (id, seit) => ipcRenderer.invoke('crash:analyze', id, seit),
|
||||
crashFix: (id, aktion) => ipcRenderer.invoke('crash:fix', id, aktion),
|
||||
crashRelaunch: (id) => ipcRenderer.invoke('crash:relaunch', id),
|
||||
shareCreate: (id) => ipcRenderer.invoke('share:create', id),
|
||||
shareFetch: (code) => ipcRenderer.invoke('share:fetch', code),
|
||||
shareInstall: (code) => ipcRenderer.invoke('share:install', code),
|
||||
onShareProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('share:progress', l);
|
||||
return () => ipcRenderer.removeListener('share:progress', l);
|
||||
},
|
||||
onAutoBackup: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:auto', l);
|
||||
return () => ipcRenderer.removeListener('backup:auto', l);
|
||||
},
|
||||
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
|
||||
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
||||
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
||||
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
||||
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
|
||||
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
||||
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
||||
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
||||
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
||||
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
||||
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
||||
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
||||
onModUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:updateProgress', l);
|
||||
return () => ipcRenderer.removeListener('mods:updateProgress', l);
|
||||
},
|
||||
|
||||
// Instanzen
|
||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
||||
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
||||
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
|
||||
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
||||
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
||||
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
||||
openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id),
|
||||
openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id),
|
||||
|
||||
// Welten & Ressourcenpakete
|
||||
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
||||
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
|
||||
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
|
||||
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
|
||||
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
|
||||
worldCopyTo: (id, worldName, zielId) => ipcRenderer.invoke('world:copyTo', id, worldName, zielId),
|
||||
friendsSendScreenshot: (otherUuid, instanceId, datei, text) => ipcRenderer.invoke('friends:sendScreenshot', otherUuid, instanceId, datei, text),
|
||||
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
||||
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
||||
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||
integritaetWiederherstellen: (id) => ipcRenderer.invoke('integritaet:wiederherstellen', id),
|
||||
// Pfad einer per Drag & Drop abgelegten Datei (File.path gibt es seit Electron 32 nicht mehr)
|
||||
pfadVonDatei: (datei) => { try { return webUtils.getPathForFile(datei) || ''; } catch { return ''; } },
|
||||
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
||||
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
||||
onBackupProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:progress', l);
|
||||
return () => ipcRenderer.removeListener('backup:progress', l);
|
||||
},
|
||||
importInstance: () => ipcRenderer.invoke('instances:import'),
|
||||
createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
|
||||
serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
|
||||
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
|
||||
onLaunchRequest: (cb) => {
|
||||
const l = (_e, payload) => cb(payload);
|
||||
ipcRenderer.on('instance:launch-request', l);
|
||||
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
||||
},
|
||||
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist –
|
||||
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
|
||||
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
|
||||
notifyReady: () => ipcRenderer.send('renderer:ready'),
|
||||
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
||||
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
|
||||
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
|
||||
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
|
||||
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
|
||||
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
|
||||
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
||||
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
||||
onLaunchProgress: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('launch:progress', listener);
|
||||
return () => ipcRenderer.removeListener('launch:progress', listener);
|
||||
},
|
||||
|
||||
// eigenes Instanz-Bild
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pickImage'),
|
||||
setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath),
|
||||
clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id),
|
||||
instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id),
|
||||
fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p),
|
||||
|
||||
// Microsoft-Login (Phase 3) + Mehrkonten
|
||||
authStatus: () => ipcRenderer.invoke('auth:status'),
|
||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
||||
skinArmformPruefen: () => ipcRenderer.invoke('skin:armformPruefen'),
|
||||
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
|
||||
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
|
||||
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
|
||||
modschalterDaten: (id) => ipcRenderer.invoke('modschalter:daten', id),
|
||||
modschalterNeustart: (id) => ipcRenderer.invoke('modschalter:neustart', id),
|
||||
modschalterSchliessen: () => ipcRenderer.send('modschalter:schliessen'),
|
||||
onModschalterNeustarten: (cb) => {
|
||||
const l = (_e, d) => cb(d || {});
|
||||
ipcRenderer.on('modschalter:neustarten', l);
|
||||
return () => ipcRenderer.removeListener('modschalter:neustarten', l);
|
||||
},
|
||||
onLaufendeInstanzen: (cb) => {
|
||||
const l = (_e, d) => cb(d || []);
|
||||
ipcRenderer.on('game:laufend', l);
|
||||
return () => ipcRenderer.removeListener('game:laufend', l);
|
||||
},
|
||||
onErfolgeNeu: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('erfolge:neu', l);
|
||||
return () => ipcRenderer.removeListener('erfolge:neu', l);
|
||||
},
|
||||
modsLeistungsPaket: (id) => ipcRenderer.invoke('mods:leistungsPaket', id),
|
||||
onPaketFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:paketFortschritt', l);
|
||||
return () => ipcRenderer.removeListener('mods:paketFortschritt', l);
|
||||
},
|
||||
garderobeSkinSpeichern: (name) => ipcRenderer.invoke('garderobe:skinSpeichern', name),
|
||||
garderobeUmhangSpeichern: (name) => ipcRenderer.invoke('garderobe:umhangSpeichern', name),
|
||||
garderobeAnziehen: (id) => ipcRenderer.invoke('garderobe:anziehen', id),
|
||||
garderobeUmbenennen: (id, name) => ipcRenderer.invoke('garderobe:umbenennen', id, name),
|
||||
garderobeLoeschen: (id) => ipcRenderer.invoke('garderobe:loeschen', id),
|
||||
skinArmformKorrigieren: () => ipcRenderer.invoke('skin:armformKorrigieren'),
|
||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
||||
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
||||
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
||||
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
||||
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
|
||||
capeInfo: () => ipcRenderer.invoke('cape:info'),
|
||||
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
|
||||
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
|
||||
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
|
||||
capeRemove: () => ipcRenderer.invoke('cape:remove'),
|
||||
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
|
||||
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
|
||||
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
|
||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||
authHealth: () => ipcRenderer.invoke('auth:health'),
|
||||
authRecheck: () => ipcRenderer.invoke('auth:recheck'),
|
||||
onAuthHealth: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:health', listener);
|
||||
return () => ipcRenderer.removeListener('auth:health', listener);
|
||||
},
|
||||
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
|
||||
onAccountsChanged: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('accounts:changed', listener);
|
||||
return () => ipcRenderer.removeListener('accounts:changed', listener);
|
||||
},
|
||||
onAuthRefreshed: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('auth:refreshed', listener);
|
||||
return () => ipcRenderer.removeListener('auth:refreshed', listener);
|
||||
},
|
||||
onAuthCode: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:code', listener);
|
||||
return () => ipcRenderer.removeListener('auth:code', listener);
|
||||
},
|
||||
|
||||
// Dialoge / App
|
||||
// MultiMC-Import
|
||||
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
|
||||
onImportFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('import:fortschritt', l);
|
||||
return () => ipcRenderer.removeListener('import:fortschritt', l);
|
||||
},
|
||||
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
|
||||
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
|
||||
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
|
||||
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
|
||||
onMmcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mmc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||
},
|
||||
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
||||
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
||||
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
||||
onMrProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('mr:progress', l);
|
||||
return () => ipcRenderer.removeListener('mr:progress', l);
|
||||
},
|
||||
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||
onCfProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('cf:progress', l);
|
||||
return () => ipcRenderer.removeListener('cf:progress', l);
|
||||
},
|
||||
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
|
||||
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
|
||||
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
|
||||
onMcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mc:progress', l);
|
||||
},
|
||||
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
|
||||
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
|
||||
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
|
||||
onTechnicProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technic:progress', l);
|
||||
return () => ipcRenderer.removeListener('technic:progress', l);
|
||||
},
|
||||
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
|
||||
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
|
||||
onTechnicPlatformProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technicPlatform:progress', l);
|
||||
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
|
||||
},
|
||||
|
||||
// Bedrock (Windows-Store-Ausgabe)
|
||||
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
||||
bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'),
|
||||
bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'),
|
||||
bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind),
|
||||
bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name),
|
||||
bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind),
|
||||
bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'),
|
||||
bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir),
|
||||
|
||||
// echte Minecraft-Icons
|
||||
iconsList: () => ipcRenderer.invoke('icons:list'),
|
||||
iconsExtract: () => ipcRenderer.invoke('icons:extract'),
|
||||
|
||||
// Updates
|
||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
||||
updateInstall: () => ipcRenderer.invoke('update:install'),
|
||||
onUpdateAvailable: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:available', l);
|
||||
return () => ipcRenderer.removeListener('update:available', l);
|
||||
},
|
||||
// Ergebnis des automatischen Checks beim Start – auch wenn kein Update
|
||||
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
|
||||
// verfügbarem Update feuert)
|
||||
onUpdateChecked: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:checked', l);
|
||||
return () => ipcRenderer.removeListener('update:checked', l);
|
||||
},
|
||||
onUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:progress', l);
|
||||
return () => ipcRenderer.removeListener('update:progress', l);
|
||||
},
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||
appInfo: () => ipcRenderer.invoke('app:info'),
|
||||
appLicense: () => ipcRenderer.invoke('app:license'),
|
||||
|
||||
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
||||
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
|
||||
popoutHide: () => ipcRenderer.send('popout:hide'),
|
||||
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
||||
onPopoutReopen: (cb) => {
|
||||
const l = (_e, params) => cb(params);
|
||||
ipcRenderer.on('popout:reopen', l);
|
||||
return () => ipcRenderer.removeListener('popout:reopen', l);
|
||||
},
|
||||
onSettingsChanged: (cb) => {
|
||||
const l = (_e, s) => cb(s);
|
||||
ipcRenderer.on('settings:changed', l);
|
||||
return () => ipcRenderer.removeListener('settings:changed', l);
|
||||
},
|
||||
onInstancesChanged: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('instances:changed', l);
|
||||
return () => ipcRenderer.removeListener('instances:changed', l);
|
||||
},
|
||||
onSkinChanged: (cb) => {
|
||||
const l = (_e, info) => cb(info || {});
|
||||
ipcRenderer.on('skin:changed', l);
|
||||
return () => ipcRenderer.removeListener('skin:changed', l);
|
||||
},
|
||||
updateGetPending: () => ipcRenderer.invoke('update:getPending'),
|
||||
|
||||
// Spielaufzeichnung
|
||||
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
|
||||
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
|
||||
recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
|
||||
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
|
||||
recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
|
||||
recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
|
||||
recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
|
||||
recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
|
||||
|
||||
// Freundesystem
|
||||
friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
|
||||
friendsList: () => ipcRenderer.invoke('friends:list'),
|
||||
friendsRequests: () => ipcRenderer.invoke('friends:requests'),
|
||||
friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
|
||||
friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
|
||||
friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
|
||||
friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
|
||||
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
||||
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
|
||||
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
|
||||
friendsMessages: (otherUuid) => ipcRenderer.invoke('friends:messages', otherUuid),
|
||||
friendsSendMessage: (otherUuid, text) => ipcRenderer.invoke('friends:sendMessage', otherUuid, text),
|
||||
friendsUnreadByFriend: () => ipcRenderer.invoke('friends:unreadByFriend'),
|
||||
onFriendsMessage: (cb) => {
|
||||
const l = (_e, n) => cb(n);
|
||||
ipcRenderer.on('friends:message', l);
|
||||
return () => ipcRenderer.removeListener('friends:message', l);
|
||||
},
|
||||
crashReport: (id, seit, wahl) => ipcRenderer.invoke('crash:report', id, seit, wahl),
|
||||
clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId),
|
||||
clipSave: (data) => ipcRenderer.invoke('clip:save', data),
|
||||
onClipHotkey: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('clip:hotkey', l);
|
||||
return () => ipcRenderer.removeListener('clip:hotkey', l);
|
||||
},
|
||||
onUiToast: (cb) => {
|
||||
const l = (_e, t) => cb(t);
|
||||
ipcRenderer.on('ui:toast', l);
|
||||
return () => ipcRenderer.removeListener('ui:toast', l);
|
||||
},
|
||||
friendsJoin: (server, version) => ipcRenderer.invoke('friends:join', server, version),
|
||||
friendsJoinNew: (server, version) => ipcRenderer.invoke('friends:joinNew', server, version),
|
||||
friendsMyServer: () => ipcRenderer.invoke('friends:myServer'),
|
||||
friendsInvite: (targetUuid) => ipcRenderer.invoke('friends:invite', targetUuid),
|
||||
friendsInvites: () => ipcRenderer.invoke('friends:invites'),
|
||||
friendsDismissInvite: (id) => ipcRenderer.invoke('friends:dismissInvite', id),
|
||||
onFriendsInvite: (cb) => {
|
||||
const l = (_e, einladung) => cb(einladung);
|
||||
ipcRenderer.on('friends:invite', l);
|
||||
return () => ipcRenderer.removeListener('friends:invite', l);
|
||||
},
|
||||
onFriendsRequestCount: (cb) => {
|
||||
const l = (_e, zahl) => cb(zahl);
|
||||
ipcRenderer.on('friends:requestCount', l);
|
||||
return () => ipcRenderer.removeListener('friends:requestCount', l);
|
||||
},
|
||||
recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
|
||||
onRecordingServerDetected: (cb) => {
|
||||
const l = (_e, payload) => cb(payload);
|
||||
ipcRenderer.on('recording:serverDetected', l);
|
||||
return () => ipcRenderer.removeListener('recording:serverDetected', l);
|
||||
},
|
||||
recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId),
|
||||
recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId),
|
||||
recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel),
|
||||
recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid),
|
||||
recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'),
|
||||
onGameAudioChunk: (cb) => {
|
||||
const l = (_e, chunk) => cb(chunk);
|
||||
ipcRenderer.on('recording:gameAudioChunk', l);
|
||||
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
|
||||
},
|
||||
recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
|
||||
onRecordCloseAttempt: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('record:closeAttempt', l);
|
||||
return () => ipcRenderer.removeListener('record:closeAttempt', l);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -299,6 +299,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
|
||||
versionNumber: v.version_number,
|
||||
filename: file.filename,
|
||||
url: file.url,
|
||||
hashes: mrHashes(file),
|
||||
sha1: file.hashes && file.hashes.sha1,
|
||||
size: file.size,
|
||||
dependencies: v.dependencies || [],
|
||||
@@ -324,6 +325,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
|
||||
versionNumber: v.version_number,
|
||||
filename: file.filename,
|
||||
url: file.url,
|
||||
hashes: mrHashes(file),
|
||||
sha1: file.hashes && file.hashes.sha1,
|
||||
size: file.size,
|
||||
dependencies: v.dependencies || [],
|
||||
@@ -350,23 +352,58 @@ async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
|
||||
name: v.name || v.version_number,
|
||||
filename: file.filename,
|
||||
url: file.url,
|
||||
hashes: mrHashes(file),
|
||||
size: file.size || 0,
|
||||
gameVersions: v.game_versions || [],
|
||||
loaders: v.loaders || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadBuffer(url, extraHeaders) {
|
||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
/*
|
||||
* Prüfsumme eines Downloads kontrollieren. erwartet: { sha512?, sha1? } – die
|
||||
* Werte stammen aus der Modrinth- bzw. CurseForge-API. Ohne Angabe wird nichts
|
||||
* geprüft (z. B. Dateien, zu denen die Quelle keine Prüfsumme nennt).
|
||||
*/
|
||||
function pruefeDownload(buf, erwartet, name) {
|
||||
if (!erwartet) return;
|
||||
for (const algo of ['sha512', 'sha256', 'sha1']) {
|
||||
const soll = erwartet[algo] && String(erwartet[algo]).toLowerCase();
|
||||
if (!soll) continue;
|
||||
const ist = require('crypto').createHash(algo).update(buf).digest('hex');
|
||||
if (ist !== soll) {
|
||||
throw new Error(`Prüfsumme von ${name || 'Download'} stimmt nicht – Datei verworfen (beschädigt oder manipuliert).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
||||
async function downloadFile(url, destPath, extraHeaders) {
|
||||
// CurseForge liefert Prüfsummen als Liste: algo 1 = SHA-1, 2 = MD5
|
||||
function cfHashes(datei) {
|
||||
const sha1 = ((datei && datei.hashes) || []).find((h) => h.algo === 1 && h.value);
|
||||
return sha1 ? { sha1: sha1.value } : null;
|
||||
}
|
||||
|
||||
// Modrinth: { sha512, sha1 }
|
||||
function mrHashes(datei) {
|
||||
const h = (datei && datei.hashes) || {};
|
||||
return h.sha512 || h.sha1 ? { sha512: h.sha512, sha1: h.sha1 } : null;
|
||||
}
|
||||
|
||||
async function downloadBuffer(url, extraHeaders, erwartet) {
|
||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
pruefeDownload(buf, erwartet, decodeURIComponent(String(url).split('/').pop() || ''));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
||||
// erwartet: Prüfsummen (siehe pruefeDownload) – geschrieben wird nur, was passt
|
||||
async function downloadFile(url, destPath, extraHeaders, erwartet) {
|
||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
pruefeDownload(buf, erwartet, path.basename(destPath));
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.writeFileSync(destPath, buf);
|
||||
return buf.length;
|
||||
@@ -383,7 +420,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir,
|
||||
: `Keine passende Version für ${gameVersion} gefunden.` };
|
||||
}
|
||||
const dest = path.join(modsDir, file.filename);
|
||||
const bytes = await downloadFile(file.url, dest);
|
||||
const bytes = await downloadFile(file.url, dest, null, file.hashes);
|
||||
return {
|
||||
ok: true,
|
||||
dependencies: file.dependencies || [],
|
||||
@@ -738,6 +775,7 @@ async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
|
||||
fileId: match.id,
|
||||
filename: name,
|
||||
url: dl,
|
||||
hashes: cfHashes(match),
|
||||
versionNumber: match.displayName || match.fileName || String(match.id),
|
||||
dependencies: match.dependencies || [],
|
||||
};
|
||||
@@ -768,7 +806,7 @@ async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader
|
||||
// Download: direkt mit Key oder über Weiterleitung
|
||||
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
||||
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
|
||||
const bytes = await downloadFile(url, dest, headers);
|
||||
const bytes = await downloadFile(url, dest, headers, file.hashes);
|
||||
|
||||
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
|
||||
const depIds = (file.dependencies || [])
|
||||
@@ -826,6 +864,7 @@ async function curseforgeResolveFiles(zugang, files, onProgress) {
|
||||
map.set(Number(d.id), {
|
||||
fileName: d.fileName,
|
||||
url: zugang.downloadAdresse(roh),
|
||||
hashes: cfHashes(d),
|
||||
kopfzeilen: zugang.downloadKopfzeilen,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,4 +176,32 @@ function waehleAnzeigeSkin(kontoProfil, spiel, kuerzlichGeaendert) {
|
||||
return Object.assign({}, kontoProfil, { skinUrl: spiel.skinUrl, variante: spiel.variante, skinAusSpiel: true });
|
||||
}
|
||||
|
||||
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin };
|
||||
/*
|
||||
* Für welche Armform ist eine Skin-Datei gemalt? Breite Arme (Steve) sind 4
|
||||
* Pixel breit, schmale (Alex) 3 – bei schmalen bleiben deshalb je Arm zwei
|
||||
* Pixelspalten der Rückseite leer: rechter Arm x 54–55 / y 20–31, linker Arm
|
||||
* x 46–47 / y 52–63. bitmap: Rohdaten mit 4 Byte je Pixel (Alpha an Stelle 3).
|
||||
* -> 'classic' | 'slim' | null (nicht eindeutig, alte 64×32-Skins: classic)
|
||||
*/
|
||||
function armformDerTextur(bitmap, breite, hoehe) {
|
||||
if (!bitmap || !breite || breite % 64 !== 0 || hoehe % 32 !== 0) return null;
|
||||
const f = breite / 64; // HD-Skins (128×128 …) mitskalieren
|
||||
if (Math.round(hoehe / f) === 32) return 'classic';
|
||||
if (Math.round(hoehe / f) !== 64) return null;
|
||||
let deckend = 0, gesamt = 0;
|
||||
const pruefe = (x0, y0) => {
|
||||
for (let y = y0 * f; y < (y0 + 12) * f; y++) {
|
||||
for (let x = x0 * f; x < (x0 + 2) * f; x++) {
|
||||
gesamt++;
|
||||
if (bitmap[(y * breite + x) * 4 + 3] > 0) deckend++;
|
||||
}
|
||||
}
|
||||
};
|
||||
pruefe(54, 20);
|
||||
pruefe(46, 52);
|
||||
if (deckend >= gesamt * 0.85) return 'classic';
|
||||
if (deckend <= gesamt * 0.1) return 'slim';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin, armformDerTextur };
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* spieldesign.js – AeroMC-Look im Spiel (Minecraft 1.14 … 26.x)
|
||||
* --------------------------------------------------------------
|
||||
* Zwei Teile:
|
||||
*
|
||||
* 1. Ressourcenpaket "AeroMC-Design" im Ordner resourcepacks der Instanz:
|
||||
* Titel-Logo mit Untertitel, Menü-Hintergrund (Panorama), eigene Sprüche,
|
||||
* Knöpfe im AeroMC-Stil und Symbole vor den Menü-Beschriftungen (als eigene
|
||||
* Bitmap-Schrift über die Sprachdateien). Bis 1.20.1 stecken die Knöpfe im
|
||||
* gemeinsamen widgets.png der Version, ab 1.20.2 sind es eigene Sprites.
|
||||
* Das Paket wird in options.txt ganz unten eingereiht – eigene
|
||||
* Ressourcenpakete des Spielers haben also immer Vorrang.
|
||||
* 2. Grafiken für den Agenten (Ladebildschirm-Logo, Fenstersymbol): Java kann
|
||||
* nicht aus app.asar lesen, darum werden sie nach userData kopiert.
|
||||
*
|
||||
* Für den Mod-Schalter (siehe modschalter.js) bringt das Paket außerdem die
|
||||
* Beschriftung „Mods verwalten“ mit – auch wenn der AeroMC-Look aus ist.
|
||||
* Ist beides aus, wird das Paket wieder entfernt.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const unzipper = require('unzipper');
|
||||
|
||||
const PAKET = 'AeroMC-Design';
|
||||
const PAKET_ID = 'file/' + PAKET;
|
||||
const STAND = 8; // erhöhen, wenn sich die Grafiken ändern -> Paket wird neu gebaut
|
||||
const ASSETS = path.join(__dirname, 'assets', 'spieldesign');
|
||||
|
||||
// ---------- Versionen ----------
|
||||
|
||||
// "1.21.9-rc1" -> [1, 21, 9]; Snapshots wie "25w31a" -> null
|
||||
function zahlen(version) {
|
||||
const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version || ''));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null;
|
||||
}
|
||||
function ab(version, grenze) {
|
||||
const a = zahlen(version);
|
||||
if (!a) return true; // Snapshots u. Ä. gelten als neu
|
||||
const b = zahlen(grenze);
|
||||
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ressourcenpaket-Format je Version (minecraft.wiki, "Pack format")
|
||||
const FORMATE = [
|
||||
['1.14', 4], ['1.15', 5], ['1.16.2', 6], ['1.17', 7], ['1.18', 8], ['1.19', 9], ['1.19.3', 12], ['1.19.4', 13],
|
||||
['1.20', 15], ['1.20.2', 18], ['1.20.3', 22], ['1.20.5', 32], ['1.21', 34], ['1.21.2', 42], ['1.21.4', 46],
|
||||
['1.21.5', 55], ['1.21.6', 63], ['1.21.7', 64], ['1.21.9', 69], ['1.21.11', 75], ['26.1', 84], ['26.2', 88], ['26.3', 97],
|
||||
];
|
||||
function paketFormat(version) {
|
||||
let f = FORMATE[FORMATE.length - 1][1];
|
||||
if (zahlen(version)) for (const [v, n] of FORMATE) if (ab(version, v)) f = n;
|
||||
return f;
|
||||
}
|
||||
function mcmeta(version) {
|
||||
const f = paketFormat(version);
|
||||
const neuste = f === FORMATE[FORMATE.length - 1][1];
|
||||
const pack = { description: 'AeroMC – Menü, Logo und Knöpfe' };
|
||||
if (f >= 65) {
|
||||
// ab 1.21.9: min/max statt pack_format; die neuste bekannte Version offen nach oben
|
||||
pack.min_format = f;
|
||||
pack.max_format = neuste ? 999 : f;
|
||||
} else {
|
||||
pack.pack_format = f;
|
||||
}
|
||||
return JSON.stringify({ pack }, null, 2);
|
||||
}
|
||||
|
||||
/** Spieldesign gibt es ab 1.14 (vorher andere Menüs und LWJGL 2). */
|
||||
function unterstuetzt(version) {
|
||||
return ab(version, '1.14');
|
||||
}
|
||||
|
||||
// ---------- PNG (verlustfrei, ohne Zusatzbibliothek) ----------
|
||||
|
||||
function pngLesen(buf) {
|
||||
if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('kein PNG');
|
||||
let pos = 8, w = 0, h = 0, tiefe = 0, typ = 0, palette = null, transparenz = null;
|
||||
const idat = [];
|
||||
while (pos < buf.length) {
|
||||
const laenge = buf.readUInt32BE(pos);
|
||||
const art = buf.toString('ascii', pos + 4, pos + 8);
|
||||
const daten = buf.subarray(pos + 8, pos + 8 + laenge);
|
||||
if (art === 'IHDR') {
|
||||
w = daten.readUInt32BE(0); h = daten.readUInt32BE(4); tiefe = daten[8]; typ = daten[9];
|
||||
if (daten[12] !== 0) throw new Error('Interlacing nicht unterstützt');
|
||||
} else if (art === 'PLTE') palette = daten;
|
||||
else if (art === 'tRNS') transparenz = daten;
|
||||
else if (art === 'IDAT') idat.push(daten);
|
||||
else if (art === 'IEND') break;
|
||||
pos += 12 + laenge;
|
||||
}
|
||||
const kanaele = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[typ];
|
||||
if (!kanaele || (tiefe !== 8 && !(typ === 3 && tiefe < 8) && !(typ === 0 && tiefe < 8))) throw new Error(`PNG-Format ${typ}/${tiefe} nicht unterstützt`);
|
||||
const roh = zlib.inflateSync(Buffer.concat(idat));
|
||||
const bpp = Math.max(1, (kanaele * tiefe) >> 3); // Bytes je Pixel für die Filter
|
||||
const zeile = Math.ceil((w * kanaele * tiefe) / 8);
|
||||
const pixel = Buffer.alloc(zeile * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const f = roh[y * (zeile + 1)];
|
||||
const ein = roh.subarray(y * (zeile + 1) + 1, (y + 1) * (zeile + 1));
|
||||
const aus = pixel.subarray(y * zeile, (y + 1) * zeile);
|
||||
const oben = y ? pixel.subarray((y - 1) * zeile, y * zeile) : null;
|
||||
for (let i = 0; i < zeile; i++) {
|
||||
const a = i >= bpp ? aus[i - bpp] : 0, b = oben ? oben[i] : 0, c = oben && i >= bpp ? oben[i - bpp] : 0;
|
||||
let v = ein[i];
|
||||
if (f === 1) v += a;
|
||||
else if (f === 2) v += b;
|
||||
else if (f === 3) v += (a + b) >> 1;
|
||||
else if (f === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
||||
aus[i] = v & 255;
|
||||
}
|
||||
}
|
||||
const rgba = Buffer.alloc(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const o = (y * w + x) * 4;
|
||||
let wert;
|
||||
if (tiefe < 8) {
|
||||
const bit = x * tiefe;
|
||||
wert = (pixel[y * zeile + (bit >> 3)] >> (8 - tiefe - (bit & 7))) & ((1 << tiefe) - 1);
|
||||
} else wert = null;
|
||||
const p = y * zeile + x * kanaele;
|
||||
if (typ === 6) { pixel.copy(rgba, o, p, p + 4); continue; }
|
||||
if (typ === 2) { rgba[o] = pixel[p]; rgba[o + 1] = pixel[p + 1]; rgba[o + 2] = pixel[p + 2]; rgba[o + 3] = 255; continue; }
|
||||
if (typ === 4) { rgba[o] = rgba[o + 1] = rgba[o + 2] = pixel[p]; rgba[o + 3] = pixel[p + 1]; continue; }
|
||||
if (typ === 0) {
|
||||
const g = tiefe < 8 ? Math.round((wert * 255) / ((1 << tiefe) - 1)) : pixel[p];
|
||||
rgba[o] = rgba[o + 1] = rgba[o + 2] = g; rgba[o + 3] = 255; continue;
|
||||
}
|
||||
const index = tiefe < 8 ? wert : pixel[p]; // Palette
|
||||
rgba[o] = palette[index * 3]; rgba[o + 1] = palette[index * 3 + 1]; rgba[o + 2] = palette[index * 3 + 2];
|
||||
rgba[o + 3] = transparenz && index < transparenz.length ? transparenz[index] : 255;
|
||||
}
|
||||
}
|
||||
return { w, h, rgba };
|
||||
}
|
||||
|
||||
const CRC = (() => {
|
||||
const t = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
|
||||
return t;
|
||||
})();
|
||||
function crc32(buf) {
|
||||
let c = -1;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8);
|
||||
return (c ^ -1) >>> 0;
|
||||
}
|
||||
function block(art, daten) {
|
||||
const kopf = Buffer.alloc(8);
|
||||
kopf.writeUInt32BE(daten.length, 0);
|
||||
kopf.write(art, 4, 'ascii');
|
||||
const pruef = Buffer.alloc(4);
|
||||
pruef.writeUInt32BE(crc32(Buffer.concat([kopf.subarray(4), daten])), 0);
|
||||
return Buffer.concat([kopf, daten, pruef]);
|
||||
}
|
||||
function pngSchreiben(w, h, rgba) {
|
||||
const roh = Buffer.alloc((w * 4 + 1) * h);
|
||||
for (let y = 0; y < h; y++) rgba.copy(roh, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
block('IHDR', ihdr), block('IDAT', zlib.deflateSync(roh, { level: 9 })), block('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------- Paket bauen ----------
|
||||
|
||||
async function ausJar(jar, eintraege) {
|
||||
const verz = await unzipper.Open.file(jar);
|
||||
const aus = {};
|
||||
for (const e of eintraege) {
|
||||
const f = verz.files.find((d) => d.path === e);
|
||||
if (f) aus[e] = await f.buffer();
|
||||
}
|
||||
return aus;
|
||||
}
|
||||
|
||||
function schreibe(datei, inhalt) {
|
||||
fs.mkdirSync(path.dirname(datei), { recursive: true });
|
||||
fs.writeFileSync(datei, inhalt);
|
||||
}
|
||||
|
||||
async function bauePaket(gameDir, version, clientJar, protokoll, teile) {
|
||||
const { design, mods } = teile;
|
||||
const ziel = path.join(gameDir, 'resourcepacks', PAKET);
|
||||
const marke = path.join(ziel, 'aeromc.json');
|
||||
const kennung = JSON.stringify({ stand: STAND, version, design: !!design, mods: !!mods });
|
||||
try { if (fs.readFileSync(marke, 'utf8') === kennung) return; } catch { /* noch nicht gebaut */ }
|
||||
fs.rmSync(ziel, { recursive: true, force: true });
|
||||
|
||||
const alt = !ab(version, '1.20'); // Titel-Logo zweigeteilt bis 1.19.4
|
||||
const gui = path.join(ziel, 'assets', 'minecraft', 'textures', 'gui');
|
||||
const asset = (n) => fs.readFileSync(path.join(ASSETS, n));
|
||||
schreibe(path.join(ziel, 'pack.png'), asset('pack.png'));
|
||||
schreibe(path.join(ziel, 'pack.mcmeta'), mcmeta(version));
|
||||
|
||||
// Symbole (Schriftzeichen) und Beschriftungen – für Menü-Knöpfe und/oder „Mods verwalten“
|
||||
schreibe(path.join(ziel, 'assets', 'aeromc', 'textures', 'font', 'symbole.png'), asset('symbole.png'));
|
||||
// Die Schriftdatei ersetzt die von Minecraft (zumindest in neueren Versionen) –
|
||||
// darum die Original-Einträge dieser Version übernehmen und die Symbole davorsetzen
|
||||
try {
|
||||
const eintrag = 'assets/minecraft/font/default.json';
|
||||
const orig = JSON.parse(String((await ausJar(clientJar, [eintrag]))[eintrag] || '{"providers":[]}'));
|
||||
const eigene = JSON.parse(String(asset('font_default.json'))).providers;
|
||||
schreibe(path.join(ziel, 'assets', 'minecraft', 'font', 'default.json'),
|
||||
JSON.stringify({ providers: [...eigene, ...(orig.providers || [])] }, null, 2));
|
||||
} catch (err) {
|
||||
if (protokoll) protokoll('[AeroMC] Symbole nicht eingebunden: ' + err.message);
|
||||
}
|
||||
for (const sprache of ['de_de', 'en_us']) {
|
||||
const texte = Object.assign({},
|
||||
design ? JSON.parse(String(asset(`lang_${sprache}.json`))) : {},
|
||||
mods ? JSON.parse(String(asset(`lang_mods_${sprache}.json`))) : {});
|
||||
schreibe(path.join(ziel, 'assets', 'minecraft', 'lang', sprache + '.json'), JSON.stringify(texte, null, 2));
|
||||
}
|
||||
// 26.x: „Feedback geben“ ist ein Symbol-Knopf -> Puzzle-Symbol für „Mods verwalten“
|
||||
if (mods) schreibe(path.join(gui, 'sprites', 'pause_menu', 'social_interactions.png'), asset('mods_knopf.png'));
|
||||
if (!design) { schreibe(marke, kennung); return; }
|
||||
|
||||
const titel = fs.readFileSync(path.join(ASSETS, alt ? 'titel_alt.png' : 'titel_neu.png'));
|
||||
schreibe(path.join(gui, 'title', 'minecraft.png'), titel);
|
||||
schreibe(path.join(gui, 'title', 'minceraft.png'), titel);
|
||||
schreibe(path.join(gui, 'title', 'edition.png'), fs.readFileSync(path.join(ASSETS, alt ? 'edition_alt.png' : 'edition_neu.png')));
|
||||
// Hintergrund des Hauptmenüs (Minecraft dreht ihn als Würfel um die Kamera)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
schreibe(path.join(gui, 'title', 'background', `panorama_${i}.png`), fs.readFileSync(path.join(ASSETS, `panorama_${i}.png`)));
|
||||
}
|
||||
schreibe(path.join(ziel, 'assets', 'minecraft', 'texts', 'splashes.txt'), asset('splashes.txt'));
|
||||
|
||||
// Knöpfe wie im Entwurf (dunkles Schiefer, hervorgehoben leuchtend blau)
|
||||
try {
|
||||
if (ab(version, '1.20.2')) {
|
||||
// eigene Sprites (doppelte Auflösung) samt Neun-Teile-Skalierung
|
||||
const w = path.join(gui, 'sprites', 'widget');
|
||||
schreibe(path.join(w, 'button.png'), asset('knopf.png'));
|
||||
schreibe(path.join(w, 'button_highlighted.png'), asset('knopf_hell.png'));
|
||||
schreibe(path.join(w, 'button.png.mcmeta'), asset('knopf.png.mcmeta'));
|
||||
schreibe(path.join(w, 'button_highlighted.png.mcmeta'), asset('knopf.png.mcmeta'));
|
||||
} else {
|
||||
// widgets.png enthält auch Hotbar & Co. – Original 4-fach vergrößern (verlustfrei)
|
||||
// und nur die Knopf-Zeilen durch die neuen ersetzen (66–86 normal, 86–106 hervorgehoben)
|
||||
const e = 'assets/minecraft/textures/gui/widgets.png';
|
||||
const daten = (await ausJar(clientJar, [e]))[e];
|
||||
if (daten) {
|
||||
const orig = pngLesen(daten);
|
||||
const f = Math.max(1, Math.round(1024 / orig.w));
|
||||
const W = orig.w * f, H = orig.h * f;
|
||||
const gross = Buffer.alloc(W * H * 4);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) orig.rgba.copy(gross, (y * W + x) * 4, (((y / f) | 0) * orig.w + ((x / f) | 0)) * 4, (((y / f) | 0) * orig.w + ((x / f) | 0)) * 4 + 4);
|
||||
}
|
||||
const s = W / 256;
|
||||
for (const [datei, zeile] of [['knopf_4x.png', 66], ['knopf_hell_4x.png', 86]]) {
|
||||
const k = pngLesen(asset(datei));
|
||||
const kb = Math.round(200 * s), kh = Math.round(20 * s), y0 = Math.round(zeile * s);
|
||||
for (let y = 0; y < kh; y++) {
|
||||
for (let x = 0; x < kb; x++) {
|
||||
const q = (((y * k.h / kh) | 0) * k.w + ((x * k.w / kb) | 0)) * 4;
|
||||
k.rgba.copy(gross, ((y0 + y) * W + x) * 4, q, q + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
schreibe(path.join(gui, 'widgets.png'), pngSchreiben(W, H, gross));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (protokoll) protokoll('[AeroMC] Knöpfe nicht ersetzt: ' + err.message);
|
||||
}
|
||||
schreibe(marke, kennung);
|
||||
}
|
||||
|
||||
// ---------- options.txt ----------
|
||||
|
||||
function liesListe(zeilen, schluessel) {
|
||||
const z = zeilen.find((l) => l.startsWith(schluessel + ':'));
|
||||
if (!z) return null;
|
||||
try { const v = JSON.parse(z.slice(schluessel.length + 1)); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||
}
|
||||
function setzeListe(zeilen, schluessel, liste) {
|
||||
const i = zeilen.findIndex((l) => l.startsWith(schluessel + ':'));
|
||||
const neu = schluessel + ':' + JSON.stringify(liste);
|
||||
if (i >= 0) zeilen[i] = neu; else zeilen.push(neu);
|
||||
}
|
||||
|
||||
/** Paket in options.txt ein- bzw. austragen. Rückgabe: ob sich etwas geändert hat. */
|
||||
function trageEin(gameDir, an) {
|
||||
const datei = path.join(gameDir, 'options.txt');
|
||||
let text = '';
|
||||
try { text = fs.readFileSync(datei, 'utf8'); } catch { if (!an) return false; }
|
||||
const zeilen = text ? text.split(/\r?\n/) : [];
|
||||
while (zeilen.length && zeilen[zeilen.length - 1] === '') zeilen.pop();
|
||||
const aktiv = liesListe(zeilen, 'resourcePacks') || ['vanilla'];
|
||||
const unvertraeglich = liesListe(zeilen, 'incompatibleResourcePacks') || [];
|
||||
const vorher = JSON.stringify([aktiv, unvertraeglich]);
|
||||
const ohne = (l) => l.filter((p) => p !== PAKET_ID && p !== PAKET);
|
||||
let a = ohne(aktiv), u = ohne(unvertraeglich);
|
||||
if (an) {
|
||||
// direkt über den eingebauten Paketen, unter allen eigenen Ressourcenpaketen
|
||||
let pos = a.findIndex((p) => String(p).startsWith('file/'));
|
||||
if (pos < 0) pos = a.length;
|
||||
if (!a.includes('vanilla')) { a.unshift('vanilla'); pos++; }
|
||||
a.splice(Math.max(pos, a.indexOf('vanilla') + 1), 0, PAKET_ID);
|
||||
// NICHT in incompatibleResourcePacks eintragen: Steht ein passendes Paket dort,
|
||||
// streicht Minecraft es aus der Liste und lässt es dabei für diesen Start weg
|
||||
}
|
||||
if (JSON.stringify([a, u]) === vorher) return false;
|
||||
if (liesListe(zeilen, 'resourcePacks') || an) setzeListe(zeilen, 'resourcePacks', a);
|
||||
if (liesListe(zeilen, 'incompatibleResourcePacks')) setzeListe(zeilen, 'incompatibleResourcePacks', u);
|
||||
fs.writeFileSync(datei, zeilen.join('\n') + '\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- Einstieg ----------
|
||||
|
||||
/**
|
||||
* Vor jedem Spielstart: Paket anlegen und eintragen bzw. (ausgeschaltet)
|
||||
* wieder entfernen. Fehler brechen den Start nie ab.
|
||||
*/
|
||||
async function vorbereiten({ gameDir, version, clientJar, an, mods, protokoll }) {
|
||||
try {
|
||||
if ((an || mods) && unterstuetzt(version)) {
|
||||
await bauePaket(gameDir, version, clientJar, protokoll, { design: !!an, mods: !!mods });
|
||||
trageEin(gameDir, true);
|
||||
} else {
|
||||
if (trageEin(gameDir, false) || fs.existsSync(path.join(gameDir, 'resourcepacks', PAKET))) {
|
||||
fs.rmSync(path.join(gameDir, 'resourcepacks', PAKET), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (protokoll) protokoll('[AeroMC] Spieldesign nicht vorbereitet: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Grafiken für den Agenten nach userData kopieren; Rückgabe: Ordner oder '' */
|
||||
function agentOrdner(userData) {
|
||||
try {
|
||||
const ordner = path.join(userData, 'agent', 'spieldesign');
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
for (const n of fs.readdirSync(ASSETS)) {
|
||||
if (!/^(ladelogo(_alt)?\.png|icon_\d+\.rgba)$/.test(n)) continue;
|
||||
const q = path.join(ASSETS, n), z = path.join(ordner, n);
|
||||
const daten = fs.readFileSync(q);
|
||||
let gleich = false;
|
||||
try { gleich = fs.readFileSync(z).equals(daten); } catch { /* fehlt noch */ }
|
||||
if (!gleich) fs.writeFileSync(z, daten);
|
||||
}
|
||||
return ordner;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { vorbereiten, agentOrdner, unterstuetzt, paketFormat, mcmeta, pngLesen, pngSchreiben, trageEin, PAKET_ID };
|
||||
@@ -32,7 +32,7 @@ body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:mi
|
||||
:root[data-theme="dark"] .field select,
|
||||
:root[data-theme="dark"] .field textarea,
|
||||
:root[data-theme="dark"] .mods-search input,
|
||||
:root[data-theme="dark"] .path-row input,
|
||||
:root[data-theme="dark"] .path-row input,:root[data-theme="dark"] .path-row select,:root[data-theme="dark"] select.s-wert,:root[data-theme="dark"] input.s-wert,:root[data-theme="dark"] .s-paar input,:root[data-theme="dark"] .s-einheit input,
|
||||
:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)}
|
||||
:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618}
|
||||
:root[data-theme="dark"] .modal-head{background:#333337}
|
||||
@@ -133,8 +133,26 @@ body.is-popout>.titelleiste{display:none}
|
||||
.ui-animiert .list.einblenden .group-header{animation:karte-rein .42s cubic-bezier(.2,.8,.2,1) both;animation-delay:calc(var(--i,0) * 28ms)}
|
||||
@keyframes karte-rein{from{opacity:0;transform:translateY(12px) scale(.94)}}
|
||||
/* Block-Symbol hebt sich beim Darüberfahren und dreht sich ein Stück */
|
||||
/* … und kippt dabei leicht zur Maus (--kx/--ky setzt kippeZurMaus) */
|
||||
.ui-animiert .card-icon,.ui-animiert .card-icon-img{transition:transform .22s cubic-bezier(.2,.8,.2,1),filter .22s}
|
||||
.ui-animiert .card:hover .card-icon,.ui-animiert .card:hover .card-icon-img{transform:translateY(-4px) rotate(-7deg) scale(1.06);filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
|
||||
.ui-animiert .card:hover .card-icon,.ui-animiert .card:hover .card-icon-img{
|
||||
transform:perspective(220px) translateY(-4px) rotateX(calc(var(--ky,0) * -26deg)) rotateY(calc(var(--kx,0) * 30deg)) rotate(-4deg) scale(1.07);
|
||||
filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
|
||||
/* Zahnrad dreht sich beim Darüberfahren */
|
||||
.ui-animiert #btn-settings svg{transition:transform .6s cubic-bezier(.3,.7,.3,1)}
|
||||
.ui-animiert #btn-settings:hover svg{transform:rotate(120deg)}
|
||||
/* Karte einer laufenden Instanz: pulsierender Rand */
|
||||
.card.laeuft::after{content:'';position:absolute;inset:-1px;border:2px solid var(--accent);border-radius:inherit;pointer-events:none;opacity:.85}
|
||||
.ui-animiert .card.laeuft::after{animation:laeuft-puls 1.9s ease-in-out infinite}
|
||||
@keyframes laeuft-puls{0%,100%{opacity:.3;box-shadow:0 0 0 0 transparent}50%{opacity:1;box-shadow:0 0 12px 0 var(--accent)}}
|
||||
/* weggefallene Karte schrumpft an ihrer alten Stelle weg */
|
||||
.flip-geist{position:fixed!important;margin:0!important;pointer-events:none;z-index:50}
|
||||
/* Abbau-Risse auf dem Block (nur im Minecraft-Design sichtbar) */
|
||||
.card-riss{display:none;position:absolute;left:50%;top:14px;width:44px;height:44px;margin-left:-22px;background-size:100% 100%;image-rendering:pixelated;pointer-events:none;z-index:1}
|
||||
/* Lichtpunkte im Instanzbereich (nur AeroMC/Nachtlila mit bewegter Oberfläche) */
|
||||
.lichtpunkte{display:none;position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
|
||||
.lichtpunkte i{position:absolute;left:var(--x);top:var(--y);width:var(--g);height:var(--g);border-radius:50%;opacity:0;will-change:transform,opacity;animation:lichtpunkt var(--d) ease-in-out var(--v) infinite}
|
||||
@keyframes lichtpunkt{0%{opacity:0;transform:translate(0,0)}25%{opacity:var(--o)}75%{opacity:var(--o)}100%{opacity:0;transform:translate(var(--dx),var(--dy))}}
|
||||
/* Designwechsel weich überblenden */
|
||||
::view-transition-old(root),::view-transition-new(root){animation-duration:.45s;animation-timing-function:ease}
|
||||
.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px}
|
||||
@@ -150,6 +168,12 @@ body.is-popout>.titelleiste{display:none}
|
||||
.sbtn:disabled{opacity:.5;cursor:default}
|
||||
.sbtn:disabled:hover{background:none;border-color:transparent}
|
||||
.sbtn.play{font-weight:600}
|
||||
/* Starten-Knopf füllt sich während Download/Vorbereitung */
|
||||
.sbtn.play{position:relative;overflow:hidden;isolation:isolate}
|
||||
.sbtn.play .play-text{position:relative;z-index:1}
|
||||
.sbtn.play::before{content:'';position:absolute;left:0;top:0;bottom:0;width:0;background:var(--accent);opacity:.35;z-index:0;transition:width .35s ease}
|
||||
.sbtn.play.laedt::before{width:var(--fortschritt,0%)}
|
||||
.sbtn.play.laedt .play-text{font-weight:600;font-size:11.5px}
|
||||
.sbtn.del:hover{color:var(--danger)}
|
||||
|
||||
/* ---------- Instanzliste / Gruppen / Kacheln ---------- */
|
||||
@@ -364,6 +388,27 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
|
||||
.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
|
||||
.skin-bild canvas:active{cursor:grabbing}
|
||||
.popout-host .skin-bild{height:240px}
|
||||
.skin-armform-hinweis{display:flex;align-items:center;gap:10px;margin:0 0 10px;padding:8px 10px;border:1px solid #c9a227;border-radius:6px;background:rgba(201,162,39,.12);font-size:12px;line-height:1.45}
|
||||
.skin-armform-hinweis span{flex:1}
|
||||
.skin-armform-hinweis.hidden{display:none}
|
||||
/* Garderobe im Skin-Fenster */
|
||||
.skin-garderobe{margin-top:14px;padding-top:12px;border-top:1px solid var(--border)}
|
||||
.garderobe-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.garderobe-knoepfe{display:flex;gap:6px}
|
||||
.garderobe-liste{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px;margin-bottom:6px}
|
||||
.garderobe-leer{grid-column:1/-1;padding:14px;text-align:center;color:var(--dim);font-size:12px;border:1px dashed var(--border);border-radius:6px}
|
||||
.gard-karte{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 6px 6px;border:1px solid var(--border);border-radius:6px;background:rgba(127,127,127,.06)}
|
||||
.gard-bild{width:64px;height:96px;object-fit:contain;image-rendering:auto}
|
||||
.gard-bild.umhang{width:96px;height:48px;image-rendering:pixelated;object-fit:contain}
|
||||
.gard-name{width:100%;font-size:11.5px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.gard-merk{font-size:10px;color:var(--dim)}
|
||||
.gard-aktionen{display:flex;gap:4px;width:100%}
|
||||
.gard-aktionen .btn{flex:1;padding:3px 0;font-size:11px}
|
||||
.gard-mini{position:absolute;top:3px;border:none;background:none;color:var(--dim);cursor:pointer;font-size:12px;padding:2px 4px;border-radius:3px}
|
||||
.gard-mini:hover{background:rgba(127,127,127,.2);color:var(--text)}
|
||||
.gard-mini.links{left:3px}
|
||||
.gard-mini.rechts{right:3px}
|
||||
|
||||
.skin-umhang{margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)}
|
||||
.skin-umhang .field-row{margin-top:6px}
|
||||
.skin-umhang .field{margin-bottom:6px}
|
||||
@@ -536,8 +581,35 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px}
|
||||
.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px}
|
||||
.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))}
|
||||
.friend-row.gezeigt{box-shadow:inset 3px 0 0 var(--accent)}
|
||||
/* Bühne mit der 3D-Figur des Freundes */
|
||||
.freund-buehne{position:relative;height:190px;margin:10px 0 6px;border:1px solid var(--border);border-radius:8px;overflow:hidden;
|
||||
background:radial-gradient(ellipse at 50% 85%,rgba(127,127,127,.18),transparent 60%)}
|
||||
.freund-buehne.hidden{display:none}
|
||||
.freund-buehne canvas{display:block;width:100%;height:100%}
|
||||
.freund-buehne-text{position:absolute;left:10px;bottom:8px;display:flex;flex-direction:column;gap:1px;pointer-events:none;text-shadow:0 1px 3px rgba(0,0,0,.6)}
|
||||
.freund-buehne-text b{font-size:13px}
|
||||
.freund-buehne-text span{font-size:11px;color:var(--dim)}
|
||||
/* Popout: Liste links, Figur rechts daneben (füllt die Höhe); schmal -> untereinander */
|
||||
#modal-friends.popout-host .modal-body{display:flex;flex-direction:column}
|
||||
#modal-friends.popout-host .freunde-raster{flex:1 1 auto;min-height:0}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)){display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,42%);gap:14px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freunde-spalte{min-height:0;overflow:auto;padding-right:2px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne{height:auto;margin:0}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne-text{left:14px;bottom:12px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne-text b{font-size:15px}
|
||||
@media (max-width:620px){
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)){display:flex;flex-direction:column-reverse;justify-content:flex-end}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freund-buehne{flex:none;height:190px;margin:10px 0 6px}
|
||||
#modal-friends.popout-host .freunde-raster:has(>.freund-buehne:not(.hidden)) .freunde-spalte{overflow:visible}
|
||||
}
|
||||
.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto}
|
||||
.friend-dot.online{background:#3bb54a}
|
||||
/* Freund kommt online: Kopf hüpft, Punkt pulsiert einmal */
|
||||
.ui-animiert .friend-row.kommt-online .friend-avatar{animation:kopf-huepfen .75s cubic-bezier(.3,.7,.4,1) 2}
|
||||
@keyframes kopf-huepfen{0%,100%{transform:none}30%{transform:translateY(-7px) scale(1.08)}55%{transform:translateY(0) scale(1.05,.94)}75%{transform:translateY(-2px)}}
|
||||
.ui-animiert .friend-row.kommt-online .friend-dot{animation:punkt-puls 1.1s ease-out 2}
|
||||
@keyframes punkt-puls{0%{box-shadow:0 0 0 0 rgba(59,181,74,.85)}100%{box-shadow:0 0 0 10px rgba(59,181,74,0)}}
|
||||
.friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain}
|
||||
.friend-info{flex:1;min-width:0}
|
||||
.friend-name{font-size:13px}
|
||||
@@ -576,6 +648,45 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.settings-hero h4{margin:0 0 6px;font-size:20px;font-weight:700}
|
||||
.settings-hero p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start}
|
||||
/* Optionen: eine Spalte, jede Einstellung eine Zeile (Text links, Bedienelement rechts) */
|
||||
#modal-settings .settings-grid{grid-template-columns:minmax(0,1fr);max-width:760px}
|
||||
#modal-settings .settings-card-wide{grid-column:auto}
|
||||
#modal-settings .settings-card{padding:14px 18px 6px}
|
||||
#modal-settings .settings-card h5{margin:0 0 4px}
|
||||
.s-zeile{display:flex;align-items:center;gap:18px;padding:11px 0;border-top:1px solid var(--border)}
|
||||
.settings-card h5+.s-zeile,.s-vorab+.s-zeile{border-top:none}
|
||||
.s-zeile.s-voll{flex-direction:column;align-items:stretch;gap:8px}
|
||||
.s-text{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
.s-text b{font-size:13px;font-weight:600}
|
||||
.s-text small{font-size:12px;color:var(--dim);line-height:1.45}
|
||||
.s-wert{flex:0 0 auto;width:230px}
|
||||
.s-wert.s-breit{width:min(320px,48%)}
|
||||
.s-wert.s-zahl{width:110px}
|
||||
select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
|
||||
.s-paar,.s-einheit,.s-regler{display:flex;align-items:center;gap:8px}
|
||||
.s-paar input,.s-einheit input{width:0;flex:1 1 0;min-width:0;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
|
||||
.s-paar span,.s-einheit span,.s-regler>span{color:var(--dim);font-size:12px;white-space:nowrap}
|
||||
.s-regler input{flex:1;min-width:0}
|
||||
.s-regler>span{min-width:42px;text-align:right}
|
||||
.s-vorab{display:block;margin:2px 0 6px;color:var(--dim)}
|
||||
.s-warnung{color:#d9822b}
|
||||
#s-backup-status,#s-restore-status{display:block;margin:-4px 0 10px}
|
||||
.s-felder{padding:0 0 6px}
|
||||
.s-zeile .field-row .field{margin-bottom:0}
|
||||
.s-zeile .path-row select{flex:1;min-width:0;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
|
||||
.s-zeile>.btn{flex:none;white-space:nowrap}
|
||||
.settings-card .backup-progress{margin:0 0 12px}
|
||||
/* Schalter (statt Häkchen) */
|
||||
.schalter{position:relative;flex:none;width:40px;height:22px;cursor:pointer}
|
||||
.schalter input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;z-index:1}
|
||||
.schalter i{position:absolute;inset:0;border-radius:11px;background:rgba(127,127,127,.38);transition:background .18s}
|
||||
.schalter i::after{content:'';position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
|
||||
.schalter input:checked+i{background:var(--accent)}
|
||||
.schalter input:checked+i::after{transform:translateX(18px)}
|
||||
.schalter input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.schalter input:disabled{cursor:default}
|
||||
.schalter input:disabled+i{opacity:.45}
|
||||
@media (max-width:640px){.s-zeile{flex-wrap:wrap}.s-wert,.s-wert.s-breit{width:100%}}
|
||||
.settings-grid-stack{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.settings-card{background:rgba(255,255,255,.7);border:1px solid var(--border);border-radius:12px;padding:16px 16px 12px;box-shadow:0 1px 0 rgba(255,255,255,.5)}
|
||||
.settings-card h5{margin:0 0 14px;font-size:13px;font-weight:700;color:var(--text)}
|
||||
@@ -651,7 +762,27 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.upd-arrow{color:var(--dim)}
|
||||
.upd-newv{font-weight:700;color:var(--accent)}
|
||||
.upd-channel{margin:-6px 0 12px;text-align:center}
|
||||
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px}
|
||||
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 14px;font-size:12.5px;line-height:1.5;margin-bottom:12px;overflow-wrap:anywhere}
|
||||
/* Markdown in den Update-Hinweisen */
|
||||
.upd-notes>:first-child{margin-top:0}
|
||||
.upd-notes>:last-child{margin-bottom:0}
|
||||
.upd-notes h4{margin:14px 0 6px;font-size:15px;font-weight:700}
|
||||
.upd-notes h5{margin:12px 0 4px;font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.05em}
|
||||
.upd-notes h6{margin:10px 0 4px;font-size:12.5px;font-weight:700}
|
||||
.upd-notes p{margin:6px 0}
|
||||
.upd-notes ul,.upd-notes ol{margin:4px 0 8px;padding-left:20px}
|
||||
.upd-notes ul ul,.upd-notes ol ul,.upd-notes ul ol{margin:3px 0 2px}
|
||||
.upd-notes li{margin:3px 0}
|
||||
.upd-notes li::marker{color:var(--accent)}
|
||||
.upd-notes code{padding:1px 5px;border-radius:4px;background:rgba(127,127,127,.18);font-family:Consolas,"Cascadia Mono",monospace;font-size:11.5px}
|
||||
.upd-notes pre{margin:8px 0;padding:8px 10px;border-radius:6px;background:rgba(127,127,127,.14);overflow:auto}
|
||||
.upd-notes pre code{padding:0;background:none}
|
||||
.upd-notes blockquote{margin:8px 0;padding:2px 0 2px 10px;border-left:3px solid var(--accent);color:var(--dim)}
|
||||
.upd-notes hr{border:none;border-top:1px solid var(--border);margin:12px 0}
|
||||
.upd-notes a{color:var(--link,var(--accent));text-decoration:underline;cursor:pointer}
|
||||
/* als eigenes Fenster: Hinweise füllen den freien Platz */
|
||||
#modal-update.popout-host .modal-body{display:flex;flex-direction:column}
|
||||
#modal-update.popout-host .upd-notes{flex:1 1 auto;max-height:none;min-height:120px}
|
||||
.upd-progress{margin:10px 0}
|
||||
.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden}
|
||||
.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s}
|
||||
@@ -682,6 +813,58 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.ctx-strong{font-weight:600}
|
||||
.ctx-danger:hover{background:var(--danger);color:#fff}
|
||||
.ctx-sep{height:1px;background:var(--border);margin:4px 6px}
|
||||
/* Erfolg-Meldung wie im Spiel („Erfolg erzielt!“) */
|
||||
.erfolg-meldungen{position:fixed;top:92px;right:14px;z-index:300;display:flex;flex-direction:column;gap:8px}
|
||||
/* Grundform (Klassisch): Karte in den Farben des Designs */
|
||||
.erfolg{position:relative;display:flex;align-items:center;gap:10px;min-width:250px;max-width:320px;padding:9px 14px 9px 9px;cursor:pointer;
|
||||
background:var(--win);border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:6px;
|
||||
box-shadow:0 10px 26px rgba(0,0,0,.35);color:var(--text)}
|
||||
.erfolg-symbol{width:36px;height:36px;flex:none;display:grid;place-items:center;border-radius:5px;background:rgba(127,127,127,.15)}
|
||||
.erfolg-symbol svg,.erfolg-symbol img{width:28px;height:28px}
|
||||
.erfolg-kopf{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;line-height:1.3}
|
||||
.erfolg-name{font-size:13px;font-weight:600;line-height:1.3}
|
||||
/* Funken (alle Designs außer Minecraft) und XP-Kugeln (Minecraft) */
|
||||
.erfolg .funke{position:absolute;z-index:2;width:6px;height:6px;border-radius:50%;background:#fff;box-shadow:0 0 5px 2px var(--accent),0 0 12px 3px var(--accent);pointer-events:none}
|
||||
.erfolg .xp-kugel{position:absolute;width:8px;height:8px;margin-top:-4px;border-radius:50%;pointer-events:none;
|
||||
background:radial-gradient(circle at 35% 35%,#fbff9c 0 25%,#b8f531 45%,#4f9a12 80%);box-shadow:0 0 6px 1px rgba(182,255,58,.75)}
|
||||
.ui-animiert .erfolg{animation:erfolg-rein .45s cubic-bezier(.2,.8,.2,1) both}
|
||||
.ui-animiert .erfolg.weg{animation:erfolg-raus .35s ease-in both}
|
||||
@keyframes erfolg-rein{from{transform:translateX(120%);opacity:0}}
|
||||
@keyframes erfolg-raus{to{transform:translateX(120%);opacity:0}}
|
||||
/* Übersicht in der Statistik */
|
||||
.erfolge-liste{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:6px;margin-bottom:12px}
|
||||
.erfolg-karte{display:flex;align-items:center;gap:9px;padding:6px 8px;border:1px solid var(--border);border-radius:6px}
|
||||
.erfolg-karte .erfolg-symbol{width:32px;height:32px}
|
||||
.erfolg-karte b{display:block;font-size:12px}
|
||||
.erfolg-karte small{display:block;font-size:11px;color:var(--dim);line-height:1.3}
|
||||
.erfolg-karte.offen{opacity:.45;filter:grayscale(1)}
|
||||
|
||||
/* Mod-Schalter: zwei Listen wie beim Ressourcenpaket-Bildschirm */
|
||||
#modal-modschalter.popout-host .modal-body{display:flex;flex-direction:column}
|
||||
.ms-instanz{font-weight:400;color:var(--dim);font-size:12px;margin-left:6px}
|
||||
.ms-kopf{display:flex;align-items:center;gap:12px;margin-bottom:10px}
|
||||
.ms-kopf input{flex:1;max-width:320px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
|
||||
.ms-kopf input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px rgba(64,126,232,.25)}
|
||||
.ms-spalten{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.ms-spalte{display:flex;flex-direction:column;min-height:0;border:1px solid var(--border);border-radius:8px;background:rgba(127,127,127,.06)}
|
||||
.ms-titel{padding:8px 12px;font-weight:600;font-size:12.5px;border-bottom:1px solid var(--border)}
|
||||
.ms-liste{flex:1 1 auto;min-height:0;overflow:auto;padding:6px}
|
||||
.ms-mod{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;cursor:pointer;border:1px solid transparent}
|
||||
.ms-mod:hover{background:rgba(127,127,127,.12);border-color:var(--border)}
|
||||
.ms-icon{width:32px;height:32px;flex:none;border-radius:6px;display:grid;place-items:center;background:rgba(127,127,127,.15);font-size:17px;overflow:hidden}
|
||||
.ms-icon img{width:100%;height:100%;object-fit:cover}
|
||||
.ms-info{flex:1;min-width:0}
|
||||
.ms-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ms-meta{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ms-warn{font-size:11px;color:#d9822b}
|
||||
.ms-pfeil{flex:none;width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--win);color:var(--text);cursor:pointer;font-size:14px;opacity:.55}
|
||||
.ms-mod:hover .ms-pfeil{opacity:1;border-color:var(--accent);color:var(--accent)}
|
||||
.ms-neu{font-size:10.5px;font-weight:700;color:var(--accent);margin-left:6px}
|
||||
.ms-leer{color:var(--dim);font-size:12px;padding:14px;text-align:center}
|
||||
.ms-status{margin-right:auto}
|
||||
.ui-animiert .ms-mod.kommt{animation:ms-rein .28s cubic-bezier(.2,.8,.2,1)}
|
||||
@keyframes ms-rein{from{opacity:0;transform:translateX(var(--ms-von,12px))}}
|
||||
|
||||
/* Konto-Menü oben rechts (wie in MultiMC) */
|
||||
.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
|
||||
.konto-menu{min-width:220px}
|
||||
@@ -758,3 +941,20 @@ img.account-av{object-fit:cover;image-rendering:auto}
|
||||
.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
|
||||
.se-fuss{align-items:center}
|
||||
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* Manipulationsschutz: gesperrte Instanz (Verbotszeichen, siehe integritaet.js) */
|
||||
.card.gesperrt .card-icon,.card.gesperrt .card-icon-img{filter:grayscale(1) brightness(.55)}
|
||||
.card-verbot{position:absolute;left:50%;top:6px;width:60px;height:60px;margin-left:-30px;opacity:.95;pointer-events:none;z-index:2;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}
|
||||
.card.gesperrt .card-name{color:#e05555}
|
||||
.card.gesperrt.sel .card-name{color:#fff;background:#b02020}
|
||||
.side-ico{position:relative}
|
||||
.side-ico.gesperrt > :not(.side-verbot){filter:grayscale(1) brightness(.55)}
|
||||
.side-ico img.side-verbot{position:absolute;left:-4px;top:-4px;width:60px;height:60px;object-fit:contain;border-radius:0;pointer-events:none}
|
||||
.side-gesperrt{margin:8px 6px 2px;padding:9px 10px;border:1px solid rgba(200,0,0,.55);background:rgba(200,0,0,.10);border-radius:6px;font-size:12px}
|
||||
.side-gesperrt-titel{display:flex;align-items:center;gap:6px;font-weight:700;color:#e04848;font-size:13px;margin-bottom:4px}
|
||||
.side-gesperrt-titel img{width:18px;height:18px}
|
||||
.side-gesperrt-text{line-height:1.35;margin:4px 0}
|
||||
.side-gesperrt-text.dim{opacity:.7;font-size:11px}
|
||||
.side-gesperrt-gruende{margin:4px 0 8px;padding-left:16px;max-height:110px;overflow:auto;word-break:break-word;line-height:1.35}
|
||||
.side-gesperrt-gruende .dim{opacity:.7;list-style:none;margin-left:-16px}
|
||||
#p-wiederherstellen{width:100%;margin:4px 0}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAXxt1cCntDQzvej4hjyVZt6Onj6Uv3hSlneJaOxxmsds=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,370 +1,495 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* updater.js – automatische Updates über die Gitea-Releases
|
||||
* ---------------------------------------------------------
|
||||
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
||||
* bei Bedarf den Windows-Installer herunterladen und starten.
|
||||
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
|
||||
/*
|
||||
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
||||
*
|
||||
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
||||
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
||||
* 'unknown' und gar kein automatisches Update anbieten.
|
||||
*/
|
||||
let cachedKind = null;
|
||||
function detectInstallKind() {
|
||||
if (cachedKind) return cachedKind;
|
||||
cachedKind = computeInstallKind();
|
||||
return cachedKind;
|
||||
}
|
||||
|
||||
function computeInstallKind() {
|
||||
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
||||
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
||||
|
||||
const exe = process.execPath || '';
|
||||
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||||
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||||
|
||||
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
|
||||
// da ist, ist es wirklich eine Installation.
|
||||
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||||
|
||||
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function hasUninstaller(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
||||
function portableTarget() {
|
||||
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
||||
if (p) return p;
|
||||
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
||||
const exe = process.execPath || '';
|
||||
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
||||
}
|
||||
|
||||
function updateDir() {
|
||||
return path.join(os.tmpdir(), 'aeromc-update');
|
||||
}
|
||||
|
||||
/*
|
||||
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
||||
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
||||
* von einem laufenden Update benutzt werden.
|
||||
*/
|
||||
function cleanupUpdateCache() {
|
||||
try {
|
||||
const dir = updateDir();
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const p = path.join(dir, name);
|
||||
try {
|
||||
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
||||
fs.rmSync(p, { force: true, recursive: true });
|
||||
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
||||
}
|
||||
} catch { /* egal */ }
|
||||
}
|
||||
|
||||
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
||||
function parseVersion(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||
if (!m) return null;
|
||||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||||
}
|
||||
|
||||
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
||||
function parsePreRelease(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
||||
return m ? m[1].split('.') : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
|
||||
*
|
||||
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
|
||||
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
|
||||
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
|
||||
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
|
||||
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
|
||||
*/
|
||||
function comparePreRelease(a, b) {
|
||||
if (!a || !b) return 0;
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i]; const y = b[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
||||
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
||||
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
||||
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
||||
if (ny !== null) return 1;
|
||||
if (x !== y) return x < y ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// >0 wenn a neuer als b
|
||||
function compareVersions(a, b) {
|
||||
const x = parseVersion(a); const y = parseVersion(b);
|
||||
if (!x || !y) return 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (x[i] !== y[i]) return x[i] - y[i];
|
||||
}
|
||||
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
||||
}
|
||||
|
||||
/*
|
||||
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
||||
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
||||
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
||||
* das falsche.
|
||||
*/
|
||||
function assetKind(name) {
|
||||
const n = String(name || '');
|
||||
if (!/\.exe$/i.test(n)) return null;
|
||||
const portable = /portable/i.test(n);
|
||||
const setup = /setup|install(er)?/i.test(n);
|
||||
if (portable && setup) return null;
|
||||
if (portable) return 'portable';
|
||||
if (setup) return 'setup';
|
||||
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
||||
}
|
||||
|
||||
// passendes Windows-Artefakt im Release finden
|
||||
function pickAsset(release, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
||||
const assets = release.assets || [];
|
||||
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Prüft auf Updates.
|
||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||
*/
|
||||
async function checkForUpdate(currentVersion) {
|
||||
const installKind = detectInstallKind();
|
||||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
||||
const list = await res.json();
|
||||
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
||||
if (!releases.length) {
|
||||
return { available: false, noReleases: true, currentVersion };
|
||||
}
|
||||
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
||||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
||||
const latest = releases[0];
|
||||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
||||
const asset = pickAsset(latest, installKind);
|
||||
return {
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
|
||||
installKind,
|
||||
notes: latest.body || '',
|
||||
published: latest.published_at || latest.created_at || null,
|
||||
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
|
||||
url: latest.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
||||
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
||||
*/
|
||||
function assertMatchingKind(fileName, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') {
|
||||
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
||||
}
|
||||
const kind = assetKind(fileName);
|
||||
if (!kind) {
|
||||
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
||||
}
|
||||
if (kind !== installKind) {
|
||||
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
||||
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
||||
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||||
* onProgress: ({ phase, loaded, total, percent })
|
||||
*/
|
||||
async function downloadAndInstall(asset, onProgress) {
|
||||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||||
const installKind = detectInstallKind();
|
||||
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
||||
// sonst kontrolliert sich die Angabe nur selbst.
|
||||
assertMatchingKind(asset.name, installKind);
|
||||
|
||||
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
||||
const target = installKind === 'portable' ? portableTarget() : '';
|
||||
if (installKind === 'portable' && !target) {
|
||||
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
||||
}
|
||||
|
||||
const dir = updateDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||
try {
|
||||
fs.rmSync(dest, { force: true });
|
||||
} catch {
|
||||
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
||||
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
||||
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
||||
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
||||
const parsed = path.parse(dest);
|
||||
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
||||
}
|
||||
|
||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
|
||||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||||
|
||||
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
||||
const out = fs.createWriteStream(dest);
|
||||
const hash = crypto.createHash('sha256');
|
||||
let loaded = 0;
|
||||
let lastReport = 0;
|
||||
try {
|
||||
for await (const chunk of res.body) {
|
||||
const buf = Buffer.from(chunk);
|
||||
hash.update(buf);
|
||||
loaded += buf.length;
|
||||
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
||||
// sonst sammeln sich über den ganzen Download hunderte davon an
|
||||
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||||
const onDrain = () => { out.off('error', onError); resolve(); };
|
||||
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||||
out.once('drain', onDrain);
|
||||
out.once('error', onError);
|
||||
});
|
||||
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
||||
if (onProgress && Date.now() - lastReport >= 250) {
|
||||
lastReport = Date.now();
|
||||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
out.once('error', reject);
|
||||
out.end((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
} catch (err) {
|
||||
out.destroy();
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (total && loaded !== total) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error('Download unvollständig.');
|
||||
}
|
||||
|
||||
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||||
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
|
||||
}
|
||||
|
||||
/*
|
||||
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
||||
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
||||
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
||||
*/
|
||||
const SWAP_SCRIPT = `@echo off
|
||||
setlocal
|
||||
set "SRC=%~1"
|
||||
set "DST=%~2"
|
||||
set "LOG=%~dp0update.log"
|
||||
set TRIES=0
|
||||
:loop
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
copy /y "%SRC%" "%DST%" >nul 2>&1
|
||||
if not errorlevel 1 goto done
|
||||
set /a TRIES+=1
|
||||
if %TRIES% GEQ 60 goto fail
|
||||
goto loop
|
||||
:done
|
||||
del /f /q "%SRC%" >nul 2>&1
|
||||
start "" "%DST%"
|
||||
exit /b 0
|
||||
:fail
|
||||
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
||||
exit /b 1
|
||||
`;
|
||||
|
||||
/*
|
||||
* Wendet das geladene Update an.
|
||||
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
|
||||
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
||||
* Der Launcher muss sich danach beenden.
|
||||
*/
|
||||
function applyUpdate(file) {
|
||||
const installKind = detectInstallKind();
|
||||
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
||||
// wirklich zu dieser Paketart?
|
||||
assertMatchingKind(path.basename(file.path || ''), installKind);
|
||||
const kind = installKind;
|
||||
|
||||
if (kind !== 'portable') {
|
||||
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
|
||||
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
return { started: 'installer' };
|
||||
}
|
||||
|
||||
const target = file.target || portableTarget();
|
||||
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
|
||||
|
||||
const script = path.join(updateDir(), 'aeromc-swap.cmd');
|
||||
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
|
||||
|
||||
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
|
||||
const child = spawn(process.env.COMSPEC || 'cmd.exe',
|
||||
['/c', 'start', '', '/min', script, file.path, target],
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true });
|
||||
child.unref();
|
||||
return { started: 'swap', target };
|
||||
}
|
||||
|
||||
// Rückwärtskompatibel: nur für die Setup-Variante
|
||||
function runInstaller(installerPath) {
|
||||
return applyUpdate({ path: installerPath, kind: 'setup' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
applyUpdate,
|
||||
runInstaller,
|
||||
cleanupUpdateCache,
|
||||
compareVersions,
|
||||
parseVersion,
|
||||
detectInstallKind,
|
||||
assetKind,
|
||||
portableTarget,
|
||||
};
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* updater.js – automatische Updates über die Gitea-Releases
|
||||
* ---------------------------------------------------------
|
||||
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
||||
* bei Bedarf den Windows-Installer herunterladen und starten.
|
||||
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
// Downloads nur von hier – eine Release-Datei mit fremder Adresse wird abgelehnt
|
||||
const ERLAUBTER_HOST = 'git.viper.ipv64.net';
|
||||
// Die Signaturen liegen nicht im Gitea-Release, sondern auf der WordPress-Seite
|
||||
// (Plugin aeromc-friends, includes/signaturen.php). Hochgeladen werden sie beim
|
||||
// Bauen automatisch von scripts/sign-release.js.
|
||||
const SIGNATUR_API = 'https://viper-network.de/wp-json/aeromc/v1/update-signatur';
|
||||
|
||||
/*
|
||||
* Sicherheit: Jede Update-Datei muss mit dem privaten Release-Schlüssel signiert
|
||||
* sein (Ed25519). Der liegt nur auf dem Rechner, auf dem gebaut wird – nie auf
|
||||
* dem Server. Wer den Gitea-Server oder das Konto übernimmt, kann deshalb zwar
|
||||
* Dateien hochladen, aber keine gültige Signatur erzeugen; der Launcher
|
||||
* installiert so etwas nicht. Signiert wird beim Bauen von
|
||||
* scripts/sign-release.js; die Signatur liegt auf einem zweiten Server
|
||||
* (SIGNATUR_API) – woher sie kommt, ist für die Sicherheit aber egal, fälschen
|
||||
* kann sie ohne den Schlüssel niemand.
|
||||
*
|
||||
* Unterschrieben wird nicht nur der Inhalt (SHA-256), sondern auch Dateiname
|
||||
* und Version. So lässt sich weder eine Portable- als Setup-Datei ausgeben noch
|
||||
* eine alte, signierte Fassung als "neues" Update unterschieben.
|
||||
*/
|
||||
const SIGNATUR_FORMAT = 'AeroMC-Update/1';
|
||||
let oeffentlicherSchluessel = null;
|
||||
function releaseSchluessel() {
|
||||
if (!oeffentlicherSchluessel) {
|
||||
const pem = fs.readFileSync(path.join(__dirname, 'update-schluessel.pem'), 'utf8');
|
||||
oeffentlicherSchluessel = crypto.createPublicKey(pem);
|
||||
}
|
||||
return oeffentlicherSchluessel;
|
||||
}
|
||||
|
||||
function normVersion(v) {
|
||||
return String(v || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
// Der Text, der unterschrieben wird (identisch in scripts/sign-release.js)
|
||||
function signaturText(datei, version, sha256) {
|
||||
return Buffer.from([SIGNATUR_FORMAT, datei, normVersion(version), String(sha256).toLowerCase()].join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
// Prüft eine Signatur (Inhalt der .sig-Datei) gegen Name, Version und Hash
|
||||
function pruefeSignatur(sig, datei, version, sha256) {
|
||||
if (!sig || sig.format !== SIGNATUR_FORMAT) throw new Error('Signaturdatei hat ein unbekanntes Format.');
|
||||
if (sig.datei !== datei) throw new Error('Signatur gehört zu einer anderen Datei.');
|
||||
if (normVersion(sig.version) !== normVersion(version)) throw new Error('Signatur gehört zu einer anderen Version.');
|
||||
if (String(sig.sha256 || '').toLowerCase() !== String(sha256).toLowerCase()) {
|
||||
throw new Error('Die heruntergeladene Datei passt nicht zur Signatur – Update abgebrochen.');
|
||||
}
|
||||
const ok = crypto.verify(null, signaturText(datei, version, sha256),
|
||||
releaseSchluessel(), Buffer.from(String(sig.signatur || ''), 'base64'));
|
||||
if (!ok) throw new Error('Ungültige Signatur – die Datei stammt nicht vom AeroMC-Entwickler. Update abgebrochen.');
|
||||
}
|
||||
|
||||
function erlaubteAdresse(adresse) {
|
||||
try {
|
||||
const u = new URL(String(adresse || ''));
|
||||
return u.protocol === 'https:' && u.hostname.toLowerCase() === ERLAUBTER_HOST;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/*
|
||||
* Signatur einer Release-Datei vom WordPress-Server holen.
|
||||
* -> Signatur-Objekt, null wenn es (noch) keine gibt; wirft bei Netzwerkfehlern.
|
||||
*/
|
||||
async function holeSignatur(datei, version) {
|
||||
const adresse = `${SIGNATUR_API}?datei=${encodeURIComponent(datei)}&version=${encodeURIComponent(normVersion(version))}`;
|
||||
const res = await fetch(adresse, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error('Signatur-Server antwortet mit HTTP ' + res.status);
|
||||
const text = await res.text();
|
||||
if (text.length > 16384) throw new Error('Signatur ist ungültig.');
|
||||
const sig = JSON.parse(text);
|
||||
if (!sig || typeof sig !== 'object' || sig.datei !== datei || normVersion(sig.version) !== normVersion(version)) {
|
||||
throw new Error('Signatur passt nicht zu diesem Update.');
|
||||
}
|
||||
return { format: sig.format, datei: sig.datei, version: sig.version, sha256: sig.sha256, signatur: sig.signatur };
|
||||
}
|
||||
|
||||
function sha256Datei(datei) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash('sha256');
|
||||
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
||||
*
|
||||
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
||||
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
||||
* 'unknown' und gar kein automatisches Update anbieten.
|
||||
*/
|
||||
let cachedKind = null;
|
||||
function detectInstallKind() {
|
||||
if (cachedKind) return cachedKind;
|
||||
cachedKind = computeInstallKind();
|
||||
return cachedKind;
|
||||
}
|
||||
|
||||
function computeInstallKind() {
|
||||
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
||||
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
||||
|
||||
const exe = process.execPath || '';
|
||||
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||||
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||||
|
||||
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
|
||||
// da ist, ist es wirklich eine Installation.
|
||||
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||||
|
||||
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function hasUninstaller(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
||||
function portableTarget() {
|
||||
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
||||
if (p) return p;
|
||||
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
||||
const exe = process.execPath || '';
|
||||
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
||||
}
|
||||
|
||||
function updateDir() {
|
||||
return path.join(os.tmpdir(), 'aeromc-update');
|
||||
}
|
||||
|
||||
/*
|
||||
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
||||
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
||||
* von einem laufenden Update benutzt werden.
|
||||
*/
|
||||
function cleanupUpdateCache() {
|
||||
try {
|
||||
const dir = updateDir();
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const p = path.join(dir, name);
|
||||
try {
|
||||
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
||||
fs.rmSync(p, { force: true, recursive: true });
|
||||
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
||||
}
|
||||
} catch { /* egal */ }
|
||||
}
|
||||
|
||||
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
||||
function parseVersion(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||
if (!m) return null;
|
||||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||||
}
|
||||
|
||||
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
||||
function parsePreRelease(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
||||
return m ? m[1].split('.') : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
|
||||
*
|
||||
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
|
||||
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
|
||||
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
|
||||
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
|
||||
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
|
||||
*/
|
||||
function comparePreRelease(a, b) {
|
||||
if (!a || !b) return 0;
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i]; const y = b[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
||||
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
||||
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
||||
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
||||
if (ny !== null) return 1;
|
||||
if (x !== y) return x < y ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// >0 wenn a neuer als b
|
||||
function compareVersions(a, b) {
|
||||
const x = parseVersion(a); const y = parseVersion(b);
|
||||
if (!x || !y) return 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (x[i] !== y[i]) return x[i] - y[i];
|
||||
}
|
||||
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
||||
}
|
||||
|
||||
/*
|
||||
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
||||
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
||||
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
||||
* das falsche.
|
||||
*/
|
||||
function assetKind(name) {
|
||||
const n = String(name || '');
|
||||
if (!/\.exe$/i.test(n)) return null;
|
||||
const portable = /portable/i.test(n);
|
||||
const setup = /setup|install(er)?/i.test(n);
|
||||
if (portable && setup) return null;
|
||||
if (portable) return 'portable';
|
||||
if (setup) return 'setup';
|
||||
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
||||
}
|
||||
|
||||
// passendes Windows-Artefakt im Release finden
|
||||
function pickAsset(release, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
||||
const assets = release.assets || [];
|
||||
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Prüft auf Updates.
|
||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||
*/
|
||||
async function checkForUpdate(currentVersion) {
|
||||
const installKind = detectInstallKind();
|
||||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
||||
const list = await res.json();
|
||||
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
||||
if (!releases.length) {
|
||||
return { available: false, noReleases: true, currentVersion };
|
||||
}
|
||||
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
||||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
||||
const latest = releases[0];
|
||||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
||||
const latestVersion = normVersion(latest.tag_name);
|
||||
let asset = pickAsset(latest, installKind);
|
||||
// ohne Signatur oder von fremder Adresse wird nichts automatisch installiert
|
||||
let unsigniert = false;
|
||||
let signaturHinweis = '';
|
||||
let sig = null;
|
||||
if (asset && !erlaubteAdresse(asset.browser_download_url)) {
|
||||
asset = null;
|
||||
unsigniert = true;
|
||||
} else if (asset && available) {
|
||||
try {
|
||||
sig = await holeSignatur(asset.name, latestVersion);
|
||||
} catch (err) {
|
||||
signaturHinweis = 'Die Signatur des Updates ließ sich gerade nicht abrufen (' + (err.message || err) + '). Bitte später erneut versuchen.';
|
||||
}
|
||||
if (!sig) {
|
||||
asset = null;
|
||||
unsigniert = !signaturHinweis;
|
||||
}
|
||||
}
|
||||
return {
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
installKind,
|
||||
unsigniert,
|
||||
signaturHinweis,
|
||||
notes: latest.body || '',
|
||||
published: latest.published_at || latest.created_at || null,
|
||||
asset: asset ? {
|
||||
name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind,
|
||||
version: latestVersion, signatur: sig,
|
||||
} : null,
|
||||
url: latest.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
||||
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
||||
*/
|
||||
function assertMatchingKind(fileName, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') {
|
||||
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
||||
}
|
||||
const kind = assetKind(fileName);
|
||||
if (!kind) {
|
||||
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
||||
}
|
||||
if (kind !== installKind) {
|
||||
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
||||
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
||||
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||||
* onProgress: ({ phase, loaded, total, percent })
|
||||
*/
|
||||
async function downloadAndInstall(asset, onProgress) {
|
||||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||||
const installKind = detectInstallKind();
|
||||
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
||||
// sonst kontrolliert sich die Angabe nur selbst.
|
||||
assertMatchingKind(asset.name, installKind);
|
||||
|
||||
if (!erlaubteAdresse(asset.url)) {
|
||||
throw new Error('Das Update kommt nicht vom AeroMC-Server – abgebrochen.');
|
||||
}
|
||||
// ohne Signatur wird gar nicht erst heruntergeladen
|
||||
const signatur = asset.signatur;
|
||||
if (!signatur || signatur.datei !== asset.name || normVersion(signatur.version) !== normVersion(asset.version)) {
|
||||
throw new Error('Signatur passt nicht zu diesem Update – abgebrochen.');
|
||||
}
|
||||
|
||||
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
||||
const target = installKind === 'portable' ? portableTarget() : '';
|
||||
if (installKind === 'portable' && !target) {
|
||||
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
||||
}
|
||||
|
||||
const dir = updateDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||
try {
|
||||
fs.rmSync(dest, { force: true });
|
||||
} catch {
|
||||
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
||||
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
||||
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
||||
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
||||
const parsed = path.parse(dest);
|
||||
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
||||
}
|
||||
|
||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
|
||||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||||
|
||||
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
||||
const out = fs.createWriteStream(dest);
|
||||
const hash = crypto.createHash('sha256');
|
||||
let loaded = 0;
|
||||
let lastReport = 0;
|
||||
try {
|
||||
for await (const chunk of res.body) {
|
||||
const buf = Buffer.from(chunk);
|
||||
hash.update(buf);
|
||||
loaded += buf.length;
|
||||
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
||||
// sonst sammeln sich über den ganzen Download hunderte davon an
|
||||
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||||
const onDrain = () => { out.off('error', onError); resolve(); };
|
||||
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||||
out.once('drain', onDrain);
|
||||
out.once('error', onError);
|
||||
});
|
||||
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
||||
if (onProgress && Date.now() - lastReport >= 250) {
|
||||
lastReport = Date.now();
|
||||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
out.once('error', reject);
|
||||
out.end((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
} catch (err) {
|
||||
out.destroy();
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (total && loaded !== total) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error('Download unvollständig.');
|
||||
}
|
||||
|
||||
const sha256 = hash.digest('hex');
|
||||
try {
|
||||
pruefeSignatur(signatur, asset.name, asset.version, sha256);
|
||||
} catch (err) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||||
return { path: dest, bytes: loaded, sha256, signatur, name: asset.name, version: asset.version, kind: installKind, target };
|
||||
}
|
||||
|
||||
/*
|
||||
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
||||
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
||||
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
||||
*/
|
||||
const SWAP_SCRIPT = `@echo off
|
||||
setlocal
|
||||
set "SRC=%~1"
|
||||
set "DST=%~2"
|
||||
set "LOG=%~dp0update.log"
|
||||
set TRIES=0
|
||||
:loop
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
copy /y "%SRC%" "%DST%" >nul 2>&1
|
||||
if not errorlevel 1 goto done
|
||||
set /a TRIES+=1
|
||||
if %TRIES% GEQ 60 goto fail
|
||||
goto loop
|
||||
:done
|
||||
del /f /q "%SRC%" >nul 2>&1
|
||||
start "" "%DST%"
|
||||
exit /b 0
|
||||
:fail
|
||||
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
||||
exit /b 1
|
||||
`;
|
||||
|
||||
/*
|
||||
* Wendet das geladene Update an.
|
||||
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
|
||||
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
||||
* Der Launcher muss sich danach beenden.
|
||||
*/
|
||||
async function applyUpdate(file) {
|
||||
const installKind = detectInstallKind();
|
||||
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
||||
// wirklich zu dieser Paketart – und ist sie noch genau die signierte? (Zwischen
|
||||
// Download und Start könnte sie im Temp-Ordner ausgetauscht worden sein.)
|
||||
assertMatchingKind(path.basename(file.path || ''), installKind);
|
||||
pruefeSignatur(file.signatur, file.name, file.version, await sha256Datei(file.path));
|
||||
const kind = installKind;
|
||||
|
||||
if (kind !== 'portable') {
|
||||
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
|
||||
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
return { started: 'installer' };
|
||||
}
|
||||
|
||||
const target = file.target || portableTarget();
|
||||
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
|
||||
|
||||
const script = path.join(updateDir(), 'aeromc-swap.cmd');
|
||||
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
|
||||
|
||||
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
|
||||
const child = spawn(process.env.COMSPEC || 'cmd.exe',
|
||||
['/c', 'start', '', '/min', script, file.path, target],
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true });
|
||||
child.unref();
|
||||
return { started: 'swap', target };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
applyUpdate,
|
||||
pruefeSignatur,
|
||||
signaturText,
|
||||
holeSignatur,
|
||||
SIGNATUR_API,
|
||||
cleanupUpdateCache,
|
||||
compareVersions,
|
||||
parseVersion,
|
||||
detectInstallKind,
|
||||
assetKind,
|
||||
portableTarget,
|
||||
};
|
||||
|
||||
@@ -1,180 +1,181 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: AeroMC Freunde
|
||||
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
|
||||
* Version: 1.5.1
|
||||
* Author: M_Viper
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // kein direkter Aufruf der Datei
|
||||
}
|
||||
|
||||
define('AEROMC_FRIENDS_VERSION', '1.5.1');
|
||||
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
|
||||
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
|
||||
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
|
||||
define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
|
||||
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
|
||||
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
|
||||
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
|
||||
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
|
||||
|
||||
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
|
||||
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
|
||||
// (ältere Launcher-Versionen funktionieren weiter). Sobald alle Nutzer
|
||||
// aktualisiert haben, in der wp-config.php auf true setzen:
|
||||
// define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
|
||||
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
|
||||
define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
|
||||
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
|
||||
*/
|
||||
function aeromc_friends_install() {
|
||||
global $wpdb;
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
|
||||
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
|
||||
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
|
||||
$sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE;
|
||||
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE;
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
|
||||
// in includes/routes.php) - vermeidet doppelte Zeilen durch
|
||||
// unterschiedliche Schreibweisen derselben UUID
|
||||
$sql_presence = "CREATE TABLE $presence_table (
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
instance_name varchar(128) DEFAULT NULL,
|
||||
server_label varchar(128) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
last_seen datetime NOT NULL,
|
||||
PRIMARY KEY (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_presence);
|
||||
|
||||
$sql_friends = "CREATE TABLE $friends_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
uuid_a varchar(32) NOT NULL,
|
||||
uuid_b varchar(32) NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY uuid_a (uuid_a),
|
||||
KEY uuid_b (uuid_b)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_friends);
|
||||
|
||||
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
|
||||
$sql_invites = "CREATE TABLE $invites_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
server varchar(128) NOT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_invites);
|
||||
|
||||
// Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
|
||||
$sql_sessions = "CREATE TABLE $sessions_table (
|
||||
token_hash char(64) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
last_used datetime NOT NULL,
|
||||
PRIMARY KEY (token_hash),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_sessions);
|
||||
|
||||
// per Code geteilte Instanzen (Mod-Liste, keine Dateien)
|
||||
$sql_shares = "CREATE TABLE $shares_table (
|
||||
code varchar(9) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
manifest longtext NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (code),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_shares);
|
||||
|
||||
// Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
|
||||
$messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE;
|
||||
$sql_messages = "CREATE TABLE $messages_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
text varchar(400) NOT NULL,
|
||||
image_url varchar(255) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
read_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid),
|
||||
KEY from_uuid (from_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_messages);
|
||||
|
||||
// Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
|
||||
$reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE;
|
||||
$sql_reports = "CREATE TABLE $reports_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
created_at datetime NOT NULL,
|
||||
uuid varchar(32) DEFAULT NULL,
|
||||
username varchar(32) DEFAULT NULL,
|
||||
launcher_version varchar(32) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
loader varchar(32) DEFAULT NULL,
|
||||
loader_version varchar(64) DEFAULT NULL,
|
||||
system varchar(128) DEFAULT NULL,
|
||||
funde text,
|
||||
mods longtext,
|
||||
bericht longtext,
|
||||
kommentar text,
|
||||
status varchar(20) NOT NULL DEFAULT 'neu',
|
||||
PRIMARY KEY (id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_reports);
|
||||
|
||||
// eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
|
||||
$capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
|
||||
$sql_capes = "CREATE TABLE $capes_table (
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
datei varchar(64) NOT NULL DEFAULT '',
|
||||
breite smallint(5) unsigned NOT NULL DEFAULT 0,
|
||||
hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
|
||||
hash varchar(40) NOT NULL DEFAULT '',
|
||||
status varchar(16) NOT NULL DEFAULT 'aktiv',
|
||||
updated_at datetime NOT NULL,
|
||||
PRIMARY KEY (uuid),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_capes);
|
||||
|
||||
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
|
||||
}
|
||||
register_activation_hook(__FILE__, 'aeromc_friends_install');
|
||||
|
||||
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
|
||||
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
|
||||
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
|
||||
add_action('plugins_loaded', function () {
|
||||
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
|
||||
aeromc_friends_install();
|
||||
}
|
||||
});
|
||||
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
|
||||
if (is_admin()) {
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
|
||||
}
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: AeroMC Freunde
|
||||
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
|
||||
* Version: 1.5.5
|
||||
* Author: M_Viper
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit; // kein direkter Aufruf der Datei
|
||||
}
|
||||
|
||||
define('AEROMC_FRIENDS_VERSION', '1.5.5');
|
||||
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
|
||||
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
|
||||
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
|
||||
define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
|
||||
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
|
||||
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
|
||||
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
|
||||
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
|
||||
|
||||
// Anmeldung ist Pflicht (seit Plugin 1.5.5): Jede Anfrage "als Spieler X" braucht
|
||||
// das Sitzungs-Token aus der Mojang-Anmeldung (Launcher ab 1.1.5). Launcher vor
|
||||
// 1.1.5 können das Freundesystem damit nicht mehr nutzen. Nur falls doch noch
|
||||
// nötig, lässt sich der alte Übergangsmodus in der wp-config.php zurückholen:
|
||||
// define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
|
||||
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
|
||||
define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die beiden Tabellen an (bzw. bringt sie bei einem Update auf den
|
||||
* neuesten Stand - dbDelta erkennt bestehende Tabellen/Spalten selbst).
|
||||
*/
|
||||
function aeromc_friends_install() {
|
||||
global $wpdb;
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$presence_table = $wpdb->prefix . AEROMC_FRIENDS_PRESENCE_TABLE;
|
||||
$friends_table = $wpdb->prefix . AEROMC_FRIENDS_FRIENDS_TABLE;
|
||||
$invites_table = $wpdb->prefix . AEROMC_FRIENDS_INVITES_TABLE;
|
||||
$sessions_table = $wpdb->prefix . AEROMC_FRIENDS_SESSIONS_TABLE;
|
||||
$shares_table = $wpdb->prefix . AEROMC_FRIENDS_SHARES_TABLE;
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
// uuid ohne Bindestriche gespeichert (siehe aeromc_friends_normalize_uuid
|
||||
// in includes/routes.php) - vermeidet doppelte Zeilen durch
|
||||
// unterschiedliche Schreibweisen derselben UUID
|
||||
$sql_presence = "CREATE TABLE $presence_table (
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
instance_name varchar(128) DEFAULT NULL,
|
||||
server_label varchar(128) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
last_seen datetime NOT NULL,
|
||||
PRIMARY KEY (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_presence);
|
||||
|
||||
$sql_friends = "CREATE TABLE $friends_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
uuid_a varchar(32) NOT NULL,
|
||||
uuid_b varchar(32) NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY uuid_a (uuid_a),
|
||||
KEY uuid_b (uuid_b)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_friends);
|
||||
|
||||
// Einladungen "Komm auf meinen Server" - gelten nur ein paar Minuten
|
||||
$sql_invites = "CREATE TABLE $invites_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
server varchar(128) NOT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_invites);
|
||||
|
||||
// Anmelde-Sitzungen: nur der SHA-256 des Tokens wird gespeichert
|
||||
$sql_sessions = "CREATE TABLE $sessions_table (
|
||||
token_hash char(64) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
last_used datetime NOT NULL,
|
||||
PRIMARY KEY (token_hash),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_sessions);
|
||||
|
||||
// per Code geteilte Instanzen (Mod-Liste, keine Dateien)
|
||||
$sql_shares = "CREATE TABLE $shares_table (
|
||||
code varchar(9) NOT NULL,
|
||||
uuid varchar(32) NOT NULL,
|
||||
manifest longtext NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (code),
|
||||
KEY uuid (uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_shares);
|
||||
|
||||
// Kurznachrichten zwischen Freunden (werden nach einigen Tagen gelöscht)
|
||||
$messages_table = $wpdb->prefix . AEROMC_FRIENDS_MESSAGES_TABLE;
|
||||
$sql_messages = "CREATE TABLE $messages_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
from_uuid varchar(32) NOT NULL,
|
||||
to_uuid varchar(32) NOT NULL,
|
||||
text varchar(400) NOT NULL,
|
||||
image_url varchar(255) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
read_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY to_uuid (to_uuid),
|
||||
KEY from_uuid (from_uuid)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_messages);
|
||||
|
||||
// Fehlerberichte aus dem Absturz-Assistenten (Ansicht: Werkzeuge → AeroMC Fehlerberichte)
|
||||
$reports_table = $wpdb->prefix . AEROMC_FRIENDS_REPORTS_TABLE;
|
||||
$sql_reports = "CREATE TABLE $reports_table (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
created_at datetime NOT NULL,
|
||||
uuid varchar(32) DEFAULT NULL,
|
||||
username varchar(32) DEFAULT NULL,
|
||||
launcher_version varchar(32) DEFAULT NULL,
|
||||
mc_version varchar(32) DEFAULT NULL,
|
||||
loader varchar(32) DEFAULT NULL,
|
||||
loader_version varchar(64) DEFAULT NULL,
|
||||
system varchar(128) DEFAULT NULL,
|
||||
funde text,
|
||||
mods longtext,
|
||||
bericht longtext,
|
||||
kommentar text,
|
||||
status varchar(20) NOT NULL DEFAULT 'neu',
|
||||
PRIMARY KEY (id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_reports);
|
||||
|
||||
// eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
|
||||
$capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
|
||||
$sql_capes = "CREATE TABLE $capes_table (
|
||||
uuid varchar(32) NOT NULL,
|
||||
username varchar(32) NOT NULL,
|
||||
datei varchar(64) NOT NULL DEFAULT '',
|
||||
breite smallint(5) unsigned NOT NULL DEFAULT 0,
|
||||
hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
|
||||
hash varchar(40) NOT NULL DEFAULT '',
|
||||
status varchar(16) NOT NULL DEFAULT 'aktiv',
|
||||
updated_at datetime NOT NULL,
|
||||
PRIMARY KEY (uuid),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
dbDelta($sql_capes);
|
||||
|
||||
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
|
||||
}
|
||||
register_activation_hook(__FILE__, 'aeromc_friends_install');
|
||||
|
||||
// Beim Ersetzen der Plugin-Dateien (Update per Upload) läuft der
|
||||
// Aktivierungs-Hook NICHT erneut - neue Spalten/Tabellen deshalb anlegen,
|
||||
// sobald die gespeicherte DB-Version nicht mehr zur Plugin-Version passt.
|
||||
add_action('plugins_loaded', function () {
|
||||
if (get_option('aeromc_friends_db_version') !== AEROMC_FRIENDS_VERSION) {
|
||||
aeromc_friends_install();
|
||||
}
|
||||
});
|
||||
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/signaturen.php';
|
||||
if (is_admin()) {
|
||||
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
/**
|
||||
* Update-Signaturen für den AeroMC Launcher
|
||||
* -----------------------------------------
|
||||
* Der Launcher installiert ein Update nur, wenn es mit dem Release-Schlüssel des
|
||||
* Entwicklers unterschrieben ist (Ed25519). Die Unterschriften liegen hier statt
|
||||
* im Gitea-Release; die EXE selbst kommt weiter aus Gitea.
|
||||
*
|
||||
* GET /wp-json/aeromc/v1/update-signatur?datei=AeroMC-Setup.exe&version=1.2.2
|
||||
* öffentlich – liefert die Unterschrift (sie ist nicht geheim)
|
||||
* POST /wp-json/aeromc/v1/update-signatur
|
||||
* nur Administratoren (Anwendungspasswort) – vom Build-Skript
|
||||
* scripts/sign-release.js automatisch aufgerufen
|
||||
* GET /wp-json/aeromc/v1/update-signatur/zugang
|
||||
* prüft beim Einrichten, ob der Zugang stimmt, und sagt sonst, woran es scheitert
|
||||
*
|
||||
* Wichtig: Der geheime Schlüssel liegt NIE auf diesem Server. Hier wird nur
|
||||
* mit dem öffentlichen Schlüssel geprüft, dass eine hochgeladene Unterschrift
|
||||
* echt ist – selbst ein übernommener Admin-Zugang kann keine gültige erzeugen.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('AEROMC_SIG_OPTION', 'aeromc_update_signaturen');
|
||||
define('AEROMC_SIG_FORMAT', 'AeroMC-Update/1');
|
||||
// öffentlicher Release-Schlüssel (identisch mit src/update-schluessel.pem im Launcher)
|
||||
define('AEROMC_SIG_SCHLUESSEL', 'MCowBQYDK2VwAyEAXxt1cCntDQzvej4hjyVZt6Onj6Uv3hSlneJaOxxmsds=');
|
||||
define('AEROMC_SIG_DATEIEN', ['AeroMC-Setup.exe', 'AeroMC-Portable.exe']);
|
||||
define('AEROMC_SIG_MAX', 40); // so viele Einträge werden aufgehoben (älteste fliegen raus)
|
||||
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_route('aeromc/v1', '/update-signatur', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => 'aeromc_sig_route_get',
|
||||
'permission_callback' => '__return_true',
|
||||
],
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => 'aeromc_sig_route_post',
|
||||
'permission_callback' => 'aeromc_sig_ist_admin',
|
||||
],
|
||||
]);
|
||||
// Zugangsprüfung mit Diagnose: sagt dem Build-Skript genau, woran eine
|
||||
// Anmeldung scheitert (Header kommt nicht an, falsches Passwort, Rolle fehlt …)
|
||||
register_rest_route('aeromc/v1', '/update-signatur/zugang', [
|
||||
'methods' => 'GET',
|
||||
'callback' => 'aeromc_sig_route_zugang',
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
* Eigene Rolle "AeroMC Release": darf NUR Signaturen hochladen (sonst nichts
|
||||
* außer dem eigenen Profil). Für das Build-Skript gedacht – so muss kein
|
||||
* Administrator-Zugang auf dem Entwickler-PC liegen.
|
||||
*/
|
||||
define('AEROMC_SIG_RECHT', 'aeromc_signaturen_hochladen');
|
||||
add_action('init', function () {
|
||||
if (!get_role('aeromc_release')) {
|
||||
add_role('aeromc_release', 'AeroMC Release', ['read' => true, AEROMC_SIG_RECHT => true]);
|
||||
}
|
||||
});
|
||||
|
||||
function aeromc_sig_route_zugang() {
|
||||
$authorization = !empty($_SERVER['HTTP_AUTHORIZATION']) || !empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])
|
||||
|| !empty($_SERVER['PHP_AUTH_USER']);
|
||||
$eigener = !empty($_SERVER['HTTP_X_AEROMC_AUTH']);
|
||||
$grund = '';
|
||||
$user = is_user_logged_in() ? wp_get_current_user() : null;
|
||||
if (!$user && $eigener) {
|
||||
$roh = base64_decode((string) $_SERVER['HTTP_X_AEROMC_AUTH'], true);
|
||||
if ($roh && strpos($roh, ':') !== false) {
|
||||
list($benutzer, $passwort) = explode(':', $roh, 2);
|
||||
$r = wp_authenticate_application_password(null, $benutzer, $passwort);
|
||||
if ($r instanceof WP_User) {
|
||||
$user = $r;
|
||||
wp_set_current_user($r->ID);
|
||||
} elseif (is_wp_error($r)) {
|
||||
$grund = $r->get_error_code();
|
||||
} else {
|
||||
$grund = 'nicht_geprueft';
|
||||
}
|
||||
} else {
|
||||
$grund = 'kopf_ungueltig';
|
||||
}
|
||||
}
|
||||
$darf = $user && (user_can($user, AEROMC_SIG_RECHT) || user_can($user, 'manage_options'));
|
||||
return new WP_REST_Response([
|
||||
'ok' => (bool) $darf,
|
||||
'version' => AEROMC_FRIENDS_VERSION,
|
||||
'authorization_kommt_an' => $authorization,
|
||||
'eigener_kopf_kommt_an' => $eigener,
|
||||
'anwendungspasswoerter' => function_exists('wp_is_application_passwords_available') && wp_is_application_passwords_available(),
|
||||
'benutzer' => $user ? $user->user_login : null,
|
||||
'darf_hochladen' => (bool) $darf,
|
||||
'grund' => $grund,
|
||||
], $darf ? 200 : 401);
|
||||
}
|
||||
|
||||
function aeromc_sig_ist_admin() {
|
||||
aeromc_sig_anmelden_ueber_kopf();
|
||||
return current_user_can(AEROMC_SIG_RECHT) || current_user_can('manage_options');
|
||||
}
|
||||
|
||||
/*
|
||||
* Manche Server (Apache mit FastCGI/PHP-FPM, Proxys) geben den Header
|
||||
* "Authorization" nicht an PHP weiter – die normale Anmeldung per
|
||||
* Anwendungspasswort kommt dann nie an. Das Build-Skript schickt dieselben
|
||||
* Daten deshalb zusätzlich als "X-AeroMC-Auth: base64(benutzer:passwort)".
|
||||
* Geprüft wird mit der WordPress-eigenen Funktion für Anwendungspasswörter –
|
||||
* das normale Login-Passwort wird hier NICHT angenommen.
|
||||
*/
|
||||
function aeromc_sig_anmelden_ueber_kopf() {
|
||||
if (is_user_logged_in()) {
|
||||
return;
|
||||
}
|
||||
$kopf = isset($_SERVER['HTTP_X_AEROMC_AUTH']) ? (string) $_SERVER['HTTP_X_AEROMC_AUTH'] : '';
|
||||
$roh = $kopf !== '' ? base64_decode($kopf, true) : false;
|
||||
if (!$roh || strpos($roh, ':') === false) {
|
||||
return;
|
||||
}
|
||||
list($benutzer, $passwort) = explode(':', $roh, 2);
|
||||
$user = wp_authenticate_application_password(null, $benutzer, $passwort);
|
||||
if ($user instanceof WP_User) {
|
||||
wp_set_current_user($user->ID);
|
||||
}
|
||||
}
|
||||
|
||||
function aeromc_sig_version_norm($v) {
|
||||
return preg_replace('/^v/i', '', trim((string) $v));
|
||||
}
|
||||
|
||||
function aeromc_sig_schluessel_von($datei, $version) {
|
||||
return $datei . '|' . aeromc_sig_version_norm($version);
|
||||
}
|
||||
|
||||
function aeromc_sig_alle() {
|
||||
$alle = get_option(AEROMC_SIG_OPTION, []);
|
||||
return is_array($alle) ? $alle : [];
|
||||
}
|
||||
|
||||
function aeromc_sig_route_get($request) {
|
||||
$datei = (string) $request->get_param('datei');
|
||||
$version = aeromc_sig_version_norm($request->get_param('version'));
|
||||
$alle = aeromc_sig_alle();
|
||||
$eintrag = $alle[aeromc_sig_schluessel_von($datei, $version)] ?? null;
|
||||
if (!$eintrag) {
|
||||
return new WP_REST_Response(['ok' => false, 'message' => 'Keine Signatur für diese Datei/Version.'], 404);
|
||||
}
|
||||
unset($eintrag['hochgeladen']);
|
||||
$antwort = new WP_REST_Response($eintrag, 200);
|
||||
$antwort->header('Cache-Control', 'no-store');
|
||||
return $antwort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Aufbau und – sofern PHP libsodium mitbringt – die Unterschrift selbst.
|
||||
* Gibt null zurück, wenn alles stimmt, sonst die Fehlermeldung.
|
||||
*/
|
||||
function aeromc_sig_pruefe(array $s) {
|
||||
if (($s['format'] ?? '') !== AEROMC_SIG_FORMAT) return 'Unbekanntes Format.';
|
||||
if (!in_array($s['datei'] ?? '', AEROMC_SIG_DATEIEN, true)) return 'Unbekannter Dateiname.';
|
||||
if (!preg_match('/^\d+\.\d+\.\d+(-[0-9A-Za-z.\-]+)?$/', aeromc_sig_version_norm($s['version'] ?? ''))) return 'Ungültige Version.';
|
||||
if (!preg_match('/^[0-9a-f]{64}$/', (string) ($s['sha256'] ?? ''))) return 'Ungültige Prüfsumme.';
|
||||
$unterschrift = base64_decode((string) ($s['signatur'] ?? ''), true);
|
||||
if ($unterschrift === false || strlen($unterschrift) !== 64) return 'Ungültige Signatur.';
|
||||
if (function_exists('sodium_crypto_sign_verify_detached')) {
|
||||
$der = base64_decode(AEROMC_SIG_SCHLUESSEL);
|
||||
$oeffentlich = substr($der, -32); // SPKI-Hülle (12 Byte) + 32 Byte Schlüssel
|
||||
$text = implode("\n", [AEROMC_SIG_FORMAT, $s['datei'], aeromc_sig_version_norm($s['version']), $s['sha256']]);
|
||||
if (!sodium_crypto_sign_verify_detached($unterschrift, $text, $oeffentlich)) {
|
||||
return 'Signatur passt nicht zum Release-Schlüssel.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function aeromc_sig_route_post($request) {
|
||||
$s = $request->get_json_params();
|
||||
if (!is_array($s)) {
|
||||
return new WP_REST_Response(['ok' => false, 'message' => 'Kein JSON erhalten.'], 400);
|
||||
}
|
||||
$s = [
|
||||
'format' => (string) ($s['format'] ?? ''),
|
||||
'datei' => (string) ($s['datei'] ?? ''),
|
||||
'version' => aeromc_sig_version_norm($s['version'] ?? ''),
|
||||
'sha256' => strtolower((string) ($s['sha256'] ?? '')),
|
||||
'signatur' => (string) ($s['signatur'] ?? ''),
|
||||
];
|
||||
$fehler = aeromc_sig_pruefe($s);
|
||||
if ($fehler) {
|
||||
return new WP_REST_Response(['ok' => false, 'message' => $fehler], 400);
|
||||
}
|
||||
$alle = aeromc_sig_alle();
|
||||
$s['hochgeladen'] = current_time('mysql');
|
||||
$alle[aeromc_sig_schluessel_von($s['datei'], $s['version'])] = $s;
|
||||
// nur die neuesten Einträge behalten
|
||||
uasort($alle, function ($a, $b) { return strcmp($b['hochgeladen'], $a['hochgeladen']); });
|
||||
$alle = array_slice($alle, 0, AEROMC_SIG_MAX, true);
|
||||
update_option(AEROMC_SIG_OPTION, $alle, false);
|
||||
return ['ok' => true, 'datei' => $s['datei'], 'version' => $s['version'],
|
||||
'geprueft' => function_exists('sodium_crypto_sign_verify_detached')];
|
||||
}
|
||||
|
||||
// Übersicht im Admin-Menü (unter "AeroMC Fehler")
|
||||
add_action('admin_menu', function () {
|
||||
add_submenu_page('aeromc-reports', 'AeroMC Update-Signaturen', 'Update-Signaturen', 'manage_options',
|
||||
'aeromc-signaturen', 'aeromc_sig_admin_seite');
|
||||
}, 20);
|
||||
|
||||
function aeromc_sig_admin_seite() {
|
||||
if (!current_user_can('manage_options')) {
|
||||
return;
|
||||
}
|
||||
if (isset($_POST['aeromc_sig_loeschen']) && check_admin_referer('aeromc_signaturen')) {
|
||||
$alle = aeromc_sig_alle();
|
||||
unset($alle[(string) wp_unslash($_POST['aeromc_sig_loeschen'])]);
|
||||
update_option(AEROMC_SIG_OPTION, $alle, false);
|
||||
echo '<div class="notice notice-success"><p>Signatur entfernt.</p></div>';
|
||||
}
|
||||
echo '<div class="wrap"><h1>AeroMC Update-Signaturen</h1>';
|
||||
echo '<p>Diese Unterschriften lädt das Build-Skript automatisch hoch. Der Launcher installiert ein Update nur, '
|
||||
. 'wenn hier eine passende, gültige Signatur liegt. Sie sind nicht geheim – der geheime Schlüssel bleibt auf dem Entwickler-PC.</p>';
|
||||
if (!function_exists('sodium_crypto_sign_verify_detached')) {
|
||||
echo '<div class="notice notice-warning"><p>PHP hat kein libsodium – hochgeladene Signaturen werden hier nicht gegengeprüft '
|
||||
. '(der Launcher prüft sie trotzdem selbst).</p></div>';
|
||||
}
|
||||
$alle = aeromc_sig_alle();
|
||||
if (!$alle) {
|
||||
echo '<p><em>Noch keine Signaturen vorhanden.</em></p></div>';
|
||||
return;
|
||||
}
|
||||
echo '<table class="widefat striped"><thead><tr><th>Datei</th><th>Version</th><th>SHA-256</th><th>Hochgeladen</th><th></th></tr></thead><tbody>';
|
||||
foreach ($alle as $schl => $s) {
|
||||
echo '<tr><td>' . esc_html($s['datei']) . '</td><td>' . esc_html($s['version']) . '</td>'
|
||||
. '<td><code>' . esc_html(substr($s['sha256'], 0, 16)) . '…</code></td><td>' . esc_html($s['hochgeladen']) . '</td><td>'
|
||||
. '<form method="post" onsubmit="return confirm(\'Signatur wirklich entfernen? Dann bietet der Launcher dieses Update nicht mehr an.\');">';
|
||||
wp_nonce_field('aeromc_signaturen');
|
||||
echo '<input type="hidden" name="aeromc_sig_loeschen" value="' . esc_attr($schl) . '" />'
|
||||
. '<button class="button button-link-delete">Entfernen</button></form></td></tr>';
|
||||
}
|
||||
echo '</tbody></table></div>';
|
||||
}
|
||||