Upload via GUI (194 Dateien)

This commit is contained in:
2026-10-07 07:32:02 +00:00
parent 7e7a2021ed
commit 3ac5a6c14d
20 changed files with 2196 additions and 489 deletions
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"]) .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))}
+49
View File
@@ -54,6 +54,13 @@
<div id="p-icon" class="side-ico">🌱</div>
<div id="p-name" class="side-title">Instanz</div>
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
<div id="p-gesperrt" class="side-gesperrt hidden">
<div class="side-gesperrt-titel"><img src="assets/verbot.svg" alt="" /> Instanz gesperrt</div>
<div class="side-gesperrt-text">Diese Instanz wurde außerhalb des Launchers verändert und kann nicht mehr gestartet werden.</div>
<ul id="p-gesperrt-gruende" class="side-gesperrt-gruende"></ul>
<button id="p-wiederherstellen" class="sbtn play">Aus Sicherung wiederherstellen</button>
<div class="side-gesperrt-text dim">Die Wiederherstellung wird als neue Instanz angelegt. Die gesperrte bleibt erhalten, bis du sie selbst löschst.</div>
</div>
<div class="hr"></div>
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></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>
</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">
<h5>Mods</h5>
<div class="s-zeile">
<div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Mods im Spiel verwalten</b><small>Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“: Mods per Klick zwischen „Aktiv“ und „Deaktiviert“ verschieben. Das Spiel läuft weiter, die Änderung gilt ab dem nächsten Start (oder direkt mit „Übernehmen &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 class="settings-card">
<h5>Konsole</h5>
@@ -657,6 +675,37 @@
</div>
</div>
<!-- ===================== Mod-Schalter (über dem laufenden Spiel) ===================== -->
<div id="modal-modschalter" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3>Mods verwalten <span id="ms-instanz" class="ms-instanz"></span></h3>
<button class="modal-close" id="ms-x" title="Zurück ins Spiel">✕</button>
</div>
<div class="modal-body ms-body">
<div class="ms-kopf">
<input id="ms-suche" type="search" placeholder="Mods durchsuchen …" autocomplete="off" />
<span id="ms-hinweis" class="hint"></span>
</div>
<div class="ms-spalten">
<section class="ms-spalte">
<div class="ms-titel">Aktive Mods <span id="ms-zahl-an" class="group-count"></span></div>
<div id="ms-an" class="ms-liste" role="list"></div>
</section>
<section class="ms-spalte">
<div class="ms-titel">Deaktivierte Mods <span id="ms-zahl-aus" class="group-count"></span></div>
<div id="ms-aus" class="ms-liste" role="list"></div>
</section>
</div>
</div>
<div class="modal-foot">
<span id="ms-status" class="hint ms-status"></span>
<button class="btn" id="ms-fertig">Zurück ins Spiel</button>
<button class="btn btn-primary hidden" id="ms-neustart" title="Minecraft speichert, beendet sich und startet mit den neuen Mods direkt wieder – in derselben Welt bzw. auf demselben Server">Übernehmen &amp; neu starten</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Bestätigung ===================== -->
<div id="modal-confirm" class="modal hidden">
<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 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';
+22 -2
View File
@@ -52,11 +52,20 @@ async function downloadIfNeeded(url, dest, sha1) {
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
}
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 } });
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' });
+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');
// 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 };
+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.
*/
const { contextBridge, ipcRenderer } = require('electron');
const { contextBridge, ipcRenderer, webUtils } = require('electron');
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
// damit man sie auch ohne offene DevTools sieht
@@ -119,6 +119,9 @@ contextBridge.exposeInMainWorld('api', {
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
integritaetWiederherstellen: (id) => ipcRenderer.invoke('integritaet:wiederherstellen', id),
// Pfad einer per Drag & Drop abgelegten Datei (File.path gibt es seit Electron 32 nicht mehr)
pfadVonDatei: (datei) => { try { return webUtils.getPathForFile(datei) || ''; } catch { return ''; } },
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
@@ -170,6 +173,14 @@ contextBridge.exposeInMainWorld('api', {
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
modschalterDaten: (id) => ipcRenderer.invoke('modschalter:daten', id),
modschalterNeustart: (id) => ipcRenderer.invoke('modschalter:neustart', id),
modschalterSchliessen: () => ipcRenderer.send('modschalter:schliessen'),
onModschalterNeustarten: (cb) => {
const l = (_e, d) => cb(d || {});
ipcRenderer.on('modschalter:neustarten', l);
return () => ipcRenderer.removeListener('modschalter:neustarten', l);
},
onLaufendeInstanzen: (cb) => {
const l = (_e, d) => cb(d || []);
ipcRenderer.on('game:laufend', l);
@@ -309,7 +320,7 @@ contextBridge.exposeInMainWorld('api', {
// Updates
updateCheck: () => ipcRenderer.invoke('update:check'),
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
updateInstall: () => ipcRenderer.invoke('update:install'),
onUpdateAvailable: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('update:available', l);
+186 -6
View File
@@ -1702,7 +1702,11 @@ function showUpdateDialog(info) {
el('upd-install').textContent = 'Installieren';
el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
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
? `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.');
@@ -1729,7 +1733,7 @@ async function startUpdate() {
el('upd-progress').classList.remove('hidden');
el('upd-status').textContent = 'Wird heruntergeladen …';
const res = await window.api.updateInstall(pendingUpdate.asset);
const res = await window.api.updateInstall();
if (res.ok) {
el('upd-status').textContent = res.kind === 'portable'
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
@@ -2233,6 +2237,7 @@ async function refreshSelectionPanel() {
if (count > 1) {
currentDetailId = null;
zeigeSperre(null);
setSingleSelectionActionsDisabled(true);
el('p-delete').disabled = false;
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-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
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) {
@@ -2313,13 +2361,17 @@ function buildCard(inst) {
const taste = schnellstartTasteVon(inst.id);
if (taste) tip += `\nSchnellstart: Strg+${taste}`;
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.innerHTML = `
${iconHtml}
<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>
${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`;
if (gesperrt) card.classList.add('gesperrt');
if (laufendeIds.has(inst.id)) card.classList.add('laeuft');
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 –
// 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('drop', async (e) => {
if (istDateiZug(e)) return;
e.preventDefault();
e.stopPropagation();
section.classList.remove('drop-hover');
@@ -2860,6 +2916,11 @@ let launching = false;
async function launch(id, accountId, launchOpts) {
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;
status('Vorbereiten …');
el('status-right').textContent = '';
@@ -4987,6 +5048,101 @@ async function installiereTeilCode() {
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 ----------
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
@@ -5704,6 +5860,7 @@ async function refreshInstalled() {
const kennzeichen = [];
if (up) kennzeichen.push('<span class="mod-new">Update</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>');
// 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.
@@ -5733,7 +5890,7 @@ async function refreshInstalled() {
schalter.addEventListener('click', async () => {
schalter.disabled = true;
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);
await refreshInstalled();
});
@@ -5981,7 +6138,9 @@ async function openSettings(pane) {
el('s-friends').checked = s.friendsEnabled !== false;
el('s-friends-server').checked = s.friendsShareServer !== false;
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-design').checked = s.aeromcDesign !== false;
el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
@@ -6117,7 +6276,9 @@ async function saveSettings() {
friendsEnabled: el('s-friends').checked,
friendsShareServer: el('s-friends-server').checked,
autoModUpdates: el('s-auto-mod-updates').checked,
modSchalter: el('s-mod-schalter').checked,
aeromcCapes: el('s-aeromc-capes').checked,
aeromcDesign: el('s-aeromc-design').checked,
autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
@@ -6950,6 +7111,7 @@ function wire() {
// Rechte Aktionsleiste (Instanz-Aktionen)
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-wiederherstellen').addEventListener('click', wiederherstellenAusSicherung);
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', async () => {
if (!currentDetailId) return;
@@ -7457,8 +7619,8 @@ function wireFileDrop() {
e.preventDefault();
dragDepth = 0;
document.body.classList.remove('file-drop-active');
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path);
for (const f of files) await handleDroppedFile(f.path);
const pfade = [...(e.dataTransfer.files || [])].map((f) => window.api.pfadVonDatei(f)).filter(Boolean);
for (const pfad of pfade) await handleDroppedFile(pfad);
});
}
@@ -7523,6 +7685,7 @@ const POPOUT_OPENERS = {
record: () => openRecording(),
friends: () => openFriends(),
stats: (p) => openStats(p.reiter),
modschalter: (p) => openModschalter(p.instanceId),
};
function popoutBoot(params) {
@@ -7612,6 +7775,23 @@ window.addEventListener('DOMContentLoaded', async () => {
await refreshSkinBackground();
window.api.onLaunchProgress(onLaunchEvt);
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);
window.api.onLaufendeInstanzen(setzeLaufende);
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,
filename: file.filename,
url: file.url,
hashes: mrHashes(file),
sha1: file.hashes && file.hashes.sha1,
size: file.size,
dependencies: v.dependencies || [],
@@ -324,6 +325,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
versionNumber: v.version_number,
filename: file.filename,
url: file.url,
hashes: mrHashes(file),
sha1: file.hashes && file.hashes.sha1,
size: file.size,
dependencies: v.dependencies || [],
@@ -350,23 +352,58 @@ async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
name: v.name || v.version_number,
filename: file.filename,
url: file.url,
hashes: mrHashes(file),
size: file.size || 0,
gameVersions: v.game_versions || [],
loaders: v.loaders || [],
};
}
async function downloadBuffer(url, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
return Buffer.from(await res.arrayBuffer());
/*
* Prüfsumme eines Downloads kontrollieren. erwartet: { sha512?, sha1? } – die
* Werte stammen aus der Modrinth- bzw. CurseForge-API. Ohne Angabe wird nichts
* geprüft (z. B. Dateien, zu denen die Quelle keine Prüfsumme nennt).
*/
function pruefeDownload(buf, erwartet, name) {
if (!erwartet) return;
for (const algo of ['sha512', 'sha256', 'sha1']) {
const soll = erwartet[algo] && String(erwartet[algo]).toLowerCase();
if (!soll) continue;
const ist = require('crypto').createHash(algo).update(buf).digest('hex');
if (ist !== soll) {
throw new Error(`Prüfsumme von ${name || 'Download'} stimmt nicht – Datei verworfen (beschädigt oder manipuliert).`);
}
return;
}
}
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
async function downloadFile(url, destPath, extraHeaders) {
// CurseForge liefert Prüfsummen als Liste: algo 1 = SHA-1, 2 = MD5
function cfHashes(datei) {
const sha1 = ((datei && datei.hashes) || []).find((h) => h.algo === 1 && h.value);
return sha1 ? { sha1: sha1.value } : null;
}
// Modrinth: { sha512, sha1 }
function mrHashes(datei) {
const h = (datei && datei.hashes) || {};
return h.sha512 || h.sha1 ? { sha512: h.sha512, sha1: h.sha1 } : null;
}
async function downloadBuffer(url, extraHeaders, erwartet) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
const buf = Buffer.from(await res.arrayBuffer());
pruefeDownload(buf, erwartet, decodeURIComponent(String(url).split('/').pop() || ''));
return buf;
}
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
// erwartet: Prüfsummen (siehe pruefeDownload) – geschrieben wird nur, was passt
async function downloadFile(url, destPath, extraHeaders, erwartet) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
const buf = Buffer.from(await res.arrayBuffer());
pruefeDownload(buf, erwartet, path.basename(destPath));
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.writeFileSync(destPath, buf);
return buf.length;
@@ -383,7 +420,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir,
: `Keine passende Version für ${gameVersion} gefunden.` };
}
const dest = path.join(modsDir, file.filename);
const bytes = await downloadFile(file.url, dest);
const bytes = await downloadFile(file.url, dest, null, file.hashes);
return {
ok: true,
dependencies: file.dependencies || [],
@@ -738,6 +775,7 @@ async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
fileId: match.id,
filename: name,
url: dl,
hashes: cfHashes(match),
versionNumber: match.displayName || match.fileName || String(match.id),
dependencies: match.dependencies || [],
};
@@ -768,7 +806,7 @@ async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader
// Download: direkt mit Key oder über Weiterleitung
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
const bytes = await downloadFile(url, dest, headers);
const bytes = await downloadFile(url, dest, headers, file.hashes);
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
const depIds = (file.dependencies || [])
@@ -826,6 +864,7 @@ async function curseforgeResolveFiles(zugang, files, onProgress) {
map.set(Number(d.id), {
fileName: d.fileName,
url: zugang.downloadAdresse(roh),
hashes: cfHashes(d),
kopfzeilen: zugang.downloadKopfzeilen,
});
}
+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 };