Compare commits

..
8 Commits
Author SHA1 Message Date
M_Viper 009038d48d Upload via GUI (194 Dateien) 2026-10-07 07:32:10 +00:00
M_Viper 3a7822302f Upload via GUI (194 Dateien) 2026-10-07 07:32:08 +00:00
M_Viper aae442ae48 Upload via GUI (194 Dateien) 2026-10-07 07:32:07 +00:00
M_Viper 5611c5883a Upload via GUI (194 Dateien) 2026-10-07 07:32:05 +00:00
M_Viper d976cb4595 Upload via GUI (194 Dateien) 2026-10-07 07:32:04 +00:00
M_Viper 3ac5a6c14d Upload via GUI (194 Dateien) 2026-10-07 07:32:02 +00:00
M_Viper 7e7a2021ed Upload via GUI (194 Dateien) 2026-10-07 07:32:01 +00:00
M_Viper 7e5d743367 Upload via GUI (194 Dateien) 2026-10-07 07:31:59 +00:00
76 changed files with 10341 additions and 6749 deletions
+41 -7
View File
@@ -3,15 +3,22 @@ package de.aeromc.capes;
import java.lang.instrument.Instrumentation; import java.lang.instrument.Instrumentation;
/** /**
* AeroMC-Umhang-Agent. * AeroMC-Agent.
* *
* Wird vom Launcher per -javaagent an Minecraft angehängt (jede Version, jeder * 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 * Loader) und kann zwei Dinge, einzeln zuschaltbar:
* jeden Spieler Skin und Umhang – und setzt dort den AeroMC-Umhang des Spielers
* ein, falls er einen hat. Alles Weitere (Herunterladen, Zwischenspeichern,
* Anzeigen, auch HD) erledigt Minecraft selbst wie bei einem offiziellen Umhang.
* *
* Argument: Basisadresse des Umhang-Servers (…/wp-json/aeromc/v1). * 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 { public final class Agent {
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */ /** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
@@ -28,8 +35,27 @@ public final class Agent {
} }
private static void starte(String argument, Instrumentation inst) { private static void starte(String argument, Instrumentation inst) {
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 { 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 // Die eingefügten Befehle in authlib holen sich den Dienst von hier – nur
// über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt. // über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt.
System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis)); System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis));
@@ -39,4 +65,12 @@ public final class Agent {
System.out.println("[AeroMC] Umhang-Agent konnte nicht starten: " + 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);
}
}
}
} }
+141
View File
@@ -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;
}
}
}
+352
View File
@@ -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;
}
}
+138
View File
@@ -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;
}
}
+3 -3
View File
@@ -20,7 +20,7 @@ import org.objectweb.asm.commons.Remapper;
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf * de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden. * demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
* *
* Aufruf: java Shade &lt;ziel.jar&gt; &lt;klassenordner&gt; &lt;asm.jar&gt; * Aufruf: java Shade &lt;ziel.jar&gt; &lt;klassenordner&gt; &lt;asm.jar&gt; [weitere ASM-Jars, z. B. asm-tree]
*/ */
public class Shade { public class Shade {
static final Remapper UMBENENNEN = new Remapper() { static final Remapper UMBENENNEN = new Remapper() {
@@ -52,7 +52,7 @@ public class Shade {
at.putValue("Premain-Class", "de.aeromc.capes.Agent"); at.putValue("Premain-Class", "de.aeromc.capes.Agent");
at.putValue("Agent-Class", "de.aeromc.capes.Agent"); at.putValue("Agent-Class", "de.aeromc.capes.Agent");
at.putValue("Can-Retransform-Classes", "false"); at.putValue("Can-Retransform-Classes", "false");
at.putValue("Implementation-Title", "AeroMC Umhang-Agent"); at.putValue("Implementation-Title", "AeroMC Agent (Umhaenge, Spieldesign)");
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) { try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
// eigene Klassen // eigene Klassen
File basis = new File(a[1]); File basis = new File(a[1]);
@@ -63,7 +63,7 @@ public class Shade {
out.closeEntry(); out.closeEntry();
} }
// ASM (umbenannt), ohne Modul-Infos und Signaturen // ASM (umbenannt), ohne Modul-Infos und Signaturen
try (JarFile asm = new JarFile(a[2])) { for (int j = 2; j < a.length; j++) try (JarFile asm = new JarFile(a[j])) {
Enumeration<JarEntry> e = asm.entries(); Enumeration<JarEntry> e = asm.entries();
while (e.hasMoreElements()) { while (e.hasMoreElements()) {
JarEntry je = e.nextElement(); JarEntry je = e.nextElement();
+26
View File
@@ -181,9 +181,35 @@ public static class Installer
k.SetValue("NoRepair", 1, RegistryValueKind.DWord); k.SetValue("NoRepair", 1, RegistryValueKind.DWord);
} }
progress.Report((0.98, "Installation wird abgeschlossen …"));
Vorwaermen(exe);
progress.Report((1.0, "Fertig")); 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) => private static List<string> TopLevelNames(ZipArchive zip) =>
zip.Entries zip.Entries
.Select(e => e.FullName.Replace('\\', '/').Split('/')[0]) .Select(e => e.FullName.Replace('\\', '/').Split('/')[0])
+10 -1
View File
@@ -300,7 +300,16 @@ public partial class MainWindow : Window
await RunInstall(DirBox.Text.Trim(), DesktopLinkBox.IsChecked == true, update: false); await RunInstall(DirBox.Text.Trim(), DesktopLinkBox.IsChecked == true, update: false);
break; break;
case PageNumProgress: 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(); Close();
break; break;
} }
@@ -5,7 +5,7 @@
}, },
"projects": { "projects": {
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": { "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
"version": "1.2.1", "version": "1.2.3",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")] [assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")] [assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.1.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.3.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.1")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.3")]
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.1.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.2.3.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")] [assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert. // Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -1 +1 @@
a17a2bc3b05635d3c29adc5c6884bde0a77826489d54b55a9b7744bfaa1ef206 111db8ee3dc7193961107c73afd8913fff02b92fd4e49b8b7e4bf051181600bd
+1 -1
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {} "C:\\Users\\Viper\\.nuget\\packages\\": {}
}, },
"project": { "project": {
"version": "1.2.1", "version": "1.2.3",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "zBMXx+S6DFU=", "dgSpecHash": "5nf4o8NXPS0=",
"success": true, "success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
+1087 -1377
View File
File diff suppressed because it is too large Load Diff
+17 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.2.1", "version": "1.2.3",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de", "homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net", "launcherWebsite": "https://aeromc.viper.ipv64.net",
@@ -11,10 +11,10 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"prebuild": "node scripts/write-build-config.js", "prebuild": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
"prebuild:portable": "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", "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})}}}\"" "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": { "build": {
"appId": "com.aeromc.launcher", "appId": "com.aeromc.launcher",
@@ -34,11 +34,18 @@
"win": { "win": {
"target": "dir", "target": "dir",
"icon": "src/assets/icon.ico" "icon": "src/assets/icon.ico"
},
"electronFuses": {
"runAsNode": false,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false,
"enableEmbeddedAsarIntegrityValidation": true,
"onlyLoadAppFromAsar": true
} }
}, },
"devDependencies": { "devDependencies": {
"electron": "^28.0.0", "electron": "^44.6.0",
"electron-builder": "^24.0.0" "electron-builder": "^26.15.3"
}, },
"dependencies": { "dependencies": {
"@xhayper/discord-rpc": "^1.3.4", "@xhayper/discord-rpc": "^1.3.4",
@@ -50,5 +57,8 @@
"sql.js": "^1.14.2", "sql.js": "^1.14.2",
"undici": "^6.28.0", "undici": "^6.28.0",
"unzipper": "^0.12.5" "unzipper": "^0.12.5"
},
"overrides": {
"brace-expansion@^2.0.0": "^2.1.7"
} }
} }
+3 -2
View File
@@ -54,18 +54,19 @@ const javac = path.join(jdk, 'bin', 'javac');
const java = path.join(jdk, 'bin', 'java'); const java = path.join(jdk, 'bin', 'java');
const asm = findeAsm('asm'); const asm = findeAsm('asm');
const asmCommons = findeAsm('asm-commons'); const asmCommons = findeAsm('asm-commons');
const asmTree = findeAsm('asm-tree');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
const klassen = path.join(tmp, 'klassen'); const klassen = path.join(tmp, 'klassen');
const werkzeug = path.join(tmp, 'werkzeug'); const werkzeug = path.join(tmp, 'werkzeug');
console.log('JDK:', jdk); console.log('JDK:', jdk);
console.log('ASM:', asm); console.log('ASM:', asm);
try { try {
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen, execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', [asm, asmTree].join(path.delimiter), '-d', klassen,
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' }); ...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug, execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' }); path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
fs.mkdirSync(path.dirname(ZIEL), { recursive: true }); fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' }); 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)`); console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
} finally { } finally {
fs.rmSync(tmp, { recursive: true, force: true }); fs.rmSync(tmp, { recursive: true, force: true });
+712
View File
@@ -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()
+39
View File
@@ -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.`);
})();
+227
View File
@@ -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);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+2 -31
View File
@@ -1,58 +1,29 @@
'use strict'; '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 http = require('http');
const { Readable } = require('stream'); const { Readable } = require('stream');
const KEY = (process.env.CF_API_KEY || '').trim(); const KEY = (process.env.CF_API_KEY || '').trim();
const PORT = Number(process.env.PORT || 8420); const PORT = Number(process.env.PORT || 8420);
const HOST = process.env.HOST || '0.0.0.0'; 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( const TOKENS = new Set(
String(process.env.CF_PROXY_TOKEN || '') String(process.env.CF_PROXY_TOKEN || '')
.split(',').map((t) => t.trim()).filter(Boolean), .split(',').map((t) => t.trim()).filter(Boolean),
); );
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60); const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512); 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 PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
const CF_API = 'https://api.curseforge.com/v1'; 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)'; const UA = 'AeroMC-Launcher-Proxy/1.0 (+https://git.viper.ipv64.net)';
function cfKopfzeilen(weitere) { function cfKopfzeilen(weitere) {
return Object.assign({ 'x-api-key': KEY, Accept: 'application/json', 'User-Agent': UA }, 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([ const ERLAUBTE_HOSTS = new Set([
'edge.forgecdn.net', 'edge.forgecdn.net',
'mediafilez.forgecdn.net', 'mediafilez.forgecdn.net',
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

+13
View File
@@ -0,0 +1,13 @@
{
"providers": [
{
"type": "bitmap",
"file": "aeromc:font/symbole.png",
"ascent": 7,
"height": 8,
"chars": [
"\ue900\ue901\ue902\ue903\ue904\ue905"
]
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

+10
View File
@@ -0,0 +1,10 @@
{
"gui": {
"scaling": {
"type": "nine_slice",
"width": 200,
"height": 20,
"border": 3
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+7
View File
@@ -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"
}
+7
View File
@@ -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..."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

+33
View File
@@ -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!
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+5
View File
@@ -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

+2
View File
@@ -150,6 +150,8 @@
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px} :root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px} :root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))} :root:is([data-design="aero"],[data-design="lila"]) .upd-notes 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 li::marker{color:rgb(var(--p-akzent2))}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))} :root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
+49
View File
@@ -54,6 +54,13 @@
<div id="p-icon" class="side-ico">🌱</div> <div id="p-icon" class="side-ico">🌱</div>
<div id="p-name" class="side-title">Instanz</div> <div id="p-name" class="side-title">Instanz</div>
<div id="p-groupline" class="side-grp">Gruppe ändern</div> <div id="p-groupline" class="side-grp">Gruppe ändern</div>
<div 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> <div class="hr"></div>
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button> <button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
<button id="p-offline" class="sbtn">Offline starten</button> <button id="p-offline" class="sbtn">Offline starten</button>
@@ -409,12 +416,23 @@
<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 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> </div>
</section> </section>
<section class="settings-card">
<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"> <section class="settings-card">
<h5>Mods</h5> <h5>Mods</h5>
<div class="s-zeile"> <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> <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> <label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
</div> </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 &amp; 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>
<section class="settings-card"> <section class="settings-card">
<h5>Konsole</h5> <h5>Konsole</h5>
@@ -657,6 +675,37 @@
</div> </div>
</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 &amp; neu starten</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Bestätigung ===================== --> <!-- ===================== Dialog: Bestätigung ===================== -->
<div id="modal-confirm" class="modal hidden"> <div id="modal-confirm" class="modal hidden">
<div class="modal-card modal-sm"> <div class="modal-card modal-sm">
+362
View File
@@ -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
View File
@@ -13,6 +13,14 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const unzipper = require('unzipper'); 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 API = 'https://api.adoptium.net/v3';
const UA = 'AeroMC-Launcher'; const UA = 'AeroMC-Launcher';
@@ -193,6 +201,11 @@ async function ensureJava(major, onProgress) {
try { try {
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 }); if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
await ladeDatei(release.url, tmpZip, onProgress); 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 // in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
// halbfertiges Java, falls unterwegs etwas schiefgeht // halbfertiges Java, falls unterwegs etwas schiefgeht
const tmpDir = ziel + '.teil'; const tmpDir = ziel + '.teil';
+22 -2
View File
@@ -52,12 +52,21 @@ async function downloadIfNeeded(url, dest, sha1) {
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 }; if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
} }
ensureDir(path.dirname(dest)); ensureDir(path.dirname(dest));
// 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 } }); const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url); if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
const buf = Buffer.from(await res.arrayBuffer()); 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); fs.writeFileSync(dest, buf);
return { skipped: false, bytes: buf.length }; return { skipped: false, bytes: buf.length };
} }
}
// begrenzte Parallelität für viele kleine Downloads (Assets) // begrenzte Parallelität für viele kleine Downloads (Assets)
async function runPool(items, worker, concurrency, onEach) { async function runPool(items, worker, concurrency, onEach) {
@@ -254,8 +263,7 @@ async function prepareAssets(vj, sharedDir, onProgress) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function pickJava(vj, preferredPath) { function pickJava(vj, preferredPath) {
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; let preferred = null;
let preferred = null;
if (preferredPath && preferredPath.trim()) { if (preferredPath && preferredPath.trim()) {
const info = services.probeJava(preferredPath); const info = services.probeJava(preferredPath);
if (info) { if (info) {
@@ -546,6 +554,10 @@ async function prepareAndLaunch(opts, onProgress) {
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight)); 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 // Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden) // ä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) { if (opts.quickPlayServer) {
const adresse = String(opts.quickPlayServer).trim(); const adresse = String(opts.quickPlayServer).trim();
if (vorQuickPlay(opts.versionId)) { if (vorQuickPlay(opts.versionId)) {
@@ -559,6 +571,14 @@ async function prepareAndLaunch(opts, onProgress) {
ensureDir(opts.gameDir); 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) // Pre-Launch-Befehl (bricht bei Fehler ab)
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) { if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' }); emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
+541 -23
View File
@@ -7,6 +7,29 @@
*/ */
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron'); const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron');
// Vom Setup am Ende der Installation einmal aufgerufen: nur starten und sofort
// wieder beenden. Windows prüft die neue EXE dabei (dauert beim ersten Start
// mehrere Sekunden) – so passiert das während des Fortschrittsbalkens und der
// echte Start nach "Fertigstellen" ist gleich da. Siehe installer/Installer.cs.
if (process.argv.includes('--aeromc-vorwaermen')) process.exit(0);
// Sicherheit: Die fertige App nimmt keine Debug-Schalter an. Mit
// --remote-debugging-port oder --inspect könnte jedes andere Programm den
// Launcher fernsteuern (Konten, Instanzen, Befehle). Beim Entwickeln (npm start)
// bleiben sie erlaubt. Die Node-Varianten (--inspect, NODE_OPTIONS,
// ELECTRON_RUN_AS_NODE) sperren zusätzlich die Electron-Fuses, siehe
// "electronFuses" in package.json (dort auch die Integritätsprüfung von app.asar).
if (app.isPackaged) {
const verboten = ['remote-debugging-port', 'remote-debugging-pipe', 'remote-debugging-address', 'inspect', 'inspect-brk', 'inspect-port', 'js-flags'];
const gesetzt = verboten.find((s) => app.commandLine.hasSwitch(s))
|| process.argv.slice(1).find((a) => /^--(remote-debugging|inspect)/i.test(a));
if (gesetzt) {
console.error('AeroMC: Debug-Schalter sind in der fertigen App nicht erlaubt (' + gesetzt + ').');
process.exit(1);
}
}
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
@@ -26,6 +49,63 @@ const loaders = require('./loaders');
const forge = require('./forge'); const forge = require('./forge');
const auth = require('./auth'); const auth = require('./auth');
const updater = require('./updater'); const updater = require('./updater');
const integritaet = require('./integritaet');
/*
* Manipulationsschutz (siehe integritaet.js): Kanäle, über die der Launcher
* selbst Mod-Dateien überschreibt oder Startdaten ändert. Vorher wird geprüft
* (eine schon manipulierte Instanz darf nicht "mitversiegelt" werden), danach
* das Siegel auf den neuen Stand gebracht. Gesperrte Instanzen lassen sich
* darüber gar nicht mehr verändern.
*/
const SIEGEL_KANAELE = new Set([
'mods:install', 'mods:addFile', 'mods:applyUpdates', 'mods:leistungsPaket',
'instances:installPerformancePack', 'instances:update', 'modpack:update',
]);
const GESPERRT_TEXT = 'Diese Instanz ist gesperrt, weil sie verändert wurde. Bitte aus einer Sicherung wiederherstellen und die gesperrte Instanz löschen.';
{
const handleOriginal = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (kanal, fn) => handleOriginal(kanal, !SIEGEL_KANAELE.has(kanal) ? fn : async (e, id, ...rest) => {
const vorher = await integritaet.pruefe(id);
if (vorher.status === 'gesperrt') {
if (vorher.neuGesperrt) meldeGesperrt(id);
return { ok: false, message: GESPERRT_TEXT };
}
try {
return await fn(e, id, ...rest);
} finally {
await integritaet.versiegele(id).catch(() => {});
}
});
}
// mit Gesperrt-Status für die Anzeige
function mitSperre(inst) {
return inst ? Object.assign({}, inst, { gesperrt: integritaet.status(inst.id) }) : inst;
}
function meldeGesperrt(id) {
broadcast('instances:changed');
const inst = store.getInstance(id);
try {
new Notification({
title: 'AeroMC – Instanz gesperrt',
body: `"${(inst && inst.name) || id}" wurde außerhalb des Launchers verändert und ist gesperrt.`,
icon: benachrichtigungsLogo || undefined,
}).show();
} catch { /* nur Hinweis */ }
}
// Beim Launcher-Start alle Instanzen im Hintergrund prüfen (schnell: nur
// geänderte Dateien werden neu gehasht). Beim Spielstart wird gründlich geprüft.
async function pruefeAlleInstanzen() {
for (const inst of store.listInstances()) {
try {
const r = await integritaet.pruefe(inst.id, { schnell: true });
if (r.neuGesperrt) meldeGesperrt(inst.id);
} catch (err) { console.warn('Integritätsprüfung', inst.id, err.message); }
}
}
const icons = require('./icons'); const icons = require('./icons');
const bedrock = require('./bedrock'); const bedrock = require('./bedrock');
const backup = require('./backup'); const backup = require('./backup');
@@ -33,6 +113,8 @@ const mmcimport = require('./mmcimport');
const kopieren = require('./kopieren'); const kopieren = require('./kopieren');
const moderkennung = require('./moderkennung'); const moderkennung = require('./moderkennung');
const garderobe = require('./garderobe'); const garderobe = require('./garderobe');
const spieldesign = require('./spieldesign');
const modschalter = require('./modschalter');
const erfolge = require('./erfolge'); const erfolge = require('./erfolge');
const cfimport = require('./cfimport'); const cfimport = require('./cfimport');
const mrimport = require('./mrimport'); const mrimport = require('./mrimport');
@@ -602,6 +684,35 @@ ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
// aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V. // aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V.
// Gilt für Haupt- und alle Popup-Fenster. // Gilt für Haupt- und alle Popup-Fenster.
app.on('web-contents-created', (_e, contents) => { app.on('web-contents-created', (_e, contents) => {
// Sicherheit: Kein Fenster darf je eine fremde Seite laden. Die Fenster haben
// über preload.js Zugriff auf window.api – eine Webseite im selben Fenster
// hätte damit die volle Kontrolle über den Launcher. Links nach außen gehen
// deshalb immer in den Standardbrowser, alles andere wird verworfen.
const eigeneSeite = (adresse) => {
try {
const u = new URL(adresse);
if (u.protocol !== 'file:') return false;
const datei = path.normalize(decodeURIComponent(u.pathname.replace(/^\/([a-z]:)/i, '$1')));
return !path.relative(__dirname, datei).startsWith('..');
} catch { return false; }
};
const nachAussen = (adresse) => {
if (/^https?:\/\//i.test(String(adresse || ''))) shell.openExternal(adresse).catch(() => {});
};
contents.on('will-navigate', (ev, adresse) => {
if (eigeneSeite(adresse)) return;
ev.preventDefault();
nachAussen(adresse);
});
contents.on('will-redirect', (ev, adresse) => {
if (!eigeneSeite(adresse) && !String(adresse).startsWith('data:')) ev.preventDefault();
});
contents.setWindowOpenHandler(({ url: adresse }) => {
nachAussen(adresse);
return { action: 'deny' };
});
contents.on('will-attach-webview', (ev) => ev.preventDefault());
contents.on('context-menu', (_ev, params) => { contents.on('context-menu', (_ev, params) => {
const auswahl = String(params.selectionText || '').trim(); const auswahl = String(params.selectionText || '').trim();
if (!params.isEditable && !auswahl) return; if (!params.isEditable && !auswahl) return;
@@ -956,11 +1067,16 @@ app.whenReady().then(() => {
moderkennung.init(app.getPath('userData')); moderkennung.init(app.getPath('userData'));
garderobe.init(app.getPath('userData')); garderobe.init(app.getPath('userData'));
erfolge.init(app.getPath('userData')); erfolge.init(app.getPath('userData'));
auth.init(app.getPath('userData'), { const tresor = {
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } }, available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
encrypt: (s) => safeStorage.encryptString(s).toString('base64'), encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')), decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
}); };
auth.init(app.getPath('userData'), tresor);
try { integritaet.init(app.getPath('userData'), tresor, store); } catch (err) { console.warn('Manipulationsschutz:', err.message); }
setTimeout(() => { pruefeAlleInstanzen().catch(() => {}); }, 8000);
// Proxy-Passwort und API-Schlüssel ebenso (siehe store.setzeTresor)
store.setzeTresor(tresor);
applyProxy(store.getSettings()); applyProxy(store.getSettings());
discordpresence.configure(store.getSettings()); discordpresence.configure(store.getSettings());
const autostartAn = !!store.getSettings().autostart; const autostartAn = !!store.getSettings().autostart;
@@ -1144,7 +1260,11 @@ ipcMain.handle('mods:search', async (_e, opts) => {
// Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden // Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“) // per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
async function modsMitErkennung(id, art) { async function modsMitErkennung(id, art) {
const liste = store.listMods(id, art); let liste = store.listMods(id, art);
if ((art || 'mod') === 'mod') {
const aus = ausstehendeMods(id);
liste = liste.map((m) => (m.filename in aus ? Object.assign({}, m, { enabled: !!aus[m.filename], ausstehend: true }) : m));
}
try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); } try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); }
catch { return liste; } catch { return liste; }
} }
@@ -1806,6 +1926,12 @@ ipcMain.handle('crash:openFolder', (_e, id) => {
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt) // Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => { ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
// Spiel läuft: Datei ist gesperrt -> vormerken, beim Beenden umsetzen
if ((art || 'mod') === 'mod' && laufendeInstanzen.has(id)) {
merkeModAenderung(id, filename, !!enabled);
broadcast('mods:changed', { id });
return { ok: true, enabled: !!enabled, ausstehend: true };
}
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod'); const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
if (res.ok) return res; if (res.ok) return res;
const ordner = services.inhaltsArt(art || 'mod').ordner; const ordner = services.inhaltsArt(art || 'mod').ordner;
@@ -2227,15 +2353,22 @@ ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt. // Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
function umhangAgent() { function umhangAgent() {
try { try {
// Die Kopie im Datenordner läuft mit vollen Rechten im Spiel mit. Deshalb vor
// jedem Start Byte für Byte mit dem Original im Paket vergleichen – eine
// ausgetauschte oder veränderte Datei wird überschrieben, nie geladen.
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar'); const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
const groesse = fs.statSync(quelle).size; const inhalt = fs.readFileSync(quelle);
const kennung = require('crypto').createHash('sha256').update(inhalt).digest('hex').slice(0, 16);
const ordner = path.join(app.getPath('userData'), 'agent'); const ordner = path.join(app.getPath('userData'), 'agent');
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`); const ziel = path.join(ordner, `aeromc-capes-${kennung}.jar`);
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) { let vorhanden = null;
try { vorhanden = fs.readFileSync(ziel); } catch { /* noch nicht da */ }
if (!vorhanden || !vorhanden.equals(inhalt)) {
fs.mkdirSync(ordner, { recursive: true }); fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(ziel, fs.readFileSync(quelle)); fs.writeFileSync(ziel, inhalt);
if (!fs.readFileSync(ziel).equals(inhalt)) throw new Error('Agent-Kopie weicht vom Original ab.');
for (const alt of fs.readdirSync(ordner)) { for (const alt of fs.readdirSync(ordner)) {
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } } if (alt !== path.basename(ziel) && /^aeromc-capes-[0-9a-f]+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
} }
} }
return `${ziel}=${friends.BASIS_URL}`; return `${ziel}=${friends.BASIS_URL}`;
@@ -2245,6 +2378,289 @@ function umhangAgent() {
} }
} }
// -javaagent-Argument: Umhänge und/oder AeroMC-Look im Spiel (siehe agent/Agent.java)
function agentArgument(settings, version, modsAdresse) {
const umhaenge = settings.aeromcCapes !== false;
const design = settings.aeromcDesign !== false && spieldesign.unterstuetzt(version)
? spieldesign.agentOrdner(app.getPath('userData')) : '';
if (!umhaenge && !design && !modsAdresse) return '';
const agent = umhangAgent();
if (!agent) return '';
return agent + (umhaenge ? '' : '|capes=0') + (design ? '|design=' + design : '') + (modsAdresse ? '|mods=' + modsAdresse : '');
}
// ---------- Mod-Schalter im Spiel (siehe modschalter.js) ----------
// Während Minecraft läuft, sind die Mod-Dateien gesperrt: Änderungen werden in
// instance.json vorgemerkt und beim Beenden umgesetzt.
const modFenster = new Map(); // instanceId -> BrowserWindow
const neustartNachBeenden = new Set(); // instanceId -> nach dem Beenden direkt wieder starten
const letzteVerbindungZeit = new Map(); // instanceId -> Zeitpunkt der letzten Server-Verbindung
const sitzungsInfo = new Map(); // instanceId -> { start, kontoId } der laufenden Sitzung
// Wohin der Neustart führt: zuletzt verbundener Server oder zuletzt gespeicherte
// Welt dieser Sitzung – je nachdem, was jünger ist – samt Konto der Sitzung
function neustartZiel(id) {
const info = sitzungsInfo.get(id) || {};
const seit = info.start || 0;
const ziel = { kontoId: info.kontoId || null };
const welt = seit ? zuletztGespielteWelt(id, seit) : null;
const serverZeit = letzteVerbindungZeit.get(id) || 0;
const server = letzteQuickPlayServer.get(id);
if (server && serverZeit >= seit && (!welt || serverZeit > welt.zeit)) ziel.quickPlayServer = server;
else if (welt) ziel.quickPlayWelt = welt.name;
return ziel;
}
function ausstehendeMods(id) {
const inst = store.getInstance(id);
return (inst && inst.modsAusstehend) || {};
}
function merkeModAenderung(id, filename, an) {
const aus = Object.assign({}, ausstehendeMods(id));
const mod = store.listMods(id, 'mod').find((m) => m.filename === filename);
if (mod && mod.enabled === an) delete aus[filename]; // zurück auf den Stand der Datei
else aus[filename] = an;
store.updateInstance(id, { modsAusstehend: aus });
return Object.keys(aus).length;
}
function wendeAusstehendeModsAn(id) {
const aus = ausstehendeMods(id);
const namen = Object.keys(aus);
if (!namen.length) return { umgesetzt: 0, offen: 0 };
const rest = {};
let umgesetzt = 0;
for (const f of namen) {
const r = store.setModEnabled(id, f, !!aus[f], 'mod');
if (r.ok) umgesetzt++;
else if (r.reason !== 'not-found' && r.reason !== 'both-exist' && r.reason !== 'bad-name') rest[f] = aus[f];
}
store.updateInstance(id, { modsAusstehend: rest });
broadcast('mods:changed', { id });
return { umgesetzt, offen: Object.keys(rest).length };
}
// Welt, die während der Sitzung zuletzt gespeichert wurde (level.dat neuer als der Start)
function zuletztGespielteWelt(id, seit) {
let beste = null;
try {
const savesDir = path.join(store.gameDir(id), 'saves');
for (const e of fs.readdirSync(savesDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
try {
const zeit = fs.statSync(path.join(savesDir, e.name, 'level.dat')).mtimeMs;
if (zeit >= seit - 5000 && (!beste || zeit > beste.zeit)) beste = { name: e.name, zeit };
} catch { /* keine Welt */ }
}
} catch { /* kein saves-Ordner */ }
return beste;
}
async function oeffneModFenster(sitzung) {
const { instanceId: id, pid } = sitzung;
const mc = await findeMinecraftFenster(pid);
const b = mc ? mc.getBounds() : null;
const breite = b ? Math.max(640, Math.min(980, Math.round(b.width * 0.9))) : 900;
const hoehe = b ? Math.max(460, Math.min(680, Math.round(b.height * 0.9))) : 620;
const lage = b ? { x: Math.round(b.x + (b.width - breite) / 2), y: Math.round(b.y + (b.height - hoehe) / 2) } : {};
let win = modFenster.get(id);
if (win && !win.isDestroyed()) {
win.setBounds(Object.assign({ width: breite, height: hoehe }, lage));
win.webContents.send('popout:reopen', { instanceId: id });
win.show();
win.focus();
return;
}
win = new BrowserWindow(Object.assign({
width: breite, height: hoehe, minWidth: 560, minHeight: 400,
title: 'AeroMC – Mods', backgroundColor: fensterFarbe(), icon: windowIcon(),
frame: false, show: false, skipTaskbar: true, alwaysOnTop: true,
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false },
}, lage));
win.setAlwaysOnTop(true, 'screen-saver');
modFenster.set(id, win);
win.loadFile(path.join(__dirname, 'index.html'), { query: { popout: 'modschalter', instanceId: id } });
win.once('ready-to-show', () => { win.show(); win.focus(); });
// nach dem Schließen gleich wieder ins Spiel
const zurueck = async () => {
const f = await findeMinecraftFenster(pid);
if (f) { try { f.bringToTop(); } catch { /* egal */ } }
};
win.on('hide', zurueck);
win.on('closed', () => { modFenster.delete(id); zurueck(); });
}
modschalter.beiKlick((sitzung) => { oeffneModFenster(sitzung).catch((err) => console.error('Mod-Fenster:', err)); });
ipcMain.handle('modschalter:daten', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
return {
ok: true,
name: inst.name,
version: inst.minecraft.version,
loader: inst.minecraft.loader,
laeuft: laufendeInstanzen.has(id),
ziel: laufendeInstanzen.has(id) ? neustartZiel(id) : null,
quickPlayWelt: spieldesign.paketFormat(inst.minecraft.version) >= 15, // Welt direkt öffnen geht ab 1.20
mods: await modsMitErkennung(id, 'mod'),
};
});
// ---------- Neustart-Bildschirm (neustart.html) ----------
// Liegt über dem Spielfenster, während Minecraft beendet, die Mods umgestellt
// und das Spiel neu gestartet wird – bis man wieder auf dem Server bzw. in der
// Welt ist (Minecraft hängt dann "Mehrspieler"/"Einzelspieler" an den Fenstertitel).
const neustartBild = new Map(); // instanceId -> { win, ziel, folger, notfall, fensterSeit, verbundenSeit }
const START_TEXTE = {
version: 'Minecraft wird vorbereitet', client: 'Minecraft wird vorbereitet', libraries: 'Bibliotheken werden geprüft',
assets: 'Spieldaten werden geprüft', loader: 'Mod-Loader wird vorbereitet', java: 'Java wird vorbereitet',
};
const START_ANTEILE = { version: [0, 6], client: [6, 14], libraries: [14, 45], assets: [45, 80], loader: [80, 90], java: [90, 96] };
// Fensterrechteck (physische Pixel) -> Electron-Koordinaten
function alsDip(r) {
try { return screen.screenToDipRect(null, r); } catch { return r; }
}
async function zeigeNeustartBild(id, pid) {
schliesseNeustartBild(id, true);
const mc = await findeMinecraftFenster(pid);
// Fensterzustand merken – Minecraft selbst weiß nach dem Neustart nicht mehr,
// dass es maximiert war, und käme in der Standardgröße wieder
let altesFenster = null;
if (mc) {
try {
const roh = mc.getBounds();
const dip = alsDip(roh);
const wa = screen.getDisplayMatching(dip).workArea;
const maximiert = dip.x <= wa.x && dip.y <= wa.y && dip.x + dip.width >= wa.x + wa.width && dip.y + dip.height >= wa.y + wa.height;
altesFenster = { bounds: roh, maximiert };
} catch { /* ohne Wiederherstellung */ }
}
let b = mc ? alsDip(mc.getBounds()) : null;
if (!b || b.width < 200 || b.height < 150) {
const a = screen.getPrimaryDisplay().workArea;
b = { x: Math.round(a.x + (a.width - 960) / 2), y: Math.round(a.y + (a.height - 540) / 2), width: 960, height: 540 };
}
const win = new BrowserWindow({
x: b.x, y: b.y, width: b.width, height: b.height,
frame: false, resizable: false, skipTaskbar: true, alwaysOnTop: true, focusable: false, show: false,
backgroundColor: '#0a0e1a', icon: windowIcon(),
webPreferences: { contextIsolation: true, sandbox: true },
});
win.setAlwaysOnTop(true, 'screen-saver');
win.loadFile(path.join(__dirname, 'neustart.html'));
win.once('ready-to-show', () => { if (!win.isDestroyed()) win.showInactive(); });
const eintrag = { win, ziel: null, folger: null, fensterSeit: 0, verbundenSeit: 0, altesFenster };
// Sicherheitsnetz: nie länger als 3 Minuten stehen lassen
eintrag.notfall = setTimeout(() => schliesseNeustartBild(id), 180000);
neustartBild.set(id, eintrag);
}
function neustartStatus(id, text, prozent) {
const e = neustartBild.get(id);
if (!e || e.win.isDestroyed()) return;
const js = `typeof setze === 'function' && setze(${JSON.stringify({ text, prozent: typeof prozent === 'number' ? prozent : null })})`;
if (e.win.webContents.isLoading()) e.win.webContents.once('did-finish-load', () => e.win.webContents.executeJavaScript(js).catch(() => {}));
else e.win.webContents.executeJavaScript(js).catch(() => {});
}
function schliesseNeustartBild(id, sofort) {
const e = neustartBild.get(id);
if (!e) return;
neustartBild.delete(id);
clearTimeout(e.notfall);
clearInterval(e.folger);
if (e.win.isDestroyed()) return;
if (sofort) { e.win.destroy(); return; }
e.win.webContents.executeJavaScript('typeof ausblenden === "function" && ausblenden()').catch(() => {});
setTimeout(() => { if (!e.win.isDestroyed()) e.win.destroy(); }, 420);
}
// Startfortschritt des Neustarts anzeigen (aus dem launch:progress der neuen Sitzung)
function neustartFortschritt(id, evt) {
if (!neustartBild.has(id)) return;
if (START_TEXTE[evt.phase]) {
const [von, bis] = START_ANTEILE[evt.phase];
const anteil = evt.total ? Math.min(1, evt.current / evt.total) : 0;
const prozent = Math.round(von + (bis - von) * anteil);
neustartStatus(id, `${START_TEXTE[evt.phase]} … ${prozent} %`, prozent);
} else if (evt.phase === 'launch') {
neustartStatus(id, 'Minecraft startet …', null);
}
}
// dem neuen Spielfenster folgen und ausblenden, sobald man wieder drin ist
function folgeNeuemSpiel(id, pid) {
const e = neustartBild.get(id);
if (!e) return;
let windowManager = null;
try { ({ windowManager } = require('node-window-manager')); } catch { /* ohne Fensterverfolgung */ }
const ziel = e.ziel || {};
e.folger = setInterval(() => {
if (e.win.isDestroyed()) { clearInterval(e.folger); return; }
let fenster = null;
// nur das Spielfenster – Java hat daneben u. a. ein (verstecktes) Konsolenfenster
try { fenster = windowManager && windowManager.getWindows().find((w) => w.processId === pid && /Minecraft/i.test(w.getTitle() || '') && w.isVisible()); } catch { /* weiter versuchen */ }
if (!fenster) return;
const jetzt = Date.now();
if (!e.fensterSeit) {
e.fensterSeit = jetzt;
// Größe/Lage bzw. „maximiert“ wie vor dem Neustart – kurz darauf noch einmal,
// falls Minecraft die Fenstergröße beim Hochfahren selbst noch setzt
const herstellen = () => {
const alt = e.altesFenster;
if (!alt) return;
try { if (alt.maximiert) fenster.maximize(); else fenster.setBounds(alt.bounds); } catch { /* egal */ }
};
herstellen();
setTimeout(herstellen, 1500);
}
try {
const r = alsDip(fenster.getBounds());
if (r.width > 200 && r.height > 150) e.win.setBounds(r);
} catch { /* Fenster gerade im Umbau */ }
let titel = '';
try { titel = fenster.getTitle() || ''; } catch { /* egal */ }
const imSpiel = titel.replace(/^AeroMC - /, '').split(' - ').length >= 2;
if (!ziel.quickPlayServer && !ziel.quickPlayWelt) {
// kein Ziel: das Spiel zeigt ab jetzt seinen eigenen (AeroMC-)Ladebildschirm
if (jetzt - e.fensterSeit > 1500) schliesseNeustartBild(id);
return;
}
if (imSpiel) {
neustartStatus(id, 'Willkommen zurück!', 100);
setTimeout(() => schliesseNeustartBild(id), 500);
clearInterval(e.folger);
return;
}
if (!e.verbundenSeit) {
neustartStatus(id, ziel.quickPlayServer ? `Verbinde mit ${ziel.quickPlayServer} …` : `Welt „${ziel.quickPlayWelt}“ wird geladen …`, null);
}
// ältere Versionen (1.14) schreiben nichts in den Titel -> nach dem Verbinden kurz warten
if (e.verbundenSeit && jetzt - e.verbundenSeit > 9000) schliesseNeustartBild(id);
if (jetzt - e.fensterSeit > 90000) schliesseNeustartBild(id);
}, 400);
}
ipcMain.handle('modschalter:neustart', async (_e, id) => {
const pid = laufendePid.get(id);
if (!pid) return { ok: false, message: 'Das Spiel läuft nicht.' };
neustartNachBeenden.add(id);
const win = modFenster.get(id);
if (win && !win.isDestroyed()) win.destroy();
await zeigeNeustartBild(id, pid).catch(() => {});
neustartStatus(id, 'Minecraft wird beendet …', null);
// ohne /F: Windows bittet das Fenster ums Schließen – Minecraft speichert und beendet sich sauber
const { spawn } = require('child_process');
spawn('taskkill', ['/PID', String(pid)], { windowsHide: true }).on('error', () => {});
return { ok: true };
});
ipcMain.on('modschalter:schliessen', (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w && !w.isDestroyed()) w.destroy();
});
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat // verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
function umhangFehler(res) { function umhangFehler(res) {
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' }; if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
@@ -2376,7 +2792,9 @@ async function findeMinecraftFenster(pid) {
for (let versuch = 1; versuch <= 4; versuch++) { for (let versuch = 1; versuch <= 4; versuch++) {
let alleFenster; let alleFenster;
try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; } try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; }
const treffer = alleFenster.find((w) => w.processId === pid); // bevorzugt das sichtbare Spielfenster (Java hat daneben u. a. ein verstecktes Konsolenfenster)
const vomProzess = alleFenster.filter((w) => w.processId === pid);
const treffer = vomProzess.find((w) => { try { return w.isVisible() && /Minecraft/i.test(w.getTitle() || ''); } catch { return false; } }) || vomProzess[0];
if (treffer) return treffer; if (treffer) return treffer;
if (versuch < 4) await new Promise((r) => setTimeout(r, 300)); if (versuch < 4) await new Promise((r) => setTimeout(r, 300));
} }
@@ -2695,6 +3113,7 @@ function teilBeschreibung(id) {
} }
ipcMain.handle('share:create', async (_e, id) => { ipcMain.handle('share:create', async (_e, id) => {
if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
const beschreibung = teilBeschreibung(id); const beschreibung = teilBeschreibung(id);
if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' }; if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' };
@@ -3034,9 +3453,9 @@ ipcMain.handle('auth:login', async () => {
// IPC – Instanzen // IPC – Instanzen
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
ipcMain.handle('instances:list', () => store.listInstances()); ipcMain.handle('instances:list', () => store.listInstances().map(mitSperre));
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id)); ipcMain.handle('instances:get', (_e, id) => mitSperre(store.getInstance(id)));
ipcMain.handle('instances:create', (_e, data) => { ipcMain.handle('instances:create', (_e, data) => {
const res = store.createInstance(data); const res = store.createInstance(data);
@@ -3050,9 +3469,26 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
return res; return res;
}); });
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id)); ipcMain.handle('instances:delete', (_e, id) => {
const res = store.deleteInstance(id);
integritaet.entferne(id);
return res;
});
// Gesperrte Instanz als neue Instanz aus der neuesten sauberen Sicherung holen
ipcMain.handle('integritaet:wiederherstellen', async (_e, id) => {
try {
const sicherungen = backup.listGlobalBackups(app.getPath('documents')).map((b) => b.path);
const res = await integritaet.stelleWiederHer(id, sicherungen);
broadcast('instances:changed');
return Object.assign({ ok: true }, res);
} catch (err) {
return { ok: false, message: err.message };
}
});
ipcMain.handle('instances:duplicate', async (_e, id) => { ipcMain.handle('instances:duplicate', async (_e, id) => {
if (integritaet.istGesperrt(id)) throw new Error(GESPERRT_TEXT);
const kopie = store.duplicateInstance(id, true); const kopie = store.duplicateInstance(id, true);
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); } try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
catch { /* Quelle evtl. leer */ } catch { /* Quelle evtl. leer */ }
@@ -3145,6 +3581,7 @@ function runPwsh(command, env) {
} }
ipcMain.handle('instances:export', async (_e, id) => { ipcMain.handle('instances:export', async (_e, id) => {
if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
const inst = store.getInstance(id); const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_'); const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_');
@@ -3325,11 +3762,24 @@ async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten })
const dest = path.join(gameDir, rel.replace(/\//g, path.sep)); const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue; if (!inside(dest)) continue;
broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' }); broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
// Nur über HTTPS und nur mit passender Prüfsumme aus dem Paket: so landet
// keine unterwegs ausgetauschte oder manipulierte Mod im mods-Ordner.
const hashes = f.hashes || {};
const algo = hashes.sha512 ? 'sha512' : (hashes.sha1 ? 'sha1' : null);
if (!/^https:\/\//i.test(String(url)) || !algo) {
console.warn(`[Modpack] ${rel} übersprungen (keine sichere Quelle/Prüfsumme)`);
continue;
}
try { try {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } }); const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) continue; if (!res.ok) continue;
const inhalt = Buffer.from(await res.arrayBuffer());
if (require('crypto').createHash(algo).update(inhalt).digest('hex') !== String(hashes[algo]).toLowerCase()) {
console.warn(`[Modpack] ${rel} verworfen: Prüfsumme stimmt nicht`);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); fs.writeFileSync(dest, inhalt);
dateien.push(rel); dateien.push(rel);
} catch { /* einzelne Datei überspringen */ } } catch { /* einzelne Datei überspringen */ }
} }
@@ -3541,7 +3991,7 @@ async function importCurseForge(buf, zip, zugang) {
try { try {
// Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf // Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf
// einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server. // einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server.
await services.downloadFile(info.url, dest, info.kopfzeilen); await services.downloadFile(info.url, dest, info.kopfzeilen, info.hashes);
geladen++; geladen++;
} catch { } catch {
fehlend.push(f.fileID); fehlend.push(f.fileID);
@@ -3597,7 +4047,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
}; };
} }
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' }); broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' });
const buf = await services.downloadBuffer(file.url); const buf = await services.downloadBuffer(file.url, null, file.hashes);
const zip = forge.readZipIndex(buf); const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) { if (!zip['modrinth.index.json']) {
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' }; return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
@@ -3648,7 +4098,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
} }
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' }); broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' });
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url; const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen); const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen, file.hashes);
const zip = forge.readZipIndex(buf); const zip = forge.readZipIndex(buf);
if (!zip['manifest.json']) { if (!zip['manifest.json']) {
return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' }; return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' };
@@ -3827,15 +4277,25 @@ async function importInstanceZipFromPath(filePath) {
notes: imported.notes || '', notes: imported.notes || '',
minecraft: imported.minecraft || {}, minecraft: imported.minecraft || {},
}); });
if (imported.java) store.updateInstance(created.id, { java: imported.java }); // Aus fremden Archiven nur den Arbeitsspeicher übernehmen. Java-Pfad und
// JVM-Argumente bleiben leer: ein präpariertes Archiv könnte sonst beim
// nächsten Start ein beliebiges Programm ausführen lassen (eigene "java.exe",
// -javaagent:…, -XX:OnOutOfMemoryError=…).
if (imported.java && typeof imported.java === 'object') {
const speicher = (v) => (Number.isFinite(Number(v)) && Number(v) >= 256 && Number(v) <= 262144 ? Math.round(Number(v)) : null);
store.updateInstance(created.id, { java: { minMemMb: speicher(imported.java.minMemMb), maxMemMb: speicher(imported.java.maxMemMb) } });
}
const mcSrc = path.join(base, 'minecraft'); const mcSrc = path.join(base, 'minecraft');
if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id)); if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
const modsSrc = path.join(base, 'mods.json'); const modsSrc = path.join(base, 'mods.json');
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json')); if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) { // nur ein schlichter Bild-Dateiname – kein "../" o. Ä., das aus dem Ordner hinausführt
fs.copyFileSync(path.join(base, imported.customImage), path.join(store.instancePath(created.id), imported.customImage)); const bildName = typeof imported.customImage === 'string' ? imported.customImage : '';
store.updateInstance(created.id, { customImage: imported.customImage }); if (/^[\w.\- ]+\.(png|jpe?g|gif|webp)$/i.test(bildName) && bildName === path.basename(bildName)
&& fs.existsSync(path.join(base, bildName))) {
fs.copyFileSync(path.join(base, bildName), path.join(store.instancePath(created.id), bildName));
store.updateInstance(created.id, { customImage: bildName });
} }
return { ok: true, instance: store.getInstance(created.id) }; return { ok: true, instance: store.getInstance(created.id) };
} catch (err) { } catch (err) {
@@ -4079,6 +4539,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (!inst.minecraft.version) { if (!inst.minecraft.version) {
return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' }; return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' };
} }
// Manipulationsschutz: gründliche Prüfung (alle Mods neu gehasht) vor jedem Start
{
const r = await integritaet.pruefe(id);
if (r.status === 'gesperrt') {
if (r.neuGesperrt) meldeGesperrt(id);
return { ok: false, gesperrt: true, message: GESPERRT_TEXT };
}
}
const settings = store.getSettings(); const settings = store.getSettings();
// Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung // Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung
@@ -4103,9 +4571,18 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten) // Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten)
let modUpdates = null; let modUpdates = null;
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null); if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
if (modUpdates) await integritaet.versiegele(id).catch(() => {}); // eigene Änderung, geprüft wurde oben
// vorgemerkte Mod-Änderungen aus der letzten Sitzung (falls dort nicht mehr umgesetzt)
if (!laufendeInstanzen.has(id)) wendeAusstehendeModsAn(id);
// Mod-Schalter im Pausemenü (nur Instanzen mit Mod-Loader)
const modSitzung = settings.modSchalter !== false && inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? await modschalter.neueSitzung(id).catch(() => null) : null;
const opts = { const opts = {
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(), javaAgent: agentArgument(settings, inst.minecraft.version, modSitzung && modSitzung.adresse),
// Ressourcenpaket für Menü-Logo, Sprüche und Knöpfe anlegen bzw. entfernen
vorStart: (info, protokoll) => spieldesign.vorbereiten(Object.assign({ an: settings.aeromcDesign !== false, mods: !!modSitzung, protokoll }, info)),
versionId: inst.minecraft.version, versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla', loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '', loaderVersion: inst.minecraft.loaderVersion || '',
@@ -4126,6 +4603,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar) // fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress), ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '', quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
quickPlayWelt: (launchOpts && launchOpts.quickPlayWelt) ? String(launchOpts.quickPlayWelt) : '',
}; };
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start // eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
@@ -4153,9 +4631,11 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
try { try {
let launchStart = 0; let launchStart = 0;
const res = await launcher.prepareAndLaunch(opts, (evt) => { const res = await launcher.prepareAndLaunch(opts, (evt) => {
if (neustartBild.has(id)) neustartFortschritt(id, evt);
if (evt.phase === 'launch') { if (evt.phase === 'launch') {
launchStart = Date.now(); launchStart = Date.now();
laufendeInstanzen.add(id); laufendeInstanzen.add(id);
sitzungsInfo.set(id, { start: Date.now(), kontoId });
meldeLaufende(); meldeLaufende();
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); } if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
discordpresence.setActivity({ discordpresence.setActivity({
@@ -4194,6 +4674,34 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
laufendeInstanzen.delete(id); laufendeInstanzen.delete(id);
meldeLaufende(); meldeLaufende();
if (modSitzung) modschalter.beende(modSitzung.token);
const offenesModFenster = modFenster.get(id);
if (offenesModFenster && !offenesModFenster.isDestroyed()) offenesModFenster.destroy();
{
const neuStarten = neustartNachBeenden.delete(id);
// jetzt bestimmen, solange die Sitzungsdaten noch stimmen
const zielBeimBeenden = neuStarten ? neustartZiel(id) : null;
sitzungsInfo.delete(id);
if (neuStarten) {
const bild = neustartBild.get(id);
if (bild) bild.ziel = zielBeimBeenden;
neustartStatus(id, 'Mods werden übernommen …', null);
} else if (neustartBild.has(id)) {
schliesseNeustartBild(id); // neues Spiel nach einem Neustart wieder beendet (z. B. Absturz)
}
// kurz warten, bis Windows die Mod-Dateien freigegeben hat
setTimeout(() => {
let erg = wendeAusstehendeModsAn(id);
if (erg.offen) { setTimeout(() => { erg = wendeAusstehendeModsAn(id); }, 2000); }
if (erg.umgesetzt && !neuStarten) broadcast('ui:toast', { text: `${erg.umgesetzt} Mod-Änderung${erg.umgesetzt === 1 ? '' : 'en'} übernommen – gilt ab dem nächsten Start.` });
if (neuStarten && mainWindow && !mainWindow.isDestroyed()) {
neustartStatus(id, 'Minecraft startet neu …', null);
mainWindow.webContents.send('modschalter:neustarten', { id, ziel: zielBeimBeenden });
} else if (neuStarten) {
schliesseNeustartBild(id);
}
}, 1500);
}
// beim nächsten Start dieses Kontos sollen bereits online befindliche // beim nächsten Start dieses Kontos sollen bereits online befindliche
// Freunde wieder gemeldet werden // Freunde wieder gemeldet werden
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); } if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
@@ -4218,6 +4726,9 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (m) { if (m) {
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`; const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
letzteQuickPlayServer.set(id, server); letzteQuickPlayServer.set(id, server);
letzteVerbindungZeit.set(id, Date.now());
const bild = neustartBild.get(id);
if (bild && !bild.verbundenSeit) { bild.verbundenSeit = Date.now(); neustartStatus(id, `Verbinde mit ${server} …`, null); }
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im // damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde // Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
broadcast('recording:serverDetected', { id, server }); broadcast('recording:serverDetected', { id, server });
@@ -4239,6 +4750,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
} }
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length; if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
if (res.ok && res.pid) laufendePid.set(id, res.pid); if (res.ok && res.pid) laufendePid.set(id, res.pid);
if (modSitzung) { if (res.ok && res.pid) modschalter.setzePid(modSitzung.token, res.pid); else modschalter.beende(modSitzung.token); }
if (neustartBild.has(id)) { if (res.ok && res.pid) folgeNeuemSpiel(id, res.pid); else schliesseNeustartBild(id); }
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); } if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
return res; return res;
} catch (err) { } catch (err) {
@@ -4611,12 +5124,17 @@ ipcMain.handle('update:check', () => pruefeAufUpdates());
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck); ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden // Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
ipcMain.handle('update:install', async (_e, asset) => { // Die Download-Adresse kommt bewusst nicht aus dem Fenster, sondern aus der
// eigenen Update-Prüfung – sonst könnte jeder Aufruf von window.api eine
// beliebige Datei als "Update" ausführen lassen.
ipcMain.handle('update:install', async () => {
try { try {
const asset = pendingUpdateInfo && pendingUpdateInfo.available ? pendingUpdateInfo.asset : null;
if (!asset) throw new Error('Kein geprüftes Update vorhanden – bitte erneut nach Updates suchen.');
const file = await updater.downloadAndInstall(asset, (p) => { const file = await updater.downloadAndInstall(asset, (p) => {
broadcast('update:progress', p); broadcast('update:progress', p);
}); });
const res = updater.applyUpdate(file); const res = await updater.applyUpdate(file);
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt // Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
setTimeout(() => app.quit(), 1200); setTimeout(() => app.quit(), 1200);
return { ok: true, path: file.path, kind: file.kind, mode: res.started }; return { ok: true, path: file.path, kind: file.kind, mode: res.started };
+65
View File
@@ -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 };
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; 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>
+13 -2
View File
@@ -5,7 +5,7 @@
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff. * contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
*/ */
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer, webUtils } = require('electron');
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses, // Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
// damit man sie auch ohne offene DevTools sieht // damit man sie auch ohne offene DevTools sieht
@@ -119,6 +119,9 @@ contextBridge.exposeInMainWorld('api', {
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name), openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts), launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
exportInstance: (id) => ipcRenderer.invoke('instances:export', id), 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'), createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
listGlobalBackups: () => ipcRenderer.invoke('backup:list'), listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath), restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
@@ -170,6 +173,14 @@ contextBridge.exposeInMainWorld('api', {
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'), garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'), erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'), 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) => { onLaufendeInstanzen: (cb) => {
const l = (_e, d) => cb(d || []); const l = (_e, d) => cb(d || []);
ipcRenderer.on('game:laufend', l); ipcRenderer.on('game:laufend', l);
@@ -309,7 +320,7 @@ contextBridge.exposeInMainWorld('api', {
// Updates // Updates
updateCheck: () => ipcRenderer.invoke('update:check'), updateCheck: () => ipcRenderer.invoke('update:check'),
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'), updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset), updateInstall: () => ipcRenderer.invoke('update:install'),
onUpdateAvailable: (cb) => { onUpdateAvailable: (cb) => {
const l = (_e, d) => cb(d); const l = (_e, d) => cb(d);
ipcRenderer.on('update:available', l); ipcRenderer.on('update:available', l);
+186 -6
View File
@@ -1702,7 +1702,11 @@ function showUpdateDialog(info) {
el('upd-install').textContent = 'Installieren'; el('upd-install').textContent = 'Installieren';
el('upd-open').classList.toggle('hidden', hatPaket || !info.url); el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
el('upd-hint').textContent = hatPaket el('upd-hint').textContent = hatPaket
? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.' ? 'Der Launcher lädt das Update herunter, prüft die Signatur und startet die Installation automatisch.'
: info.signaturHinweis
? info.signaturHinweis
: info.unsigniert
? 'Dieses Release ist nicht signiert. Aus Sicherheitsgründen wird es nicht automatisch installiert – bitte nur von der offiziellen Seite laden.'
: (channel : (channel
? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.` ? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.`
: 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.'); : 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.');
@@ -1729,7 +1733,7 @@ async function startUpdate() {
el('upd-progress').classList.remove('hidden'); el('upd-progress').classList.remove('hidden');
el('upd-status').textContent = 'Wird heruntergeladen …'; el('upd-status').textContent = 'Wird heruntergeladen …';
const res = await window.api.updateInstall(pendingUpdate.asset); const res = await window.api.updateInstall();
if (res.ok) { if (res.ok) {
el('upd-status').textContent = res.kind === 'portable' el('upd-status').textContent = res.kind === 'portable'
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.' ? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
@@ -2233,6 +2237,7 @@ async function refreshSelectionPanel() {
if (count > 1) { if (count > 1) {
currentDetailId = null; currentDetailId = null;
zeigeSperre(null);
setSingleSelectionActionsDisabled(true); setSingleSelectionActionsDisabled(true);
el('p-delete').disabled = false; el('p-delete').disabled = false;
el('p-delete').textContent = `${count} Instanzen löschen`; el('p-delete').textContent = `${count} Instanzen löschen`;
@@ -2283,6 +2288,49 @@ async function refreshSelectionPanel() {
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')'; el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie'); el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds); el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
zeigeSperre(inst);
}
// Manipulierte Instanz (siehe integritaet.js im Hauptprozess): nicht startbar,
// nicht kopier-/export-/teilbar – nur ansehen, wiederherstellen oder löschen
const GESPERRT_AUS = ['p-play', 'p-offline', 'p-edit', 'p-mods', 'p-modpack', 'p-record', 'p-servers', 'p-shortcut', 'p-export', 'p-share', 'p-copy'];
function zeigeSperre(inst) {
const sperre = inst && inst.gesperrt && inst.gesperrt.gesperrt ? inst.gesperrt : null;
el('p-gesperrt').classList.toggle('hidden', !sperre);
el('p-icon').classList.toggle('gesperrt', !!sperre);
['p-play', 'p-offline'].forEach((id) => el(id).classList.toggle('hidden', !!sperre));
el('p-share').disabled = false;
el('p-record').disabled = false;
if (!sperre) return;
GESPERRT_AUS.forEach((id) => { el(id).disabled = true; });
if (!el('p-icon').querySelector('.side-verbot')) {
el('p-icon').insertAdjacentHTML('beforeend', '<img class="side-verbot" src="assets/verbot.svg" alt="" />');
}
const gruende = sperre.gruende || [];
const liste = el('p-gesperrt-gruende');
liste.innerHTML = gruende.slice(0, 5).map((g) => `<li>${escapeHtml(g)}</li>`).join('')
+ (gruende.length > 5 ? `<li>… und ${gruende.length - 5} weitere</li>` : '')
+ (sperre.seit ? `<li class="dim">Erkannt: ${escapeHtml(fmtDate(sperre.seit))}</li>` : '');
el('status-play').textContent = 'Gesperrt – bitte aus einer Sicherung wiederherstellen';
}
async function wiederherstellenAusSicherung() {
const id = currentDetailId;
if (!id) return;
const knopf = el('p-wiederherstellen');
knopf.disabled = true;
knopf.textContent = 'Sicherungen werden geprüft …';
try {
const res = await window.api.integritaetWiederherstellen(id);
if (!res.ok) { toast(res.message || 'Wiederherstellen fehlgeschlagen.', true); return; }
toast(`Wiederhergestellt aus ${res.sicherung} als „${res.name}“. Die gesperrte Instanz kannst du jetzt löschen.`);
await reload();
await selectInstance(res.id);
} finally {
knopf.disabled = false;
knopf.textContent = 'Aus Sicherung wiederherstellen';
}
} }
async function applySelection(ids) { async function applySelection(ids) {
@@ -2313,13 +2361,17 @@ function buildCard(inst) {
const taste = schnellstartTasteVon(inst.id); const taste = schnellstartTasteVon(inst.id);
if (taste) tip += `\nSchnellstart: Strg+${taste}`; if (taste) tip += `\nSchnellstart: Strg+${taste}`;
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim(); if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
const gesperrt = !!(inst.gesperrt && inst.gesperrt.gesperrt);
if (gesperrt) tip = 'GESPERRT – außerhalb des Launchers verändert\n' + tip;
card.title = tip; card.title = tip;
card.innerHTML = ` card.innerHTML = `
${iconHtml} ${iconHtml}
<i class="card-riss" aria-hidden="true"></i> <i class="card-riss" aria-hidden="true"></i>
${gesperrt ? '<img class="card-verbot" src="assets/verbot.svg" alt="Gesperrt" />' : ''}
<div class="card-name">${escapeHtml(inst.name)}</div> <div class="card-name">${escapeHtml(inst.name)}</div>
${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`; ${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`;
if (gesperrt) card.classList.add('gesperrt');
if (laufendeIds.has(inst.id)) card.classList.add('laeuft'); if (laufendeIds.has(inst.id)) card.classList.add('laeuft');
if (startStand.has(inst.id)) zeigeAbbau(card); if (startStand.has(inst.id)) zeigeAbbau(card);
@@ -2516,9 +2568,13 @@ function baueGruppenSektion(knoten, tiefe, showHeaders) {
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben – // Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere // stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); }); // Dateien aus dem Explorer gehören nicht hierher, sondern zum Datei-Import
// (wireFileDrop) – sonst würde die Gruppe sie verschlucken
const istDateiZug = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
section.addEventListener('dragover', (e) => { if (istDateiZug(e)) return; e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); }); section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
section.addEventListener('drop', async (e) => { section.addEventListener('drop', async (e) => {
if (istDateiZug(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
section.classList.remove('drop-hover'); section.classList.remove('drop-hover');
@@ -2860,6 +2916,11 @@ let launching = false;
async function launch(id, accountId, launchOpts) { async function launch(id, accountId, launchOpts) {
if (launching) return; if (launching) return;
const lokal = instances.find((i) => i.id === id);
if (lokal && lokal.gesperrt && lokal.gesperrt.gesperrt) {
toast('Diese Instanz ist gesperrt, weil sie verändert wurde – bitte aus einer Sicherung wiederherstellen.', true);
return;
}
launching = true; launching = true;
status('Vorbereiten …'); status('Vorbereiten …');
el('status-right').textContent = ''; el('status-right').textContent = '';
@@ -4987,6 +5048,101 @@ async function installiereTeilCode() {
el('code-preview').classList.add('hidden'); el('code-preview').classList.add('hidden');
} }
// ---------- Mod-Schalter (Fenster über dem laufenden Spiel) ----------
// Zwei Listen wie beim Ressourcenpaket-Bildschirm: ein Klick schiebt eine Mod in
// die andere Liste. Läuft das Spiel, merkt der Launcher die Änderung vor und
// setzt sie beim Beenden um (geladene Mods lassen sich nicht entladen).
let msInstanz = null;
let msDaten = null;
let msStart = null; // Stand beim Öffnen: filename -> an
async function openModschalter(instanceId) {
if (POPOUT !== 'modschalter') return;
msInstanz = instanceId || new URLSearchParams(location.search).get('instanceId');
msStart = null;
el('ms-suche').value = '';
show('modal-modschalter');
await ladeModschalter();
el('ms-suche').focus();
}
async function ladeModschalter(bewegt) {
const d = await window.api.modschalterDaten(msInstanz);
if (!d.ok) { el('ms-status').textContent = d.message || 'Instanz nicht gefunden.'; return; }
msDaten = d;
if (!msStart) msStart = new Map(d.mods.map((m) => [m.filename, m.enabled !== false]));
el('ms-instanz').textContent = `${d.name} · ${d.version}`;
zeichneModschalter(bewegt);
}
function zeichneModschalter(bewegt) {
const q = el('ms-suche').value.trim().toLowerCase();
const passt = (m) => !q || `${m.title || ''} ${m.filename}`.toLowerCase().includes(q);
const mods = msDaten.mods.filter((m) => !m.fehlt).sort((a, b) => (a.title || a.filename).localeCompare(b.title || b.filename, 'de'));
const an = mods.filter((m) => m.enabled !== false), aus = mods.filter((m) => m.enabled === false);
el('ms-zahl-an').textContent = `(${an.length})`;
el('ms-zahl-aus').textContent = `(${aus.length})`;
for (const [box, liste, istAn] of [[el('ms-an'), an, true], [el('ms-aus'), aus, false]]) {
box.innerHTML = '';
const sichtbar = liste.filter(passt);
if (!sichtbar.length) {
box.innerHTML = `<div class="ms-leer">${q ? 'Keine Treffer.' : istAn ? 'Keine aktiven Mods.' : 'Hierher verschobene Mods werden deaktiviert.'}</div>`;
continue;
}
for (const m of sichtbar) {
const zeile = document.createElement('div');
zeile.className = 'ms-mod' + (bewegt === m.filename ? ' kommt' : '');
zeile.style.setProperty('--ms-von', istAn ? '14px' : '-14px');
zeile.setAttribute('role', 'listitem');
zeile.title = istAn ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren';
const geaendert = msStart && msStart.get(m.filename) !== (m.enabled !== false);
zeile.innerHTML = `<div class="ms-icon">${m.iconUrl ? `<img src="${escapeHtml(m.iconUrl)}" alt="" />` : '🧩'}</div>
<div class="ms-info"><div class="ms-name">${escapeHtml(m.title || m.filename)}${geaendert ? '<span class="ms-neu">ab nächstem Start</span>' : ''}</div>
<div class="ms-meta">${escapeHtml([m.versionNumber && m.versionNumber !== 'Importiert' ? m.versionNumber : '', m.filename].filter(Boolean).join(' · '))}</div>
<div class="ms-warn"></div></div>
<button class="ms-pfeil" tabindex="-1">${istAn ? '›' : '‹'}</button>`;
const img = zeile.querySelector('img');
if (img) img.addEventListener('error', () => { img.parentElement.textContent = '🧩'; });
zeile.addEventListener('click', () => schalteMod(m, !istAn, zeile));
box.appendChild(zeile);
}
}
const geaendert = msStart ? msDaten.mods.filter((m) => msStart.get(m.filename) !== (m.enabled !== false)).length : 0;
el('ms-neustart').classList.toggle('hidden', !(msDaten.laeuft && geaendert));
const z = msDaten.ziel;
el('ms-neustart').title = z && z.quickPlayServer
? `Minecraft speichert, startet mit den neuen Mods neu und verbindet wieder mit ${z.quickPlayServer}`
: z && z.quickPlayWelt && msDaten.quickPlayWelt
? `Minecraft speichert, startet mit den neuen Mods neu und öffnet wieder die Welt „${z.quickPlayWelt}“`
: 'Minecraft speichert und startet mit den neuen Mods neu (ins Hauptmenü)';
el('ms-status').textContent = geaendert
? (msDaten.laeuft
? `${geaendert} Änderung${geaendert === 1 ? '' : 'en'} – gilt ab dem nächsten Start (das Spiel läuft weiter)`
+ (z && z.quickPlayServer ? ` · Neustart verbindet wieder mit ${z.quickPlayServer}` : z && z.quickPlayWelt && msDaten.quickPlayWelt ? ` · Neustart öffnet wieder „${z.quickPlayWelt}“` : '')
: `${geaendert} Änderung${geaendert === 1 ? '' : 'en'} übernommen`)
: 'Klicke eine Mod an, um sie in die andere Liste zu verschieben.';
}
async function schalteMod(m, an, zeile) {
if (!an) {
// Warnen, wenn andere Mods diese brauchen – verschoben wird trotzdem
const brauchen = await window.api.modDependents(msInstanz, m.filename, 'mod').catch(() => []);
if (brauchen && brauchen.length) toast(`${m.title || m.filename} wird gebraucht von: ${brauchen.slice(0, 3).join(', ')}${brauchen.length > 3 ? ' …' : ''}`, true);
}
if (zeile) zeile.style.pointerEvents = 'none';
const res = await window.api.modSetEnabled(msInstanz, m.filename, an, 'mod');
if (!res.ok) toast(res.message || 'Umschalten fehlgeschlagen.', true);
await ladeModschalter(m.filename);
}
function schliesseModschalter() { window.api.modschalterSchliessen(); }
async function modschalterNeustart() {
el('ms-neustart').disabled = true;
el('ms-status').textContent = 'Minecraft speichert und startet neu …';
const res = await window.api.modschalterNeustart(msInstanz);
if (!res.ok) { el('ms-neustart').disabled = false; toast(res.message || 'Neustart nicht möglich.', true); }
}
// ---------- Spielzeit-Statistik ---------- // ---------- Spielzeit-Statistik ----------
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste // Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten). // (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
@@ -5704,6 +5860,7 @@ async function refreshInstalled() {
const kennzeichen = []; const kennzeichen = [];
if (up) kennzeichen.push('<span class="mod-new">Update</span>'); if (up) kennzeichen.push('<span class="mod-new">Update</span>');
if (aus) kennzeichen.push('<span class="mod-off">aus</span>'); if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
if (m.ausstehend) kennzeichen.push('<span class="mod-new" title="Das Spiel läuft – die Änderung wird beim Beenden übernommen">ab nächstem Start</span>');
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>'); if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
// OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf // OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf
// deren Downloads) – von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf. // deren Downloads) – von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf.
@@ -5733,7 +5890,7 @@ async function refreshInstalled() {
schalter.addEventListener('click', async () => { schalter.addEventListener('click', async () => {
schalter.disabled = true; schalter.disabled = true;
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt); const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt);
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title); if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title + (res.ausstehend ? ' – gilt ab dem nächsten Start' : ''));
else toast(res.message || 'Umschalten fehlgeschlagen.', true); else toast(res.message || 'Umschalten fehlgeschlagen.', true);
await refreshInstalled(); await refreshInstalled();
}); });
@@ -5981,7 +6138,9 @@ async function openSettings(pane) {
el('s-friends').checked = s.friendsEnabled !== false; el('s-friends').checked = s.friendsEnabled !== false;
el('s-friends-server').checked = s.friendsShareServer !== false; el('s-friends-server').checked = s.friendsShareServer !== false;
el('s-auto-mod-updates').checked = !!s.autoModUpdates; el('s-auto-mod-updates').checked = !!s.autoModUpdates;
el('s-mod-schalter').checked = s.modSchalter !== false;
el('s-aeromc-capes').checked = s.aeromcCapes !== false; el('s-aeromc-capes').checked = s.aeromcCapes !== false;
el('s-aeromc-design').checked = s.aeromcDesign !== false;
el('s-autostart').checked = !!s.autostart; el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false; el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled; el('s-autobackup').checked = !!s.autoBackupEnabled;
@@ -6117,7 +6276,9 @@ async function saveSettings() {
friendsEnabled: el('s-friends').checked, friendsEnabled: el('s-friends').checked,
friendsShareServer: el('s-friends-server').checked, friendsShareServer: el('s-friends-server').checked,
autoModUpdates: el('s-auto-mod-updates').checked, autoModUpdates: el('s-auto-mod-updates').checked,
modSchalter: el('s-mod-schalter').checked,
aeromcCapes: el('s-aeromc-capes').checked, aeromcCapes: el('s-aeromc-capes').checked,
aeromcDesign: el('s-aeromc-design').checked,
autostart: el('s-autostart').checked, autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked, autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked, autoBackupEnabled: el('s-autobackup').checked,
@@ -6950,6 +7111,7 @@ function wire() {
// Rechte Aktionsleiste (Instanz-Aktionen) // Rechte Aktionsleiste (Instanz-Aktionen)
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId)); el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-wiederherstellen').addEventListener('click', wiederherstellenAusSicherung);
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId)); el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', async () => { el('p-shortcut').addEventListener('click', async () => {
if (!currentDetailId) return; if (!currentDetailId) return;
@@ -7457,8 +7619,8 @@ function wireFileDrop() {
e.preventDefault(); e.preventDefault();
dragDepth = 0; dragDepth = 0;
document.body.classList.remove('file-drop-active'); document.body.classList.remove('file-drop-active');
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path); const pfade = [...(e.dataTransfer.files || [])].map((f) => window.api.pfadVonDatei(f)).filter(Boolean);
for (const f of files) await handleDroppedFile(f.path); for (const pfad of pfade) await handleDroppedFile(pfad);
}); });
} }
@@ -7523,6 +7685,7 @@ const POPOUT_OPENERS = {
record: () => openRecording(), record: () => openRecording(),
friends: () => openFriends(), friends: () => openFriends(),
stats: (p) => openStats(p.reiter), stats: (p) => openStats(p.reiter),
modschalter: (p) => openModschalter(p.instanceId),
}; };
function popoutBoot(params) { function popoutBoot(params) {
@@ -7612,6 +7775,23 @@ window.addEventListener('DOMContentLoaded', async () => {
await refreshSkinBackground(); await refreshSkinBackground();
window.api.onLaunchProgress(onLaunchEvt); window.api.onLaunchProgress(onLaunchEvt);
window.api.onErfolgeNeu(zeigeErfolgMeldung); window.api.onErfolgeNeu(zeigeErfolgMeldung);
// Mod-Schalter
el('ms-suche').addEventListener('input', () => msDaten && zeichneModschalter());
el('ms-x').addEventListener('click', schliesseModschalter);
el('ms-fertig').addEventListener('click', schliesseModschalter);
el('ms-neustart').addEventListener('click', modschalterNeustart);
document.addEventListener('keydown', (e) => { if (POPOUT === 'modschalter' && e.key === 'Escape') schliesseModschalter(); });
// Neustart nach dem Mod-Schalter: wieder in dieselbe Welt bzw. auf denselben Server
if (!POPOUT) {
window.api.onModschalterNeustarten(({ id, ziel }) => {
if (!instances.some((i) => i.id === id)) return;
const wohin = ziel && ziel.quickPlayServer ? ` und verbindet wieder mit ${ziel.quickPlayServer}` : ziel && ziel.quickPlayWelt ? ` in die Welt „${ziel.quickPlayWelt}“` : '';
toast(`Mods übernommen – Minecraft startet neu${wohin} …`);
const opts = ziel && (ziel.quickPlayServer || ziel.quickPlayWelt)
? { quickPlayServer: ziel.quickPlayServer || '', quickPlayWelt: ziel.quickPlayWelt || '' } : null;
launch(id, (ziel && ziel.kontoId) || null, opts);
});
}
el('upd-notes').addEventListener('click', updateLinkKlick); el('upd-notes').addEventListener('click', updateLinkKlick);
window.api.onLaufendeInstanzen(setzeLaufende); window.api.onLaufendeInstanzen(setzeLaufende);
window.api.laufendeInstanzen().then(setzeLaufende).catch(() => {}); window.api.laufendeInstanzen().then(setzeLaufende).catch(() => {});
+47 -8
View File
@@ -299,6 +299,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
versionNumber: v.version_number, versionNumber: v.version_number,
filename: file.filename, filename: file.filename,
url: file.url, url: file.url,
hashes: mrHashes(file),
sha1: file.hashes && file.hashes.sha1, sha1: file.hashes && file.hashes.sha1,
size: file.size, size: file.size,
dependencies: v.dependencies || [], dependencies: v.dependencies || [],
@@ -324,6 +325,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
versionNumber: v.version_number, versionNumber: v.version_number,
filename: file.filename, filename: file.filename,
url: file.url, url: file.url,
hashes: mrHashes(file),
sha1: file.hashes && file.hashes.sha1, sha1: file.hashes && file.hashes.sha1,
size: file.size, size: file.size,
dependencies: v.dependencies || [], dependencies: v.dependencies || [],
@@ -350,23 +352,58 @@ async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
name: v.name || v.version_number, name: v.name || v.version_number,
filename: file.filename, filename: file.filename,
url: file.url, url: file.url,
hashes: mrHashes(file),
size: file.size || 0, size: file.size || 0,
gameVersions: v.game_versions || [], gameVersions: v.game_versions || [],
loaders: v.loaders || [], loaders: v.loaders || [],
}; };
} }
async function downloadBuffer(url, extraHeaders) { /*
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) }); * Prüfsumme eines Downloads kontrollieren. erwartet: { sha512?, sha1? } – die
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); * Werte stammen aus der Modrinth- bzw. CurseForge-API. Ohne Angabe wird nichts
return Buffer.from(await res.arrayBuffer()); * 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 // CurseForge liefert Prüfsummen als Liste: algo 1 = SHA-1, 2 = MD5
async function downloadFile(url, destPath, extraHeaders) { 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 || {}) }); const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
const buf = Buffer.from(await res.arrayBuffer()); 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.mkdirSync(path.dirname(destPath), { recursive: true });
fs.writeFileSync(destPath, buf); fs.writeFileSync(destPath, buf);
return buf.length; return buf.length;
@@ -383,7 +420,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir,
: `Keine passende Version für ${gameVersion} gefunden.` }; : `Keine passende Version für ${gameVersion} gefunden.` };
} }
const dest = path.join(modsDir, file.filename); 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 { return {
ok: true, ok: true,
dependencies: file.dependencies || [], dependencies: file.dependencies || [],
@@ -738,6 +775,7 @@ async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
fileId: match.id, fileId: match.id,
filename: name, filename: name,
url: dl, url: dl,
hashes: cfHashes(match),
versionNumber: match.displayName || match.fileName || String(match.id), versionNumber: match.displayName || match.fileName || String(match.id),
dependencies: match.dependencies || [], dependencies: match.dependencies || [],
}; };
@@ -768,7 +806,7 @@ async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader
// Download: direkt mit Key oder über Weiterleitung // Download: direkt mit Key oder über Weiterleitung
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url; const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const headers = Object.assign({}, zugang.downloadKopfzeilen || {}); 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) // Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
const depIds = (file.dependencies || []) const depIds = (file.dependencies || [])
@@ -826,6 +864,7 @@ async function curseforgeResolveFiles(zugang, files, onProgress) {
map.set(Number(d.id), { map.set(Number(d.id), {
fileName: d.fileName, fileName: d.fileName,
url: zugang.downloadAdresse(roh), url: zugang.downloadAdresse(roh),
hashes: cfHashes(d),
kopfzeilen: zugang.downloadKopfzeilen, kopfzeilen: zugang.downloadKopfzeilen,
}); });
} }
+359
View File
@@ -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 };
+54 -2
View File
@@ -79,6 +79,8 @@ const DEFAULT_SETTINGS = {
friendsEnabled: true, friendsEnabled: true,
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten") friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
autoModUpdates: false, autoModUpdates: false,
modSchalter: true, // „Mods verwalten“ im Pausemenü (Instanzen mit Mod-Loader)
aeromcDesign: true, // AeroMC-Look im Spiel: Ladebildschirm, Menü-Logo, Sprüche, Knöpfe, Fenstertitel
aeromcCapes: true, // AeroMC-Umhänge im Spiel anzeigen (Umhang-Agent beim Start) // vor jedem Start Mods über Modrinth aktualisieren aeromcCapes: true, // AeroMC-Umhänge im Spiel anzeigen (Umhang-Agent beim Start) // vor jedem Start Mods über Modrinth aktualisieren
schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … }) schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … })
autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
@@ -116,13 +118,61 @@ function settingsFile() {
return path.join(USER_DATA, 'settings.json'); return path.join(USER_DATA, 'settings.json');
} }
/*
* Geheime Einstellungen (Proxy-Passwort, API-Schlüssel, Token) stehen nicht im
* Klartext in settings.json, sondern mit Windows-DPAPI verschlüsselt – genau
* wie die Konten (siehe auth.js). Nur dieses Windows-Konto kann sie lesen.
* Der Tresor kommt erst nach app.ready (safeStorage); bis dahin bleiben die
* verschlüsselten Werte unverändert stehen und werden so auch zurückgeschrieben.
*/
const GEHEIME_FELDER = ['proxyPass', 'curseforgeApiKey', 'curseforgeProxyToken'];
const ENC = 'enc:v1:';
let tresor = null;
function bereit() {
try { return !!(tresor && tresor.available()); } catch { return false; }
}
function verschluesseln(obj) {
if (!bereit()) return obj;
const o = Object.assign({}, obj);
for (const k of GEHEIME_FELDER) {
if (typeof o[k] === 'string' && o[k] && !o[k].startsWith(ENC)) o[k] = ENC + tresor.encrypt(o[k]);
}
return o;
}
function entschluesseln(obj) {
if (!bereit()) return obj;
const o = Object.assign({}, obj);
for (const k of GEHEIME_FELDER) {
if (typeof o[k] === 'string' && o[k].startsWith(ENC)) {
// nicht lesbar (z. B. Sicherung von einem anderen PC) -> neu eingeben lassen
try { o[k] = tresor.decrypt(o[k].slice(ENC.length)); } catch { o[k] = ''; }
}
}
return o;
}
// Wird nach app.ready aufgerufen; stellt vorhandene Klartext-Werte sofort um
function setzeTresor(t) {
tresor = t;
if (!bereit()) return;
try {
const roh = readJson(settingsFile(), null);
if (roh && GEHEIME_FELDER.some((k) => typeof roh[k] === 'string' && roh[k] && !roh[k].startsWith(ENC))) {
writeJson(settingsFile(), verschluesseln(roh));
}
} catch { /* beim nächsten Speichern erneut */ }
}
function getSettings() { function getSettings() {
return Object.assign({}, DEFAULT_SETTINGS, readJson(settingsFile(), {})); return Object.assign({}, DEFAULT_SETTINGS, entschluesseln(readJson(settingsFile(), {})));
} }
function saveSettings(patch) { function saveSettings(patch) {
const merged = Object.assign({}, getSettings(), patch || {}); const merged = Object.assign({}, getSettings(), patch || {});
writeJson(settingsFile(), merged); writeJson(settingsFile(), verschluesseln(merged));
// Falls sich das Instanzverzeichnis geändert hat: sicherstellen, dass es existiert // Falls sich das Instanzverzeichnis geändert hat: sicherstellen, dass es existiert
ensureDir(getInstancesDir()); ensureDir(getInstancesDir());
return merged; return merged;
@@ -510,6 +560,7 @@ function duplicateInstance(id, ohneSpieldaten) {
module.exports = { module.exports = {
init, init,
getSettings, getSettings,
setzeTresor,
saveSettings, saveSettings,
getInstancesDir, getInstancesDir,
instancePath, instancePath,
@@ -530,6 +581,7 @@ module.exports = {
createInstance, createInstance,
createOrReplaceImportedInstance, createOrReplaceImportedInstance,
updateInstance, updateInstance,
uniqueFolderId,
deleteInstance, deleteInstance,
raeumePapierkorbAuf, raeumePapierkorbAuf,
duplicateInstance, duplicateInstance,
+43
View File
@@ -839,6 +839,32 @@ select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border
.erfolg-karte small{display:block;font-size:11px;color:var(--dim);line-height:1.3} .erfolg-karte small{display:block;font-size:11px;color:var(--dim);line-height:1.3}
.erfolg-karte.offen{opacity:.45;filter:grayscale(1)} .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) */ /* Konto-Menü oben rechts (wie in MultiMC) */
.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px} .acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
.konto-menu{min-width:220px} .konto-menu{min-width:220px}
@@ -915,3 +941,20 @@ select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border
.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy} .se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-fuss{align-items:center} .se-fuss{align-items:center}
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 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}
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXxt1cCntDQzvej4hjyVZt6Onj6Uv3hSlneJaOxxmsds=
-----END PUBLIC KEY-----
+137 -12
View File
@@ -16,6 +16,90 @@ const { spawn } = require('child_process');
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases'; const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
const UA = 'AeroMC-Launcher'; 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'. * Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
@@ -179,15 +263,39 @@ async function checkForUpdate(currentVersion) {
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name)); releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
const latest = releases[0]; const latest = releases[0];
const available = compareVersions(latest.tag_name, currentVersion) > 0; const available = compareVersions(latest.tag_name, currentVersion) > 0;
const asset = pickAsset(latest, installKind); 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 { return {
available, available,
currentVersion, currentVersion,
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''), latestVersion,
installKind, installKind,
unsigniert,
signaturHinweis,
notes: latest.body || '', notes: latest.body || '',
published: latest.published_at || latest.created_at || null, published: latest.published_at || latest.created_at || null,
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : 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, url: latest.html_url,
}; };
} }
@@ -222,6 +330,15 @@ async function downloadAndInstall(asset, onProgress) {
// sonst kontrolliert sich die Angabe nur selbst. // sonst kontrolliert sich die Angabe nur selbst.
assertMatchingKind(asset.name, installKind); 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 // Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
const target = installKind === 'portable' ? portableTarget() : ''; const target = installKind === 'portable' ? portableTarget() : '';
if (installKind === 'portable' && !target) { if (installKind === 'portable' && !target) {
@@ -286,8 +403,16 @@ async function downloadAndInstall(asset, onProgress) {
throw new Error('Download unvollständig.'); 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 }); if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target }; return { path: dest, bytes: loaded, sha256, signatur, name: asset.name, version: asset.version, kind: installKind, target };
} }
/* /*
@@ -323,11 +448,13 @@ exit /b 1
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt * portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
* Der Launcher muss sich danach beenden. * Der Launcher muss sich danach beenden.
*/ */
function applyUpdate(file) { async function applyUpdate(file) {
const installKind = detectInstallKind(); const installKind = detectInstallKind();
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte // letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
// wirklich zu dieser Paketart? // 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); assertMatchingKind(path.basename(file.path || ''), installKind);
pruefeSignatur(file.signatur, file.name, file.version, await sha256Datei(file.path));
const kind = installKind; const kind = installKind;
if (kind !== 'portable') { if (kind !== 'portable') {
@@ -351,16 +478,14 @@ function applyUpdate(file) {
return { started: 'swap', target }; return { started: 'swap', target };
} }
// Rückwärtskompatibel: nur für die Setup-Variante
function runInstaller(installerPath) {
return applyUpdate({ path: installerPath, kind: 'setup' });
}
module.exports = { module.exports = {
checkForUpdate, checkForUpdate,
downloadAndInstall, downloadAndInstall,
applyUpdate, applyUpdate,
runInstaller, pruefeSignatur,
signaturText,
holeSignatur,
SIGNATUR_API,
cleanupUpdateCache, cleanupUpdateCache,
compareVersions, compareVersions,
parseVersion, parseVersion,
@@ -2,7 +2,7 @@
/** /**
* Plugin Name: AeroMC Freunde * Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort. * Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.5.1 * Version: 1.5.5
* Author: M_Viper * Author: M_Viper
*/ */
@@ -10,7 +10,7 @@ if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei exit; // kein direkter Aufruf der Datei
} }
define('AEROMC_FRIENDS_VERSION', '1.5.1'); define('AEROMC_FRIENDS_VERSION', '1.5.5');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence'); define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends'); define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites'); define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
@@ -20,13 +20,13 @@ define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports'); define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes'); define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber // Anmeldung ist Pflicht (seit Plugin 1.5.5): Jede Anfrage "als Spieler X" braucht
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben // das Sitzungs-Token aus der Mojang-Anmeldung (Launcher ab 1.1.5). Launcher vor
// (ältere Launcher-Versionen funktionieren weiter). Sobald alle Nutzer // 1.1.5 können das Freundesystem damit nicht mehr nutzen. Nur falls doch noch
// aktualisiert haben, in der wp-config.php auf true setzen: // nötig, lässt sich der alte Übergangsmodus in der wp-config.php zurückholen:
// define('AEROMC_FRIENDS_REQUIRE_AUTH', true); // define('AEROMC_FRIENDS_REQUIRE_AUTH', false);
if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) { if (!defined('AEROMC_FRIENDS_REQUIRE_AUTH')) {
define('AEROMC_FRIENDS_REQUIRE_AUTH', false); define('AEROMC_FRIENDS_REQUIRE_AUTH', true);
} }
/** /**
@@ -175,6 +175,7 @@ add_action('plugins_loaded', function () {
require_once plugin_dir_path(__FILE__) . 'includes/routes.php'; require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
require_once plugin_dir_path(__FILE__) . 'includes/capes.php'; require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
require_once plugin_dir_path(__FILE__) . 'includes/signaturen.php';
if (is_admin()) { if (is_admin()) {
require_once plugin_dir_path(__FILE__) . 'includes/admin.php'; require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
} }
@@ -0,0 +1,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>';
}
BIN
View File
Binary file not shown.