diff --git a/src/assets/spieldesign/panorama_3.png b/src/assets/spieldesign/panorama_3.png
new file mode 100644
index 0000000..6cf3a36
Binary files /dev/null and b/src/assets/spieldesign/panorama_3.png differ
diff --git a/src/assets/spieldesign/panorama_4.png b/src/assets/spieldesign/panorama_4.png
new file mode 100644
index 0000000..4d000d9
Binary files /dev/null and b/src/assets/spieldesign/panorama_4.png differ
diff --git a/src/assets/spieldesign/panorama_5.png b/src/assets/spieldesign/panorama_5.png
new file mode 100644
index 0000000..2d301f9
Binary files /dev/null and b/src/assets/spieldesign/panorama_5.png differ
diff --git a/src/assets/spieldesign/splashes.txt b/src/assets/spieldesign/splashes.txt
new file mode 100644
index 0000000..9714871
--- /dev/null
+++ b/src/assets/spieldesign/splashes.txt
@@ -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!
diff --git a/src/assets/spieldesign/symbole.png b/src/assets/spieldesign/symbole.png
new file mode 100644
index 0000000..258f8aa
Binary files /dev/null and b/src/assets/spieldesign/symbole.png differ
diff --git a/src/assets/spieldesign/titel_alt.png b/src/assets/spieldesign/titel_alt.png
new file mode 100644
index 0000000..4f9f2e6
Binary files /dev/null and b/src/assets/spieldesign/titel_alt.png differ
diff --git a/src/assets/spieldesign/titel_neu.png b/src/assets/spieldesign/titel_neu.png
new file mode 100644
index 0000000..ca2c7aa
Binary files /dev/null and b/src/assets/spieldesign/titel_neu.png differ
diff --git a/src/assets/verbot.svg b/src/assets/verbot.svg
new file mode 100644
index 0000000..2d0776e
--- /dev/null
+++ b/src/assets/verbot.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/designs.css b/src/designs.css
index 8aa58ee..865865e 100644
--- a/src/designs.css
+++ b/src/designs.css
@@ -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"]) .upd-notes{background:var(--input);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
+:root:is([data-design="aero"],[data-design="lila"]) .ms-spalte{background:var(--surface);border-radius:14px}
+:root:is([data-design="aero"],[data-design="lila"]) .ms-kopf input{background:var(--input);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes li::marker{color:rgb(var(--p-akzent2))}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
diff --git a/src/index.html b/src/index.html
index 743b644..b53130d 100644
--- a/src/index.html
+++ b/src/index.html
@@ -54,6 +54,13 @@
🌱
Instanz
Gruppe ändern
+
+
Instanz gesperrt
+
Diese Instanz wurde außerhalb des Launchers verändert und kann nicht mehr gestartet werden.
+
+
+
Die Wiederherstellung wird als neue Instanz angelegt. Die gesperrte bleibt erhalten, bis du sie selbst löschst.
+
@@ -409,12 +416,23 @@
×
+
+
AeroMC-Look
+
+
AeroMC-Look im SpielLadebildschirm 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.
+
+
+
Mods
Mod-Updates vor dem StartSucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.
+
+
Mods im Spiel verwaltenIm Pausemenü wird „Feedback geben“ zu „Mods verwalten“: Mods per Klick zwischen „Aktiv“ und „Deaktiviert“ verschieben. Das Spiel läuft weiter, die Änderung gilt ab dem nächsten Start (oder direkt mit „Übernehmen & neu starten“). Nur Instanzen mit Mod-Loader.
+
+
Konsole
@@ -657,6 +675,37 @@
+
+
+
+
+
Mods verwalten
+
+
+
+
+
+
+
+
+
+
Aktive Mods
+
+
+
+
Deaktivierte Mods
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/integritaet.js b/src/integritaet.js
new file mode 100644
index 0000000..44dcd40
--- /dev/null
+++ b/src/integritaet.js
@@ -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 " (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,
+};
diff --git a/src/javadl.js b/src/javadl.js
index dfe5ed6..a54a733 100644
--- a/src/javadl.js
+++ b/src/javadl.js
@@ -13,6 +13,14 @@
const fs = require('fs');
const path = require('path');
const unzipper = require('unzipper');
+const crypto = require('crypto');
+
+function sha256Datei(datei) {
+ return new Promise((resolve, reject) => {
+ const h = crypto.createHash('sha256');
+ fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
+ });
+}
const API = 'https://api.adoptium.net/v3';
const UA = 'AeroMC-Launcher';
@@ -193,6 +201,11 @@ async function ensureJava(major, onProgress) {
try {
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
await ladeDatei(release.url, tmpZip, onProgress);
+ // Adoptium nennt zu jedem Paket die SHA-256 – ohne passende Prüfsumme wird
+ // keine Java-Laufzeit entpackt (sie würde später jedes Spiel ausführen)
+ const soll = String(release.sha256 || '').toLowerCase();
+ if (!/^[0-9a-f]{64}$/.test(soll)) throw new Error('Adoptium hat keine Prüfsumme für dieses Java-Paket geliefert – Download verworfen.');
+ if (await sha256Datei(tmpZip) !== soll) throw new Error('Prüfsumme des Java-Downloads stimmt nicht – Datei verworfen (beschädigt oder manipuliert).');
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
// halbfertiges Java, falls unterwegs etwas schiefgeht
const tmpDir = ziel + '.teil';
diff --git a/src/launcher.js b/src/launcher.js
index 0c96bb5..ae49a8f 100644
--- a/src/launcher.js
+++ b/src/launcher.js
@@ -52,11 +52,20 @@ async function downloadIfNeeded(url, dest, sha1) {
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
}
ensureDir(path.dirname(dest));
- const res = await fetch(url, { headers: { 'User-Agent': UA } });
- if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
- const buf = Buffer.from(await res.arrayBuffer());
- fs.writeFileSync(dest, buf);
- return { skipped: false, bytes: buf.length };
+ // Mojang (und die Loader) nennen zu jeder Datei die SHA-1 – geschrieben wird
+ // nur, was dazu passt. Ein Fehlversuch wird einmal wiederholt (abgebrochene
+ // Übertragung), dann gilt die Datei als beschädigt oder manipuliert.
+ for (let versuch = 1; ; versuch++) {
+ const res = await fetch(url, { headers: { 'User-Agent': UA } });
+ if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
+ const buf = Buffer.from(await res.arrayBuffer());
+ if (sha1 && crypto.createHash('sha1').update(buf).digest('hex') !== String(sha1).toLowerCase()) {
+ if (versuch < 2) continue;
+ throw new Error('Prüfsumme stimmt nicht – Datei verworfen (beschädigt oder manipuliert): ' + path.basename(dest));
+ }
+ fs.writeFileSync(dest, buf);
+ return { skipped: false, bytes: buf.length };
+ }
}
// begrenzte Parallelität für viele kleine Downloads (Assets)
@@ -254,8 +263,7 @@ async function prepareAssets(vj, sharedDir, onProgress) {
// ---------------------------------------------------------------------------
function pickJava(vj, preferredPath) {
- const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
- let preferred = null;
+ const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; let preferred = null;
if (preferredPath && preferredPath.trim()) {
const info = services.probeJava(preferredPath);
if (info) {
@@ -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));
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
+ // Direkt in eine Einzelspieler-Welt (ab 1.20, z. B. nach einem Neustart über den Mod-Schalter)
+ if (!opts.quickPlayServer && opts.quickPlayWelt && !vorQuickPlay(opts.versionId)) {
+ args.push('--quickPlaySingleplayer', String(opts.quickPlayWelt));
+ }
if (opts.quickPlayServer) {
const adresse = String(opts.quickPlayServer).trim();
if (vorQuickPlay(opts.versionId)) {
@@ -559,6 +571,14 @@ async function prepareAndLaunch(opts, onProgress) {
ensureDir(opts.gameDir);
+ // AeroMC-Look im Spiel (Ressourcenpaket, siehe spieldesign.js) – braucht das
+ // fertig geladene Client-JAR, aus dem die Original-Knöpfe umgefärbt werden
+ if (opts.vorStart) {
+ try {
+ await opts.vorStart({ gameDir: opts.gameDir, version: opts.versionId, clientJar }, (line) => emit('log', { line: line + '\n' }));
+ } catch { /* darf den Start nie verhindern */ }
+ }
+
// Pre-Launch-Befehl (bricht bei Fehler ab)
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
diff --git a/src/main.js b/src/main.js
index 4e15563..f741790 100644
--- a/src/main.js
+++ b/src/main.js
@@ -7,6 +7,29 @@
*/
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 fs = require('fs');
const os = require('os');
@@ -26,6 +49,63 @@ const loaders = require('./loaders');
const forge = require('./forge');
const auth = require('./auth');
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 bedrock = require('./bedrock');
const backup = require('./backup');
@@ -33,6 +113,8 @@ const mmcimport = require('./mmcimport');
const kopieren = require('./kopieren');
const moderkennung = require('./moderkennung');
const garderobe = require('./garderobe');
+const spieldesign = require('./spieldesign');
+const modschalter = require('./modschalter');
const erfolge = require('./erfolge');
const cfimport = require('./cfimport');
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.
// Gilt für Haupt- und alle Popup-Fenster.
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) => {
const auswahl = String(params.selectionText || '').trim();
if (!params.isEditable && !auswahl) return;
@@ -956,11 +1067,16 @@ app.whenReady().then(() => {
moderkennung.init(app.getPath('userData'));
garderobe.init(app.getPath('userData'));
erfolge.init(app.getPath('userData'));
- auth.init(app.getPath('userData'), {
+ const tresor = {
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
encrypt: (s) => safeStorage.encryptString(s).toString('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());
discordpresence.configure(store.getSettings());
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
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
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)); }
catch { return liste; }
}
@@ -1806,6 +1926,12 @@ ipcMain.handle('crash:openFolder', (_e, id) => {
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
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');
if (res.ok) return res;
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.
function umhangAgent() {
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 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 ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
- if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
+ const ziel = path.join(ordner, `aeromc-capes-${kennung}.jar`);
+ let vorhanden = null;
+ try { vorhanden = fs.readFileSync(ziel); } catch { /* noch nicht da */ }
+ if (!vorhanden || !vorhanden.equals(inhalt)) {
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)) {
- 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}`;
@@ -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
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).' };
@@ -2376,7 +2792,9 @@ async function findeMinecraftFenster(pid) {
for (let versuch = 1; versuch <= 4; versuch++) {
let 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 (versuch < 4) await new Promise((r) => setTimeout(r, 300));
}
@@ -2695,6 +3113,7 @@ function teilBeschreibung(id) {
}
ipcMain.handle('share:create', async (_e, id) => {
+ if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
const beschreibung = teilBeschreibung(id);
if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' };
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
// ---------------------------------------------------------------------------
-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) => {
const res = store.createInstance(data);
@@ -3050,9 +3469,26 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
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) => {
+ if (integritaet.istGesperrt(id)) throw new Error(GESPERRT_TEXT);
const kopie = store.duplicateInstance(id, true);
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
catch { /* Quelle evtl. leer */ }
@@ -3145,6 +3581,7 @@ function runPwsh(command, env) {
}
ipcMain.handle('instances:export', async (_e, id) => {
+ if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
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));
if (!inside(dest)) continue;
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 {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
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.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
+ fs.writeFileSync(dest, inhalt);
dateien.push(rel);
} catch { /* einzelne Datei überspringen */ }
}
@@ -3541,7 +3991,7 @@ async function importCurseForge(buf, zip, zugang) {
try {
// Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf
// 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++;
} catch {
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') + ' …' });
- const buf = await services.downloadBuffer(file.url);
+ const buf = await services.downloadBuffer(file.url, null, file.hashes);
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) {
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 + ' …' });
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);
if (!zip['manifest.json']) {
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 || '',
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');
if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
const modsSrc = path.join(base, '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))) {
- fs.copyFileSync(path.join(base, imported.customImage), path.join(store.instancePath(created.id), imported.customImage));
- store.updateInstance(created.id, { customImage: imported.customImage });
+ // nur ein schlichter Bild-Dateiname – kein "../" o. Ä., das aus dem Ordner hinausführt
+ const bildName = typeof imported.customImage === 'string' ? 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) };
} catch (err) {
@@ -4079,6 +4539,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (!inst.minecraft.version) {
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();
// 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)
let modUpdates = 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 = {
- 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,
loader: inst.minecraft.loader || 'vanilla',
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)
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
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
@@ -4153,9 +4631,11 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
try {
let launchStart = 0;
const res = await launcher.prepareAndLaunch(opts, (evt) => {
+ if (neustartBild.has(id)) neustartFortschritt(id, evt);
if (evt.phase === 'launch') {
launchStart = Date.now();
laufendeInstanzen.add(id);
+ sitzungsInfo.set(id, { start: Date.now(), kontoId });
meldeLaufende();
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
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
laufendeInstanzen.delete(id);
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
// Freunde wieder gemeldet werden
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
@@ -4218,6 +4726,9 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (m) {
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
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
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
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 && 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(); }
return res;
} catch (err) {
@@ -4611,12 +5124,17 @@ ipcMain.handle('update:check', () => pruefeAufUpdates());
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
// 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 {
+ 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) => {
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
setTimeout(() => app.quit(), 1200);
return { ok: true, path: file.path, kind: file.kind, mode: res.started };
diff --git a/src/modschalter.js b/src/modschalter.js
new file mode 100644
index 0000000..26b3f0f
--- /dev/null
+++ b/src/modschalter.js
@@ -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 };
diff --git a/src/neustart.html b/src/neustart.html
new file mode 100644
index 0000000..5a0083a
--- /dev/null
+++ b/src/neustart.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ AeroMC
+
+
+
+
+