Upload via GUI (194 Dateien)
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 661 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
Mehr als nur Minecraft!
|
||||||
|
Mehr als nur Minecraft!
|
||||||
|
Mehr als nur Minecraft!
|
||||||
|
Mehr als nur Minecraft!
|
||||||
|
Gemeinsam mehr bauen!
|
||||||
|
Dein Minecraft-Erlebnis!
|
||||||
|
Gestartet mit AeroMC!
|
||||||
|
Jetzt mit eigenen Umhängen!
|
||||||
|
Freunde inklusive!
|
||||||
|
Blockig, aber schön!
|
||||||
|
Auch in Nachtlila!
|
||||||
|
Turbo dank Leistungs-Paket!
|
||||||
|
17 Erfolge warten!
|
||||||
|
Mit Garderobe!
|
||||||
|
Ein Klick, und los!
|
||||||
|
Mods? Ein Klick!
|
||||||
|
Diamanten nicht inklusive!
|
||||||
|
Achtung, Creeper!
|
||||||
|
Handgepixelt!
|
||||||
|
Heute schon gebaut?
|
||||||
|
Deine Welt, dein Stil!
|
||||||
|
Spielzeit läuft mit!
|
||||||
|
Läuft wie geschmiert!
|
||||||
|
Kein Lag, nur Spaß!
|
||||||
|
Lade Freunde ein!
|
||||||
|
Schon ein Backup gemacht?
|
||||||
|
Hallo Nachbar!
|
||||||
|
Nur noch ein Block!
|
||||||
|
Bitte nicht die Lava trinken!
|
||||||
|
Jetzt auch auf Deutsch!
|
||||||
|
Mit 100 % mehr Blau!
|
||||||
|
Schlaf gut, Steve!
|
||||||
|
Endlich Wochenende!
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="49.5" fill="#fff" stroke="#222" stroke-width="0.6"/>
|
||||||
|
<circle cx="50" cy="50" r="43.8" fill="#fff" stroke="#c80000" stroke-width="11.2"/>
|
||||||
|
<line x1="23.4" y1="23.4" x2="76.6" y2="76.6" stroke="#c80000" stroke-width="11.2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
@@ -150,6 +150,8 @@
|
|||||||
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
|
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
|
||||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
|
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
|
||||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
|
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
|
||||||
|
:root:is([data-design="aero"],[data-design="lila"]) .ms-spalte{background:var(--surface);border-radius:14px}
|
||||||
|
:root:is([data-design="aero"],[data-design="lila"]) .ms-kopf input{background:var(--input);border-radius:10px}
|
||||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes li::marker{color:rgb(var(--p-akzent2))}
|
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes li::marker{color:rgb(var(--p-akzent2))}
|
||||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
|
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,13 @@
|
|||||||
<div id="p-icon" class="side-ico">🌱</div>
|
<div id="p-icon" class="side-ico">🌱</div>
|
||||||
<div id="p-name" class="side-title">Instanz</div>
|
<div id="p-name" class="side-title">Instanz</div>
|
||||||
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
|
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
|
||||||
|
<div id="p-gesperrt" class="side-gesperrt hidden">
|
||||||
|
<div class="side-gesperrt-titel"><img src="assets/verbot.svg" alt="" /> Instanz gesperrt</div>
|
||||||
|
<div class="side-gesperrt-text">Diese Instanz wurde außerhalb des Launchers verändert und kann nicht mehr gestartet werden.</div>
|
||||||
|
<ul id="p-gesperrt-gruende" class="side-gesperrt-gruende"></ul>
|
||||||
|
<button id="p-wiederherstellen" class="sbtn play">Aus Sicherung wiederherstellen</button>
|
||||||
|
<div class="side-gesperrt-text dim">Die Wiederherstellung wird als neue Instanz angelegt. Die gesperrte bleibt erhalten, bis du sie selbst löschst.</div>
|
||||||
|
</div>
|
||||||
<div class="hr"></div>
|
<div class="hr"></div>
|
||||||
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
|
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
|
||||||
<button id="p-offline" class="sbtn">Offline starten</button>
|
<button id="p-offline" class="sbtn">Offline starten</button>
|
||||||
@@ -409,12 +416,23 @@
|
|||||||
<div class="s-wert s-paar"><input id="s-mc-width" type="number" min="320" step="10" aria-label="Fensterbreite" /><span>×</span><input id="s-mc-height" type="number" min="240" step="10" aria-label="Fensterhöhe" /></div>
|
<div class="s-wert s-paar"><input id="s-mc-width" type="number" min="320" step="10" aria-label="Fensterbreite" /><span>×</span><input id="s-mc-height" type="number" min="240" step="10" aria-label="Fensterhöhe" /></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-card">
|
||||||
|
<h5>AeroMC-Look</h5>
|
||||||
|
<div class="s-zeile">
|
||||||
|
<div class="s-text"><b>AeroMC-Look im Spiel</b><small>Ladebildschirm und Hauptmenü im AeroMC-Look: Landschaftsbild, großes AeroMC-Logo, Knöpfe mit Symbolen und eigene Sprüche, dazu Fenstertitel und -symbol. Ab Minecraft 1.14, gilt ab dem nächsten Start – eigene Ressourcenpakete haben Vorrang.</small></div>
|
||||||
|
<label class="schalter"><input id="s-aeromc-design" type="checkbox" /><i></i></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Mods</h5>
|
<h5>Mods</h5>
|
||||||
<div class="s-zeile">
|
<div class="s-zeile">
|
||||||
<div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
|
<div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
|
||||||
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
|
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="s-zeile">
|
||||||
|
<div class="s-text"><b>Mods im Spiel verwalten</b><small>Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“: Mods per Klick zwischen „Aktiv“ und „Deaktiviert“ verschieben. Das Spiel läuft weiter, die Änderung gilt ab dem nächsten Start (oder direkt mit „Übernehmen & neu starten“). Nur Instanzen mit Mod-Loader.</small></div>
|
||||||
|
<label class="schalter"><input id="s-mod-schalter" type="checkbox" /><i></i></label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Konsole</h5>
|
<h5>Konsole</h5>
|
||||||
@@ -657,6 +675,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Mod-Schalter (über dem laufenden Spiel) ===================== -->
|
||||||
|
<div id="modal-modschalter" class="modal hidden">
|
||||||
|
<div class="modal-card modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Mods verwalten <span id="ms-instanz" class="ms-instanz"></span></h3>
|
||||||
|
<button class="modal-close" id="ms-x" title="Zurück ins Spiel">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body ms-body">
|
||||||
|
<div class="ms-kopf">
|
||||||
|
<input id="ms-suche" type="search" placeholder="Mods durchsuchen …" autocomplete="off" />
|
||||||
|
<span id="ms-hinweis" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ms-spalten">
|
||||||
|
<section class="ms-spalte">
|
||||||
|
<div class="ms-titel">Aktive Mods <span id="ms-zahl-an" class="group-count"></span></div>
|
||||||
|
<div id="ms-an" class="ms-liste" role="list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="ms-spalte">
|
||||||
|
<div class="ms-titel">Deaktivierte Mods <span id="ms-zahl-aus" class="group-count"></span></div>
|
||||||
|
<div id="ms-aus" class="ms-liste" role="list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span id="ms-status" class="hint ms-status"></span>
|
||||||
|
<button class="btn" id="ms-fertig">Zurück ins Spiel</button>
|
||||||
|
<button class="btn btn-primary hidden" id="ms-neustart" title="Minecraft speichert, beendet sich und startet mit den neuen Mods direkt wieder – in derselben Welt bzw. auf demselben Server">Übernehmen & neu starten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Dialog: Bestätigung ===================== -->
|
<!-- ===================== Dialog: Bestätigung ===================== -->
|
||||||
<div id="modal-confirm" class="modal hidden">
|
<div id="modal-confirm" class="modal hidden">
|
||||||
<div class="modal-card modal-sm">
|
<div class="modal-card modal-sm">
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* integritaet.js – Manipulationsschutz für Instanzen
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Für jede Instanz hält der Launcher ein "Siegel" fest:
|
||||||
|
* - die Startdaten aus instance.json, mit denen sich Code ausführen ließe
|
||||||
|
* (Java-Pfad, JVM-Argumente, Befehle vor dem Start / nach dem Beenden)
|
||||||
|
* - den SHA-256 jeder Mod-Datei im mods-Ordner
|
||||||
|
* Das Siegel ist mit einem geheimen Schlüssel unterschrieben (HMAC), der per
|
||||||
|
* Windows-DPAPI verschlüsselt im Datenordner liegt.
|
||||||
|
*
|
||||||
|
* Als Manipulation gilt:
|
||||||
|
* - eine bekannte Mod-Datei hat bei gleichem Namen einen anderen Inhalt
|
||||||
|
* (so arbeitet Jar-infizierende Schadsoftware wie "Fractureiser")
|
||||||
|
* - die Startdaten wurden außerhalb des Launchers geändert
|
||||||
|
* - das Siegel selbst wurde verändert
|
||||||
|
* Keine Manipulation: neue, gelöschte, aktivierte oder deaktivierte Mods – das
|
||||||
|
* ist der normale Weg, Mods per Explorer hinzuzufügen oder zu entfernen.
|
||||||
|
*
|
||||||
|
* Eine manipulierte Instanz wird gesperrt: Sie bleibt erhalten, lässt sich aber
|
||||||
|
* nicht mehr starten, kopieren, exportieren oder teilen. Der Spieler kann sie
|
||||||
|
* aus einer sauberen Sicherung als neue Instanz wiederherstellen und muss die
|
||||||
|
* gesperrte selbst löschen.
|
||||||
|
*
|
||||||
|
* Ehrlich gesagt: Ein Schadprogramm, das gezielt AeroMC angreift und unter
|
||||||
|
* demselben Windows-Konto läuft, kann auch den DPAPI-Schlüssel benutzen. Gegen
|
||||||
|
* allgemeine Jar-Infektionen, fremde Tools und Hände am Dateisystem hilft es.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const ENC = 'enc:v1:';
|
||||||
|
const MOD_ENDUNGEN = /\.(jar|zip|litemod)(\.disabled)?$/i;
|
||||||
|
|
||||||
|
let datenOrdner = null;
|
||||||
|
let store = null;
|
||||||
|
let schluessel = null;
|
||||||
|
let zustand = { version: 1, instanzen: {} };
|
||||||
|
// Pfad -> { groesse, zeit, hash }: nur für die schnelle Prüfung beim Launcher-Start
|
||||||
|
let hashCache = new Map();
|
||||||
|
|
||||||
|
function zustandsDatei() { return path.join(datenOrdner, 'integritaet.json'); }
|
||||||
|
function schluesselDatei() { return path.join(datenOrdner, 'integritaet.key'); }
|
||||||
|
|
||||||
|
function init(userData, tresor, storeModul) {
|
||||||
|
datenOrdner = userData;
|
||||||
|
store = storeModul;
|
||||||
|
schluessel = ladeSchluessel(tresor);
|
||||||
|
if (!schluessel) return;
|
||||||
|
let roh = null;
|
||||||
|
try { roh = JSON.parse(fs.readFileSync(zustandsDatei(), 'utf8')); } catch { /* erster Start oder Datei fehlt */ }
|
||||||
|
zustand = { version: 1, instanzen: (roh && roh.instanzen && typeof roh.instanzen === 'object') ? roh.instanzen : {} };
|
||||||
|
// Siegel und Sperre sind getrennt unterschrieben: Wer nur die Sperre aus der
|
||||||
|
// Datei löscht, bekommt sie zurück – der gute Stand (für die Prüfung der
|
||||||
|
// Sicherungen) bleibt dabei gültig. Wer am Siegel selbst dreht, verliert ihn.
|
||||||
|
const jetzt = () => new Date().toISOString();
|
||||||
|
for (const [id, e] of Object.entries(zustand.instanzen)) {
|
||||||
|
if (!e || e.mac !== siegelMac(id, e)) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: {}, dateien: {},
|
||||||
|
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde verändert.'] },
|
||||||
|
});
|
||||||
|
} else if (e.sperrMac !== sperrMac(id, e.gesperrt)) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: e.felder, dateien: e.dateien,
|
||||||
|
gesperrt: (e.gesperrt && e.gesperrt.seit) ? e.gesperrt
|
||||||
|
: { seit: jetzt(), gruende: ['Die Sperre dieser Instanz wurde von Hand aufgehoben.'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Liste aller versiegelten Instanzen: ein gelöschter Eintrag fällt auf
|
||||||
|
if (roh && Array.isArray(roh.ids) && roh.idsMac === listenMac(roh.ids)) {
|
||||||
|
for (const id of roh.ids) {
|
||||||
|
if (!zustand.instanzen[id]) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: {}, dateien: {},
|
||||||
|
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde entfernt.'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ladeSchluessel(tresor) {
|
||||||
|
const verfuegbar = (() => { try { return !!(tresor && tresor.available()); } catch { return false; } })();
|
||||||
|
if (fs.existsSync(schluesselDatei())) {
|
||||||
|
try {
|
||||||
|
const inhalt = fs.readFileSync(schluesselDatei(), 'utf8').trim();
|
||||||
|
const roh = inhalt.startsWith(ENC) ? tresor.decrypt(inhalt.slice(ENC.length)) : inhalt;
|
||||||
|
const s = Buffer.from(roh, 'hex');
|
||||||
|
if (s.length === 32) return s;
|
||||||
|
throw new Error('falsche Länge');
|
||||||
|
} catch (err) {
|
||||||
|
// Vorhanden, aber nicht lesbar (z. B. Datenordner von einem anderen PC
|
||||||
|
// kopiert). Nicht einfach neu anlegen – dann sähen ALLE Siegel verändert
|
||||||
|
// aus und jede Instanz wäre gesperrt. Schutz bleibt dann aus.
|
||||||
|
console.warn('Integritäts-Schlüssel nicht lesbar – Manipulationsschutz inaktiv:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const neu = crypto.randomBytes(32);
|
||||||
|
const text = verfuegbar ? ENC + tresor.encrypt(neu.toString('hex')) : neu.toString('hex');
|
||||||
|
fs.mkdirSync(datenOrdner, { recursive: true });
|
||||||
|
fs.writeFileSync(schluesselDatei(), text);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function speichere() {
|
||||||
|
const tmp = zustandsDatei() + '.tmp';
|
||||||
|
const ids = Object.keys(zustand.instanzen).sort();
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(Object.assign({}, zustand, { ids, idsMac: listenMac(ids) }), null, 1));
|
||||||
|
fs.renameSync(tmp, zustandsDatei());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortiert(obj) {
|
||||||
|
return Object.keys(obj || {}).sort().map((k) => [k, obj[k]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(daten) {
|
||||||
|
return crypto.createHmac('sha256', schluessel).update(JSON.stringify(daten)).digest('hex');
|
||||||
|
}
|
||||||
|
function siegelMac(id, e) { return hmac(['siegel', id, sortiert(e.felder), sortiert(e.dateien)]); }
|
||||||
|
function sperrMac(id, gesperrt) { return hmac(['sperre', id, gesperrt || null]); }
|
||||||
|
function listenMac(ids) { return hmac(['liste', ids]); }
|
||||||
|
|
||||||
|
function versiegeltEintrag(id, e) {
|
||||||
|
const eintrag = { felder: e.felder, dateien: e.dateien, gesperrt: e.gesperrt || null, stand: new Date().toISOString() };
|
||||||
|
eintrag.mac = siegelMac(id, eintrag);
|
||||||
|
eintrag.sperrMac = sperrMac(id, eintrag.gesperrt);
|
||||||
|
return eintrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die sicherheitsrelevanten Startdaten einer Instanz
|
||||||
|
function startDaten(inst) {
|
||||||
|
const j = (inst && inst.java) || {};
|
||||||
|
return {
|
||||||
|
javaPfad: String(j.path || ''),
|
||||||
|
jvmArgumente: String(j.extraArgs || ''),
|
||||||
|
vorStart: String((inst && inst.preLaunchCommand) || ''),
|
||||||
|
nachEnde: String((inst && inst.postExitCommand) || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FELD_NAMEN = {
|
||||||
|
javaPfad: 'Java-Pfad', jvmArgumente: 'JVM-Argumente', vorStart: 'Befehl vor dem Start', nachEnde: 'Befehl nach dem Beenden',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alle Mod-Dateien (auch Unterordner wie mods/1.12.2/). Schlüssel ohne
|
||||||
|
// ".disabled" und in Kleinbuchstaben – Umschalten ist keine Veränderung.
|
||||||
|
function modDateien(gameDir) {
|
||||||
|
const basis = path.join(gameDir, 'mods');
|
||||||
|
const ergebnis = {};
|
||||||
|
const lauf = (ordner, tiefe) => {
|
||||||
|
let eintraege;
|
||||||
|
try { eintraege = fs.readdirSync(ordner, { withFileTypes: true }); } catch { return; }
|
||||||
|
for (const e of eintraege) {
|
||||||
|
const voll = path.join(ordner, e.name);
|
||||||
|
if (e.isDirectory()) { if (tiefe < 2) lauf(voll, tiefe + 1); continue; }
|
||||||
|
if (!e.isFile() || !MOD_ENDUNGEN.test(e.name)) continue;
|
||||||
|
const rel = path.relative(basis, voll).replace(/\\/g, '/');
|
||||||
|
ergebnis[rel.replace(/\.disabled$/i, '').toLowerCase()] = voll;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lauf(basis, 0);
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashDatei(datei) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash('sha256');
|
||||||
|
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// schnell=true: unveränderte Größe+Zeit -> Hash aus dem Zwischenspeicher
|
||||||
|
async function hashe(datei, schnell) {
|
||||||
|
const st = fs.statSync(datei);
|
||||||
|
const c = hashCache.get(datei);
|
||||||
|
if (schnell && c && c.groesse === st.size && c.zeit === st.mtimeMs) return c.hash;
|
||||||
|
const hash = await hashDatei(datei);
|
||||||
|
hashCache.set(datei, { groesse: st.size, zeit: st.mtimeMs, hash });
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aktuellerStand(id, schnell) {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
if (!inst) return null;
|
||||||
|
const dateien = {};
|
||||||
|
for (const [schl, voll] of Object.entries(modDateien(store.gameDir(id)))) {
|
||||||
|
try { dateien[schl] = await hashe(voll, schnell); } catch { /* gesperrt/weg -> beim nächsten Mal */ }
|
||||||
|
}
|
||||||
|
return { felder: startDaten(inst), dateien };
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(id) {
|
||||||
|
const e = zustand.instanzen[id];
|
||||||
|
return e && e.gesperrt ? { gesperrt: true, seit: e.gesperrt.seit, gruende: e.gesperrt.gruende } : { gesperrt: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function istGesperrt(id) { return status(id).gesperrt; }
|
||||||
|
|
||||||
|
function sperre(id, gruende) {
|
||||||
|
if (!schluessel) return;
|
||||||
|
const e = zustand.instanzen[id] || { felder: {}, dateien: {} };
|
||||||
|
// das letzte gute Siegel bleibt stehen – daran wird eine Sicherung gemessen
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: e.felder, dateien: e.dateien,
|
||||||
|
gesperrt: { seit: new Date().toISOString(), gruende },
|
||||||
|
});
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prüft eine Instanz gegen ihr Siegel.
|
||||||
|
* -> { status: 'ok' | 'neu' | 'gesperrt', gruende?, neuGesperrt? }
|
||||||
|
* Ohne Befund werden neue und entfernte Mods ins Siegel übernommen.
|
||||||
|
*/
|
||||||
|
async function pruefe(id, { schnell = false } = {}) {
|
||||||
|
if (!schluessel) return { status: 'ok' };
|
||||||
|
const vorhanden = zustand.instanzen[id];
|
||||||
|
if (vorhanden && vorhanden.gesperrt) return { status: 'gesperrt', gruende: vorhanden.gesperrt.gruende };
|
||||||
|
const jetzt = await aktuellerStand(id, schnell);
|
||||||
|
if (!jetzt) return { status: 'ok' };
|
||||||
|
if (!vorhanden) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||||
|
speichere();
|
||||||
|
return { status: 'neu' };
|
||||||
|
}
|
||||||
|
const gruende = [];
|
||||||
|
for (const [k, name] of Object.entries(FELD_NAMEN)) {
|
||||||
|
if ((vorhanden.felder[k] || '') !== jetzt.felder[k]) gruende.push(`${name} wurde außerhalb des Launchers geändert.`);
|
||||||
|
}
|
||||||
|
for (const [datei, hash] of Object.entries(jetzt.dateien)) {
|
||||||
|
if (vorhanden.dateien[datei] && vorhanden.dateien[datei] !== hash) gruende.push(`Mod-Datei verändert: ${datei}`);
|
||||||
|
}
|
||||||
|
if (gruende.length) {
|
||||||
|
sperre(id, gruende);
|
||||||
|
return { status: 'gesperrt', gruende, neuGesperrt: true };
|
||||||
|
}
|
||||||
|
const geaendert = JSON.stringify(sortiert(vorhanden.dateien)) !== JSON.stringify(sortiert(jetzt.dateien));
|
||||||
|
if (geaendert) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach einer Änderung durch den Launcher selbst (Mod-Update, Bearbeiten …)
|
||||||
|
async function versiegele(id) {
|
||||||
|
if (!schluessel) return;
|
||||||
|
if (istGesperrt(id)) return;
|
||||||
|
const jetzt = await aktuellerStand(id, false);
|
||||||
|
if (!jetzt) return;
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
function entferne(id) {
|
||||||
|
if (!schluessel) return;
|
||||||
|
if (!zustand.instanzen[id]) return;
|
||||||
|
delete zustand.instanzen[id];
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
function alleGesperrten() {
|
||||||
|
const r = {};
|
||||||
|
for (const [id, e] of Object.entries(zustand.instanzen)) if (e.gesperrt) r[id] = { seit: e.gesperrt.seit, gruende: e.gesperrt.gruende };
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das letzte gute Siegel (für die Prüfung einer Sicherung)
|
||||||
|
function gutesSiegel(id) {
|
||||||
|
const e = zustand.instanzen[id];
|
||||||
|
return e ? { felder: e.felder, dateien: e.dateien } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wiederherstellen aus einer Sicherung (AeroMC-Backup-*.zip, siehe backup.js)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const unterPfad = (p) => String(p || '').replace(/\\/g, '/');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Neueste Sicherung, in der die Instanz steckt UND deren Mods zum letzten
|
||||||
|
* guten Siegel passen. Eine Sicherung, die erst nach der Manipulation entstand,
|
||||||
|
* enthält die veränderte Datei und wird übersprungen.
|
||||||
|
* backupPfade: neueste zuerst
|
||||||
|
*/
|
||||||
|
async function sucheSaubereSicherung(id, backupPfade) {
|
||||||
|
const unzipper = require('unzipper');
|
||||||
|
const gut = gutesSiegel(id);
|
||||||
|
const praefix = `instances/${id}/`;
|
||||||
|
const uebersprungen = [];
|
||||||
|
for (const zip of backupPfade) {
|
||||||
|
let verzeichnis;
|
||||||
|
try { verzeichnis = await unzipper.Open.file(zip); } catch { continue; }
|
||||||
|
const eintraege = verzeichnis.files.filter((f) => f.type === 'File' && unterPfad(f.path).startsWith(praefix));
|
||||||
|
if (!eintraege.some((f) => unterPfad(f.path) === praefix + 'instance.json')) continue;
|
||||||
|
let sauber = true;
|
||||||
|
for (const f of eintraege) {
|
||||||
|
const m = unterPfad(f.path).slice(praefix.length).match(/^minecraft\/mods\/(.+)$/i);
|
||||||
|
if (!m || !MOD_ENDUNGEN.test(m[1])) continue;
|
||||||
|
const schl = m[1].replace(/\.disabled$/i, '').toLowerCase();
|
||||||
|
if (!gut || !gut.dateien[schl]) continue;
|
||||||
|
const hash = crypto.createHash('sha256').update(await f.buffer()).digest('hex');
|
||||||
|
if (hash !== gut.dateien[schl]) { sauber = false; break; }
|
||||||
|
}
|
||||||
|
if (sauber) return { zip, eintraege, praefix, uebersprungen };
|
||||||
|
uebersprungen.push(path.basename(zip));
|
||||||
|
}
|
||||||
|
return { zip: null, uebersprungen };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stellt die Instanz als NEUE Instanz "<Name> (wiederhergestellt)" wieder her.
|
||||||
|
* Die gesperrte bleibt unangetastet liegen.
|
||||||
|
*/
|
||||||
|
async function stelleWiederHer(id, backupPfade) {
|
||||||
|
const { pipeline } = require('stream/promises');
|
||||||
|
const fund = await sucheSaubereSicherung(id, backupPfade);
|
||||||
|
if (!fund.zip) {
|
||||||
|
throw new Error(fund.uebersprungen.length
|
||||||
|
? `Keine saubere Sicherung gefunden – ${fund.uebersprungen.length} Sicherung(en) enthalten bereits die veränderten Dateien.`
|
||||||
|
: 'Keine Sicherung mit dieser Instanz gefunden. Sicherungen lassen sich unter Optionen → Sicherung anlegen.');
|
||||||
|
}
|
||||||
|
const alt = store.getInstance(id);
|
||||||
|
const name = `${(alt && alt.name) || id} (wiederhergestellt)`;
|
||||||
|
const neuId = store.uniqueFolderId(name);
|
||||||
|
const ziel = store.instancePath(neuId);
|
||||||
|
fs.mkdirSync(ziel, { recursive: true });
|
||||||
|
for (const f of fund.eintraege) {
|
||||||
|
const rel = unterPfad(f.path).slice(fund.praefix.length);
|
||||||
|
const dest = path.join(ziel, ...rel.split('/'));
|
||||||
|
if (!dest.startsWith(ziel + path.sep)) continue; // nichts außerhalb des Ordners
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
await pipeline(f.stream(), fs.createWriteStream(dest));
|
||||||
|
}
|
||||||
|
const datei = path.join(ziel, 'instance.json');
|
||||||
|
const inst = JSON.parse(fs.readFileSync(datei, 'utf8'));
|
||||||
|
inst.id = neuId;
|
||||||
|
inst.name = name;
|
||||||
|
// Startdaten auf den letzten geprüften Stand setzen
|
||||||
|
const gut = gutesSiegel(id);
|
||||||
|
if (gut && gut.felder && Object.keys(gut.felder).length) {
|
||||||
|
inst.java = Object.assign({}, inst.java, { path: gut.felder.javaPfad || '', extraArgs: gut.felder.jvmArgumente || '' });
|
||||||
|
inst.preLaunchCommand = gut.felder.vorStart || '';
|
||||||
|
inst.postExitCommand = gut.felder.nachEnde || '';
|
||||||
|
}
|
||||||
|
fs.writeFileSync(datei, JSON.stringify(inst, null, 2));
|
||||||
|
await versiegele(neuId);
|
||||||
|
return { id: neuId, name, sicherung: path.basename(fund.zip), uebersprungen: fund.uebersprungen };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init, pruefe, versiegele, sperre, status, istGesperrt, entferne, alleGesperrten, gutesSiegel,
|
||||||
|
sucheSaubereSicherung, stelleWiederHer,
|
||||||
|
startDaten, modDateien, hashDatei, FELD_NAMEN,
|
||||||
|
};
|
||||||
@@ -13,6 +13,14 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const unzipper = require('unzipper');
|
const unzipper = require('unzipper');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function sha256Datei(datei) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash('sha256');
|
||||||
|
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const API = 'https://api.adoptium.net/v3';
|
const API = 'https://api.adoptium.net/v3';
|
||||||
const UA = 'AeroMC-Launcher';
|
const UA = 'AeroMC-Launcher';
|
||||||
@@ -193,6 +201,11 @@ async function ensureJava(major, onProgress) {
|
|||||||
try {
|
try {
|
||||||
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
|
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
|
||||||
await ladeDatei(release.url, tmpZip, onProgress);
|
await ladeDatei(release.url, tmpZip, onProgress);
|
||||||
|
// Adoptium nennt zu jedem Paket die SHA-256 – ohne passende Prüfsumme wird
|
||||||
|
// keine Java-Laufzeit entpackt (sie würde später jedes Spiel ausführen)
|
||||||
|
const soll = String(release.sha256 || '').toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(soll)) throw new Error('Adoptium hat keine Prüfsumme für dieses Java-Paket geliefert – Download verworfen.');
|
||||||
|
if (await sha256Datei(tmpZip) !== soll) throw new Error('Prüfsumme des Java-Downloads stimmt nicht – Datei verworfen (beschädigt oder manipuliert).');
|
||||||
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
|
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
|
||||||
// halbfertiges Java, falls unterwegs etwas schiefgeht
|
// halbfertiges Java, falls unterwegs etwas schiefgeht
|
||||||
const tmpDir = ziel + '.teil';
|
const tmpDir = ziel + '.teil';
|
||||||
|
|||||||
@@ -52,12 +52,21 @@ async function downloadIfNeeded(url, dest, sha1) {
|
|||||||
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
|
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
|
||||||
}
|
}
|
||||||
ensureDir(path.dirname(dest));
|
ensureDir(path.dirname(dest));
|
||||||
|
// Mojang (und die Loader) nennen zu jeder Datei die SHA-1 – geschrieben wird
|
||||||
|
// nur, was dazu passt. Ein Fehlversuch wird einmal wiederholt (abgebrochene
|
||||||
|
// Übertragung), dann gilt die Datei als beschädigt oder manipuliert.
|
||||||
|
for (let versuch = 1; ; versuch++) {
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||||
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (sha1 && crypto.createHash('sha1').update(buf).digest('hex') !== String(sha1).toLowerCase()) {
|
||||||
|
if (versuch < 2) continue;
|
||||||
|
throw new Error('Prüfsumme stimmt nicht – Datei verworfen (beschädigt oder manipuliert): ' + path.basename(dest));
|
||||||
|
}
|
||||||
fs.writeFileSync(dest, buf);
|
fs.writeFileSync(dest, buf);
|
||||||
return { skipped: false, bytes: buf.length };
|
return { skipped: false, bytes: buf.length };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// begrenzte Parallelität für viele kleine Downloads (Assets)
|
// begrenzte Parallelität für viele kleine Downloads (Assets)
|
||||||
async function runPool(items, worker, concurrency, onEach) {
|
async function runPool(items, worker, concurrency, onEach) {
|
||||||
@@ -254,8 +263,7 @@ async function prepareAssets(vj, sharedDir, onProgress) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function pickJava(vj, preferredPath) {
|
function pickJava(vj, preferredPath) {
|
||||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; let preferred = null;
|
||||||
let preferred = null;
|
|
||||||
if (preferredPath && preferredPath.trim()) {
|
if (preferredPath && preferredPath.trim()) {
|
||||||
const info = services.probeJava(preferredPath);
|
const info = services.probeJava(preferredPath);
|
||||||
if (info) {
|
if (info) {
|
||||||
@@ -546,6 +554,10 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
||||||
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
|
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
|
||||||
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
|
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
|
||||||
|
// Direkt in eine Einzelspieler-Welt (ab 1.20, z. B. nach einem Neustart über den Mod-Schalter)
|
||||||
|
if (!opts.quickPlayServer && opts.quickPlayWelt && !vorQuickPlay(opts.versionId)) {
|
||||||
|
args.push('--quickPlaySingleplayer', String(opts.quickPlayWelt));
|
||||||
|
}
|
||||||
if (opts.quickPlayServer) {
|
if (opts.quickPlayServer) {
|
||||||
const adresse = String(opts.quickPlayServer).trim();
|
const adresse = String(opts.quickPlayServer).trim();
|
||||||
if (vorQuickPlay(opts.versionId)) {
|
if (vorQuickPlay(opts.versionId)) {
|
||||||
@@ -559,6 +571,14 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
|
|
||||||
ensureDir(opts.gameDir);
|
ensureDir(opts.gameDir);
|
||||||
|
|
||||||
|
// AeroMC-Look im Spiel (Ressourcenpaket, siehe spieldesign.js) – braucht das
|
||||||
|
// fertig geladene Client-JAR, aus dem die Original-Knöpfe umgefärbt werden
|
||||||
|
if (opts.vorStart) {
|
||||||
|
try {
|
||||||
|
await opts.vorStart({ gameDir: opts.gameDir, version: opts.versionId, clientJar }, (line) => emit('log', { line: line + '\n' }));
|
||||||
|
} catch { /* darf den Start nie verhindern */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-Launch-Befehl (bricht bei Fehler ab)
|
// Pre-Launch-Befehl (bricht bei Fehler ab)
|
||||||
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
|
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
|
||||||
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
|
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
|
||||||
|
|||||||
@@ -7,6 +7,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron');
|
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron');
|
||||||
|
|
||||||
|
// Vom Setup am Ende der Installation einmal aufgerufen: nur starten und sofort
|
||||||
|
// wieder beenden. Windows prüft die neue EXE dabei (dauert beim ersten Start
|
||||||
|
// mehrere Sekunden) – so passiert das während des Fortschrittsbalkens und der
|
||||||
|
// echte Start nach "Fertigstellen" ist gleich da. Siehe installer/Installer.cs.
|
||||||
|
if (process.argv.includes('--aeromc-vorwaermen')) process.exit(0);
|
||||||
|
|
||||||
|
// Sicherheit: Die fertige App nimmt keine Debug-Schalter an. Mit
|
||||||
|
// --remote-debugging-port oder --inspect könnte jedes andere Programm den
|
||||||
|
// Launcher fernsteuern (Konten, Instanzen, Befehle). Beim Entwickeln (npm start)
|
||||||
|
// bleiben sie erlaubt. Die Node-Varianten (--inspect, NODE_OPTIONS,
|
||||||
|
// ELECTRON_RUN_AS_NODE) sperren zusätzlich die Electron-Fuses, siehe
|
||||||
|
// "electronFuses" in package.json (dort auch die Integritätsprüfung von app.asar).
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const verboten = ['remote-debugging-port', 'remote-debugging-pipe', 'remote-debugging-address', 'inspect', 'inspect-brk', 'inspect-port', 'js-flags'];
|
||||||
|
const gesetzt = verboten.find((s) => app.commandLine.hasSwitch(s))
|
||||||
|
|| process.argv.slice(1).find((a) => /^--(remote-debugging|inspect)/i.test(a));
|
||||||
|
if (gesetzt) {
|
||||||
|
console.error('AeroMC: Debug-Schalter sind in der fertigen App nicht erlaubt (' + gesetzt + ').');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
@@ -26,6 +49,63 @@ const loaders = require('./loaders');
|
|||||||
const forge = require('./forge');
|
const forge = require('./forge');
|
||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const updater = require('./updater');
|
const updater = require('./updater');
|
||||||
|
const integritaet = require('./integritaet');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Manipulationsschutz (siehe integritaet.js): Kanäle, über die der Launcher
|
||||||
|
* selbst Mod-Dateien überschreibt oder Startdaten ändert. Vorher wird geprüft
|
||||||
|
* (eine schon manipulierte Instanz darf nicht "mitversiegelt" werden), danach
|
||||||
|
* das Siegel auf den neuen Stand gebracht. Gesperrte Instanzen lassen sich
|
||||||
|
* darüber gar nicht mehr verändern.
|
||||||
|
*/
|
||||||
|
const SIEGEL_KANAELE = new Set([
|
||||||
|
'mods:install', 'mods:addFile', 'mods:applyUpdates', 'mods:leistungsPaket',
|
||||||
|
'instances:installPerformancePack', 'instances:update', 'modpack:update',
|
||||||
|
]);
|
||||||
|
const GESPERRT_TEXT = 'Diese Instanz ist gesperrt, weil sie verändert wurde. Bitte aus einer Sicherung wiederherstellen und die gesperrte Instanz löschen.';
|
||||||
|
{
|
||||||
|
const handleOriginal = ipcMain.handle.bind(ipcMain);
|
||||||
|
ipcMain.handle = (kanal, fn) => handleOriginal(kanal, !SIEGEL_KANAELE.has(kanal) ? fn : async (e, id, ...rest) => {
|
||||||
|
const vorher = await integritaet.pruefe(id);
|
||||||
|
if (vorher.status === 'gesperrt') {
|
||||||
|
if (vorher.neuGesperrt) meldeGesperrt(id);
|
||||||
|
return { ok: false, message: GESPERRT_TEXT };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(e, id, ...rest);
|
||||||
|
} finally {
|
||||||
|
await integritaet.versiegele(id).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// mit Gesperrt-Status für die Anzeige
|
||||||
|
function mitSperre(inst) {
|
||||||
|
return inst ? Object.assign({}, inst, { gesperrt: integritaet.status(inst.id) }) : inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
function meldeGesperrt(id) {
|
||||||
|
broadcast('instances:changed');
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
try {
|
||||||
|
new Notification({
|
||||||
|
title: 'AeroMC – Instanz gesperrt',
|
||||||
|
body: `"${(inst && inst.name) || id}" wurde außerhalb des Launchers verändert und ist gesperrt.`,
|
||||||
|
icon: benachrichtigungsLogo || undefined,
|
||||||
|
}).show();
|
||||||
|
} catch { /* nur Hinweis */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Launcher-Start alle Instanzen im Hintergrund prüfen (schnell: nur
|
||||||
|
// geänderte Dateien werden neu gehasht). Beim Spielstart wird gründlich geprüft.
|
||||||
|
async function pruefeAlleInstanzen() {
|
||||||
|
for (const inst of store.listInstances()) {
|
||||||
|
try {
|
||||||
|
const r = await integritaet.pruefe(inst.id, { schnell: true });
|
||||||
|
if (r.neuGesperrt) meldeGesperrt(inst.id);
|
||||||
|
} catch (err) { console.warn('Integritätsprüfung', inst.id, err.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
const icons = require('./icons');
|
const icons = require('./icons');
|
||||||
const bedrock = require('./bedrock');
|
const bedrock = require('./bedrock');
|
||||||
const backup = require('./backup');
|
const backup = require('./backup');
|
||||||
@@ -33,6 +113,8 @@ const mmcimport = require('./mmcimport');
|
|||||||
const kopieren = require('./kopieren');
|
const kopieren = require('./kopieren');
|
||||||
const moderkennung = require('./moderkennung');
|
const moderkennung = require('./moderkennung');
|
||||||
const garderobe = require('./garderobe');
|
const garderobe = require('./garderobe');
|
||||||
|
const spieldesign = require('./spieldesign');
|
||||||
|
const modschalter = require('./modschalter');
|
||||||
const erfolge = require('./erfolge');
|
const erfolge = require('./erfolge');
|
||||||
const cfimport = require('./cfimport');
|
const cfimport = require('./cfimport');
|
||||||
const mrimport = require('./mrimport');
|
const mrimport = require('./mrimport');
|
||||||
@@ -602,6 +684,35 @@ ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
|
|||||||
// aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V.
|
// aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V.
|
||||||
// Gilt für Haupt- und alle Popup-Fenster.
|
// Gilt für Haupt- und alle Popup-Fenster.
|
||||||
app.on('web-contents-created', (_e, contents) => {
|
app.on('web-contents-created', (_e, contents) => {
|
||||||
|
// Sicherheit: Kein Fenster darf je eine fremde Seite laden. Die Fenster haben
|
||||||
|
// über preload.js Zugriff auf window.api – eine Webseite im selben Fenster
|
||||||
|
// hätte damit die volle Kontrolle über den Launcher. Links nach außen gehen
|
||||||
|
// deshalb immer in den Standardbrowser, alles andere wird verworfen.
|
||||||
|
const eigeneSeite = (adresse) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(adresse);
|
||||||
|
if (u.protocol !== 'file:') return false;
|
||||||
|
const datei = path.normalize(decodeURIComponent(u.pathname.replace(/^\/([a-z]:)/i, '$1')));
|
||||||
|
return !path.relative(__dirname, datei).startsWith('..');
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
const nachAussen = (adresse) => {
|
||||||
|
if (/^https?:\/\//i.test(String(adresse || ''))) shell.openExternal(adresse).catch(() => {});
|
||||||
|
};
|
||||||
|
contents.on('will-navigate', (ev, adresse) => {
|
||||||
|
if (eigeneSeite(adresse)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
nachAussen(adresse);
|
||||||
|
});
|
||||||
|
contents.on('will-redirect', (ev, adresse) => {
|
||||||
|
if (!eigeneSeite(adresse) && !String(adresse).startsWith('data:')) ev.preventDefault();
|
||||||
|
});
|
||||||
|
contents.setWindowOpenHandler(({ url: adresse }) => {
|
||||||
|
nachAussen(adresse);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
contents.on('will-attach-webview', (ev) => ev.preventDefault());
|
||||||
|
|
||||||
contents.on('context-menu', (_ev, params) => {
|
contents.on('context-menu', (_ev, params) => {
|
||||||
const auswahl = String(params.selectionText || '').trim();
|
const auswahl = String(params.selectionText || '').trim();
|
||||||
if (!params.isEditable && !auswahl) return;
|
if (!params.isEditable && !auswahl) return;
|
||||||
@@ -956,11 +1067,16 @@ app.whenReady().then(() => {
|
|||||||
moderkennung.init(app.getPath('userData'));
|
moderkennung.init(app.getPath('userData'));
|
||||||
garderobe.init(app.getPath('userData'));
|
garderobe.init(app.getPath('userData'));
|
||||||
erfolge.init(app.getPath('userData'));
|
erfolge.init(app.getPath('userData'));
|
||||||
auth.init(app.getPath('userData'), {
|
const tresor = {
|
||||||
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
|
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
|
||||||
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
|
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
|
||||||
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
|
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
|
||||||
});
|
};
|
||||||
|
auth.init(app.getPath('userData'), tresor);
|
||||||
|
try { integritaet.init(app.getPath('userData'), tresor, store); } catch (err) { console.warn('Manipulationsschutz:', err.message); }
|
||||||
|
setTimeout(() => { pruefeAlleInstanzen().catch(() => {}); }, 8000);
|
||||||
|
// Proxy-Passwort und API-Schlüssel ebenso (siehe store.setzeTresor)
|
||||||
|
store.setzeTresor(tresor);
|
||||||
applyProxy(store.getSettings());
|
applyProxy(store.getSettings());
|
||||||
discordpresence.configure(store.getSettings());
|
discordpresence.configure(store.getSettings());
|
||||||
const autostartAn = !!store.getSettings().autostart;
|
const autostartAn = !!store.getSettings().autostart;
|
||||||
@@ -1144,7 +1260,11 @@ ipcMain.handle('mods:search', async (_e, opts) => {
|
|||||||
// Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden
|
// Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden
|
||||||
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
|
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
|
||||||
async function modsMitErkennung(id, art) {
|
async function modsMitErkennung(id, art) {
|
||||||
const liste = store.listMods(id, art);
|
let liste = store.listMods(id, art);
|
||||||
|
if ((art || 'mod') === 'mod') {
|
||||||
|
const aus = ausstehendeMods(id);
|
||||||
|
liste = liste.map((m) => (m.filename in aus ? Object.assign({}, m, { enabled: !!aus[m.filename], ausstehend: true }) : m));
|
||||||
|
}
|
||||||
try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); }
|
try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); }
|
||||||
catch { return liste; }
|
catch { return liste; }
|
||||||
}
|
}
|
||||||
@@ -1806,6 +1926,12 @@ ipcMain.handle('crash:openFolder', (_e, id) => {
|
|||||||
|
|
||||||
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
|
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
|
||||||
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
|
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
|
||||||
|
// Spiel läuft: Datei ist gesperrt -> vormerken, beim Beenden umsetzen
|
||||||
|
if ((art || 'mod') === 'mod' && laufendeInstanzen.has(id)) {
|
||||||
|
merkeModAenderung(id, filename, !!enabled);
|
||||||
|
broadcast('mods:changed', { id });
|
||||||
|
return { ok: true, enabled: !!enabled, ausstehend: true };
|
||||||
|
}
|
||||||
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
|
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const ordner = services.inhaltsArt(art || 'mod').ordner;
|
const ordner = services.inhaltsArt(art || 'mod').ordner;
|
||||||
@@ -2227,15 +2353,22 @@ ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
|
|||||||
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
|
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
|
||||||
function umhangAgent() {
|
function umhangAgent() {
|
||||||
try {
|
try {
|
||||||
|
// Die Kopie im Datenordner läuft mit vollen Rechten im Spiel mit. Deshalb vor
|
||||||
|
// jedem Start Byte für Byte mit dem Original im Paket vergleichen – eine
|
||||||
|
// ausgetauschte oder veränderte Datei wird überschrieben, nie geladen.
|
||||||
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
|
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
|
||||||
const groesse = fs.statSync(quelle).size;
|
const inhalt = fs.readFileSync(quelle);
|
||||||
|
const kennung = require('crypto').createHash('sha256').update(inhalt).digest('hex').slice(0, 16);
|
||||||
const ordner = path.join(app.getPath('userData'), 'agent');
|
const ordner = path.join(app.getPath('userData'), 'agent');
|
||||||
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
|
const ziel = path.join(ordner, `aeromc-capes-${kennung}.jar`);
|
||||||
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
|
let vorhanden = null;
|
||||||
|
try { vorhanden = fs.readFileSync(ziel); } catch { /* noch nicht da */ }
|
||||||
|
if (!vorhanden || !vorhanden.equals(inhalt)) {
|
||||||
fs.mkdirSync(ordner, { recursive: true });
|
fs.mkdirSync(ordner, { recursive: true });
|
||||||
fs.writeFileSync(ziel, fs.readFileSync(quelle));
|
fs.writeFileSync(ziel, inhalt);
|
||||||
|
if (!fs.readFileSync(ziel).equals(inhalt)) throw new Error('Agent-Kopie weicht vom Original ab.');
|
||||||
for (const alt of fs.readdirSync(ordner)) {
|
for (const alt of fs.readdirSync(ordner)) {
|
||||||
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
|
if (alt !== path.basename(ziel) && /^aeromc-capes-[0-9a-f]+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${ziel}=${friends.BASIS_URL}`;
|
return `${ziel}=${friends.BASIS_URL}`;
|
||||||
@@ -2245,6 +2378,289 @@ function umhangAgent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -javaagent-Argument: Umhänge und/oder AeroMC-Look im Spiel (siehe agent/Agent.java)
|
||||||
|
function agentArgument(settings, version, modsAdresse) {
|
||||||
|
const umhaenge = settings.aeromcCapes !== false;
|
||||||
|
const design = settings.aeromcDesign !== false && spieldesign.unterstuetzt(version)
|
||||||
|
? spieldesign.agentOrdner(app.getPath('userData')) : '';
|
||||||
|
if (!umhaenge && !design && !modsAdresse) return '';
|
||||||
|
const agent = umhangAgent();
|
||||||
|
if (!agent) return '';
|
||||||
|
return agent + (umhaenge ? '' : '|capes=0') + (design ? '|design=' + design : '') + (modsAdresse ? '|mods=' + modsAdresse : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Mod-Schalter im Spiel (siehe modschalter.js) ----------
|
||||||
|
// Während Minecraft läuft, sind die Mod-Dateien gesperrt: Änderungen werden in
|
||||||
|
// instance.json vorgemerkt und beim Beenden umgesetzt.
|
||||||
|
const modFenster = new Map(); // instanceId -> BrowserWindow
|
||||||
|
const neustartNachBeenden = new Set(); // instanceId -> nach dem Beenden direkt wieder starten
|
||||||
|
const letzteVerbindungZeit = new Map(); // instanceId -> Zeitpunkt der letzten Server-Verbindung
|
||||||
|
const sitzungsInfo = new Map(); // instanceId -> { start, kontoId } der laufenden Sitzung
|
||||||
|
|
||||||
|
// Wohin der Neustart führt: zuletzt verbundener Server oder zuletzt gespeicherte
|
||||||
|
// Welt dieser Sitzung – je nachdem, was jünger ist – samt Konto der Sitzung
|
||||||
|
function neustartZiel(id) {
|
||||||
|
const info = sitzungsInfo.get(id) || {};
|
||||||
|
const seit = info.start || 0;
|
||||||
|
const ziel = { kontoId: info.kontoId || null };
|
||||||
|
const welt = seit ? zuletztGespielteWelt(id, seit) : null;
|
||||||
|
const serverZeit = letzteVerbindungZeit.get(id) || 0;
|
||||||
|
const server = letzteQuickPlayServer.get(id);
|
||||||
|
if (server && serverZeit >= seit && (!welt || serverZeit > welt.zeit)) ziel.quickPlayServer = server;
|
||||||
|
else if (welt) ziel.quickPlayWelt = welt.name;
|
||||||
|
return ziel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ausstehendeMods(id) {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
return (inst && inst.modsAusstehend) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function merkeModAenderung(id, filename, an) {
|
||||||
|
const aus = Object.assign({}, ausstehendeMods(id));
|
||||||
|
const mod = store.listMods(id, 'mod').find((m) => m.filename === filename);
|
||||||
|
if (mod && mod.enabled === an) delete aus[filename]; // zurück auf den Stand der Datei
|
||||||
|
else aus[filename] = an;
|
||||||
|
store.updateInstance(id, { modsAusstehend: aus });
|
||||||
|
return Object.keys(aus).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wendeAusstehendeModsAn(id) {
|
||||||
|
const aus = ausstehendeMods(id);
|
||||||
|
const namen = Object.keys(aus);
|
||||||
|
if (!namen.length) return { umgesetzt: 0, offen: 0 };
|
||||||
|
const rest = {};
|
||||||
|
let umgesetzt = 0;
|
||||||
|
for (const f of namen) {
|
||||||
|
const r = store.setModEnabled(id, f, !!aus[f], 'mod');
|
||||||
|
if (r.ok) umgesetzt++;
|
||||||
|
else if (r.reason !== 'not-found' && r.reason !== 'both-exist' && r.reason !== 'bad-name') rest[f] = aus[f];
|
||||||
|
}
|
||||||
|
store.updateInstance(id, { modsAusstehend: rest });
|
||||||
|
broadcast('mods:changed', { id });
|
||||||
|
return { umgesetzt, offen: Object.keys(rest).length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welt, die während der Sitzung zuletzt gespeichert wurde (level.dat neuer als der Start)
|
||||||
|
function zuletztGespielteWelt(id, seit) {
|
||||||
|
let beste = null;
|
||||||
|
try {
|
||||||
|
const savesDir = path.join(store.gameDir(id), 'saves');
|
||||||
|
for (const e of fs.readdirSync(savesDir, { withFileTypes: true })) {
|
||||||
|
if (!e.isDirectory()) continue;
|
||||||
|
try {
|
||||||
|
const zeit = fs.statSync(path.join(savesDir, e.name, 'level.dat')).mtimeMs;
|
||||||
|
if (zeit >= seit - 5000 && (!beste || zeit > beste.zeit)) beste = { name: e.name, zeit };
|
||||||
|
} catch { /* keine Welt */ }
|
||||||
|
}
|
||||||
|
} catch { /* kein saves-Ordner */ }
|
||||||
|
return beste;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oeffneModFenster(sitzung) {
|
||||||
|
const { instanceId: id, pid } = sitzung;
|
||||||
|
const mc = await findeMinecraftFenster(pid);
|
||||||
|
const b = mc ? mc.getBounds() : null;
|
||||||
|
const breite = b ? Math.max(640, Math.min(980, Math.round(b.width * 0.9))) : 900;
|
||||||
|
const hoehe = b ? Math.max(460, Math.min(680, Math.round(b.height * 0.9))) : 620;
|
||||||
|
const lage = b ? { x: Math.round(b.x + (b.width - breite) / 2), y: Math.round(b.y + (b.height - hoehe) / 2) } : {};
|
||||||
|
let win = modFenster.get(id);
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.setBounds(Object.assign({ width: breite, height: hoehe }, lage));
|
||||||
|
win.webContents.send('popout:reopen', { instanceId: id });
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
win = new BrowserWindow(Object.assign({
|
||||||
|
width: breite, height: hoehe, minWidth: 560, minHeight: 400,
|
||||||
|
title: 'AeroMC – Mods', backgroundColor: fensterFarbe(), icon: windowIcon(),
|
||||||
|
frame: false, show: false, skipTaskbar: true, alwaysOnTop: true,
|
||||||
|
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false },
|
||||||
|
}, lage));
|
||||||
|
win.setAlwaysOnTop(true, 'screen-saver');
|
||||||
|
modFenster.set(id, win);
|
||||||
|
win.loadFile(path.join(__dirname, 'index.html'), { query: { popout: 'modschalter', instanceId: id } });
|
||||||
|
win.once('ready-to-show', () => { win.show(); win.focus(); });
|
||||||
|
// nach dem Schließen gleich wieder ins Spiel
|
||||||
|
const zurueck = async () => {
|
||||||
|
const f = await findeMinecraftFenster(pid);
|
||||||
|
if (f) { try { f.bringToTop(); } catch { /* egal */ } }
|
||||||
|
};
|
||||||
|
win.on('hide', zurueck);
|
||||||
|
win.on('closed', () => { modFenster.delete(id); zurueck(); });
|
||||||
|
}
|
||||||
|
modschalter.beiKlick((sitzung) => { oeffneModFenster(sitzung).catch((err) => console.error('Mod-Fenster:', err)); });
|
||||||
|
|
||||||
|
ipcMain.handle('modschalter:daten', async (_e, id) => {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
name: inst.name,
|
||||||
|
version: inst.minecraft.version,
|
||||||
|
loader: inst.minecraft.loader,
|
||||||
|
laeuft: laufendeInstanzen.has(id),
|
||||||
|
ziel: laufendeInstanzen.has(id) ? neustartZiel(id) : null,
|
||||||
|
quickPlayWelt: spieldesign.paketFormat(inst.minecraft.version) >= 15, // Welt direkt öffnen geht ab 1.20
|
||||||
|
mods: await modsMitErkennung(id, 'mod'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// ---------- Neustart-Bildschirm (neustart.html) ----------
|
||||||
|
// Liegt über dem Spielfenster, während Minecraft beendet, die Mods umgestellt
|
||||||
|
// und das Spiel neu gestartet wird – bis man wieder auf dem Server bzw. in der
|
||||||
|
// Welt ist (Minecraft hängt dann "Mehrspieler"/"Einzelspieler" an den Fenstertitel).
|
||||||
|
const neustartBild = new Map(); // instanceId -> { win, ziel, folger, notfall, fensterSeit, verbundenSeit }
|
||||||
|
const START_TEXTE = {
|
||||||
|
version: 'Minecraft wird vorbereitet', client: 'Minecraft wird vorbereitet', libraries: 'Bibliotheken werden geprüft',
|
||||||
|
assets: 'Spieldaten werden geprüft', loader: 'Mod-Loader wird vorbereitet', java: 'Java wird vorbereitet',
|
||||||
|
};
|
||||||
|
const START_ANTEILE = { version: [0, 6], client: [6, 14], libraries: [14, 45], assets: [45, 80], loader: [80, 90], java: [90, 96] };
|
||||||
|
|
||||||
|
// Fensterrechteck (physische Pixel) -> Electron-Koordinaten
|
||||||
|
function alsDip(r) {
|
||||||
|
try { return screen.screenToDipRect(null, r); } catch { return r; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zeigeNeustartBild(id, pid) {
|
||||||
|
schliesseNeustartBild(id, true);
|
||||||
|
const mc = await findeMinecraftFenster(pid);
|
||||||
|
// Fensterzustand merken – Minecraft selbst weiß nach dem Neustart nicht mehr,
|
||||||
|
// dass es maximiert war, und käme in der Standardgröße wieder
|
||||||
|
let altesFenster = null;
|
||||||
|
if (mc) {
|
||||||
|
try {
|
||||||
|
const roh = mc.getBounds();
|
||||||
|
const dip = alsDip(roh);
|
||||||
|
const wa = screen.getDisplayMatching(dip).workArea;
|
||||||
|
const maximiert = dip.x <= wa.x && dip.y <= wa.y && dip.x + dip.width >= wa.x + wa.width && dip.y + dip.height >= wa.y + wa.height;
|
||||||
|
altesFenster = { bounds: roh, maximiert };
|
||||||
|
} catch { /* ohne Wiederherstellung */ }
|
||||||
|
}
|
||||||
|
let b = mc ? alsDip(mc.getBounds()) : null;
|
||||||
|
if (!b || b.width < 200 || b.height < 150) {
|
||||||
|
const a = screen.getPrimaryDisplay().workArea;
|
||||||
|
b = { x: Math.round(a.x + (a.width - 960) / 2), y: Math.round(a.y + (a.height - 540) / 2), width: 960, height: 540 };
|
||||||
|
}
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
x: b.x, y: b.y, width: b.width, height: b.height,
|
||||||
|
frame: false, resizable: false, skipTaskbar: true, alwaysOnTop: true, focusable: false, show: false,
|
||||||
|
backgroundColor: '#0a0e1a', icon: windowIcon(),
|
||||||
|
webPreferences: { contextIsolation: true, sandbox: true },
|
||||||
|
});
|
||||||
|
win.setAlwaysOnTop(true, 'screen-saver');
|
||||||
|
win.loadFile(path.join(__dirname, 'neustart.html'));
|
||||||
|
win.once('ready-to-show', () => { if (!win.isDestroyed()) win.showInactive(); });
|
||||||
|
const eintrag = { win, ziel: null, folger: null, fensterSeit: 0, verbundenSeit: 0, altesFenster };
|
||||||
|
// Sicherheitsnetz: nie länger als 3 Minuten stehen lassen
|
||||||
|
eintrag.notfall = setTimeout(() => schliesseNeustartBild(id), 180000);
|
||||||
|
neustartBild.set(id, eintrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function neustartStatus(id, text, prozent) {
|
||||||
|
const e = neustartBild.get(id);
|
||||||
|
if (!e || e.win.isDestroyed()) return;
|
||||||
|
const js = `typeof setze === 'function' && setze(${JSON.stringify({ text, prozent: typeof prozent === 'number' ? prozent : null })})`;
|
||||||
|
if (e.win.webContents.isLoading()) e.win.webContents.once('did-finish-load', () => e.win.webContents.executeJavaScript(js).catch(() => {}));
|
||||||
|
else e.win.webContents.executeJavaScript(js).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliesseNeustartBild(id, sofort) {
|
||||||
|
const e = neustartBild.get(id);
|
||||||
|
if (!e) return;
|
||||||
|
neustartBild.delete(id);
|
||||||
|
clearTimeout(e.notfall);
|
||||||
|
clearInterval(e.folger);
|
||||||
|
if (e.win.isDestroyed()) return;
|
||||||
|
if (sofort) { e.win.destroy(); return; }
|
||||||
|
e.win.webContents.executeJavaScript('typeof ausblenden === "function" && ausblenden()').catch(() => {});
|
||||||
|
setTimeout(() => { if (!e.win.isDestroyed()) e.win.destroy(); }, 420);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startfortschritt des Neustarts anzeigen (aus dem launch:progress der neuen Sitzung)
|
||||||
|
function neustartFortschritt(id, evt) {
|
||||||
|
if (!neustartBild.has(id)) return;
|
||||||
|
if (START_TEXTE[evt.phase]) {
|
||||||
|
const [von, bis] = START_ANTEILE[evt.phase];
|
||||||
|
const anteil = evt.total ? Math.min(1, evt.current / evt.total) : 0;
|
||||||
|
const prozent = Math.round(von + (bis - von) * anteil);
|
||||||
|
neustartStatus(id, `${START_TEXTE[evt.phase]} … ${prozent} %`, prozent);
|
||||||
|
} else if (evt.phase === 'launch') {
|
||||||
|
neustartStatus(id, 'Minecraft startet …', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dem neuen Spielfenster folgen und ausblenden, sobald man wieder drin ist
|
||||||
|
function folgeNeuemSpiel(id, pid) {
|
||||||
|
const e = neustartBild.get(id);
|
||||||
|
if (!e) return;
|
||||||
|
let windowManager = null;
|
||||||
|
try { ({ windowManager } = require('node-window-manager')); } catch { /* ohne Fensterverfolgung */ }
|
||||||
|
const ziel = e.ziel || {};
|
||||||
|
e.folger = setInterval(() => {
|
||||||
|
if (e.win.isDestroyed()) { clearInterval(e.folger); return; }
|
||||||
|
let fenster = null;
|
||||||
|
// nur das Spielfenster – Java hat daneben u. a. ein (verstecktes) Konsolenfenster
|
||||||
|
try { fenster = windowManager && windowManager.getWindows().find((w) => w.processId === pid && /Minecraft/i.test(w.getTitle() || '') && w.isVisible()); } catch { /* weiter versuchen */ }
|
||||||
|
if (!fenster) return;
|
||||||
|
const jetzt = Date.now();
|
||||||
|
if (!e.fensterSeit) {
|
||||||
|
e.fensterSeit = jetzt;
|
||||||
|
// Größe/Lage bzw. „maximiert“ wie vor dem Neustart – kurz darauf noch einmal,
|
||||||
|
// falls Minecraft die Fenstergröße beim Hochfahren selbst noch setzt
|
||||||
|
const herstellen = () => {
|
||||||
|
const alt = e.altesFenster;
|
||||||
|
if (!alt) return;
|
||||||
|
try { if (alt.maximiert) fenster.maximize(); else fenster.setBounds(alt.bounds); } catch { /* egal */ }
|
||||||
|
};
|
||||||
|
herstellen();
|
||||||
|
setTimeout(herstellen, 1500);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = alsDip(fenster.getBounds());
|
||||||
|
if (r.width > 200 && r.height > 150) e.win.setBounds(r);
|
||||||
|
} catch { /* Fenster gerade im Umbau */ }
|
||||||
|
let titel = '';
|
||||||
|
try { titel = fenster.getTitle() || ''; } catch { /* egal */ }
|
||||||
|
const imSpiel = titel.replace(/^AeroMC - /, '').split(' - ').length >= 2;
|
||||||
|
if (!ziel.quickPlayServer && !ziel.quickPlayWelt) {
|
||||||
|
// kein Ziel: das Spiel zeigt ab jetzt seinen eigenen (AeroMC-)Ladebildschirm
|
||||||
|
if (jetzt - e.fensterSeit > 1500) schliesseNeustartBild(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (imSpiel) {
|
||||||
|
neustartStatus(id, 'Willkommen zurück!', 100);
|
||||||
|
setTimeout(() => schliesseNeustartBild(id), 500);
|
||||||
|
clearInterval(e.folger);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.verbundenSeit) {
|
||||||
|
neustartStatus(id, ziel.quickPlayServer ? `Verbinde mit ${ziel.quickPlayServer} …` : `Welt „${ziel.quickPlayWelt}“ wird geladen …`, null);
|
||||||
|
}
|
||||||
|
// ältere Versionen (1.14) schreiben nichts in den Titel -> nach dem Verbinden kurz warten
|
||||||
|
if (e.verbundenSeit && jetzt - e.verbundenSeit > 9000) schliesseNeustartBild(id);
|
||||||
|
if (jetzt - e.fensterSeit > 90000) schliesseNeustartBild(id);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('modschalter:neustart', async (_e, id) => {
|
||||||
|
const pid = laufendePid.get(id);
|
||||||
|
if (!pid) return { ok: false, message: 'Das Spiel läuft nicht.' };
|
||||||
|
neustartNachBeenden.add(id);
|
||||||
|
const win = modFenster.get(id);
|
||||||
|
if (win && !win.isDestroyed()) win.destroy();
|
||||||
|
await zeigeNeustartBild(id, pid).catch(() => {});
|
||||||
|
neustartStatus(id, 'Minecraft wird beendet …', null);
|
||||||
|
// ohne /F: Windows bittet das Fenster ums Schließen – Minecraft speichert und beendet sich sauber
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
spawn('taskkill', ['/PID', String(pid)], { windowsHide: true }).on('error', () => {});
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
ipcMain.on('modschalter:schliessen', (e) => {
|
||||||
|
const w = BrowserWindow.fromWebContents(e.sender);
|
||||||
|
if (w && !w.isDestroyed()) w.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
|
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
|
||||||
function umhangFehler(res) {
|
function umhangFehler(res) {
|
||||||
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
|
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
|
||||||
@@ -2376,7 +2792,9 @@ async function findeMinecraftFenster(pid) {
|
|||||||
for (let versuch = 1; versuch <= 4; versuch++) {
|
for (let versuch = 1; versuch <= 4; versuch++) {
|
||||||
let alleFenster;
|
let alleFenster;
|
||||||
try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; }
|
try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; }
|
||||||
const treffer = alleFenster.find((w) => w.processId === pid);
|
// bevorzugt das sichtbare Spielfenster (Java hat daneben u. a. ein verstecktes Konsolenfenster)
|
||||||
|
const vomProzess = alleFenster.filter((w) => w.processId === pid);
|
||||||
|
const treffer = vomProzess.find((w) => { try { return w.isVisible() && /Minecraft/i.test(w.getTitle() || ''); } catch { return false; } }) || vomProzess[0];
|
||||||
if (treffer) return treffer;
|
if (treffer) return treffer;
|
||||||
if (versuch < 4) await new Promise((r) => setTimeout(r, 300));
|
if (versuch < 4) await new Promise((r) => setTimeout(r, 300));
|
||||||
}
|
}
|
||||||
@@ -2695,6 +3113,7 @@ function teilBeschreibung(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('share:create', async (_e, id) => {
|
ipcMain.handle('share:create', async (_e, id) => {
|
||||||
|
if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
|
||||||
const beschreibung = teilBeschreibung(id);
|
const beschreibung = teilBeschreibung(id);
|
||||||
if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' };
|
if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' };
|
||||||
@@ -3034,9 +3453,9 @@ ipcMain.handle('auth:login', async () => {
|
|||||||
// IPC – Instanzen
|
// IPC – Instanzen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
ipcMain.handle('instances:list', () => store.listInstances());
|
ipcMain.handle('instances:list', () => store.listInstances().map(mitSperre));
|
||||||
|
|
||||||
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id));
|
ipcMain.handle('instances:get', (_e, id) => mitSperre(store.getInstance(id)));
|
||||||
|
|
||||||
ipcMain.handle('instances:create', (_e, data) => {
|
ipcMain.handle('instances:create', (_e, data) => {
|
||||||
const res = store.createInstance(data);
|
const res = store.createInstance(data);
|
||||||
@@ -3050,9 +3469,26 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
|
|||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
|
ipcMain.handle('instances:delete', (_e, id) => {
|
||||||
|
const res = store.deleteInstance(id);
|
||||||
|
integritaet.entferne(id);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gesperrte Instanz als neue Instanz aus der neuesten sauberen Sicherung holen
|
||||||
|
ipcMain.handle('integritaet:wiederherstellen', async (_e, id) => {
|
||||||
|
try {
|
||||||
|
const sicherungen = backup.listGlobalBackups(app.getPath('documents')).map((b) => b.path);
|
||||||
|
const res = await integritaet.stelleWiederHer(id, sicherungen);
|
||||||
|
broadcast('instances:changed');
|
||||||
|
return Object.assign({ ok: true }, res);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('instances:duplicate', async (_e, id) => {
|
ipcMain.handle('instances:duplicate', async (_e, id) => {
|
||||||
|
if (integritaet.istGesperrt(id)) throw new Error(GESPERRT_TEXT);
|
||||||
const kopie = store.duplicateInstance(id, true);
|
const kopie = store.duplicateInstance(id, true);
|
||||||
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
|
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
|
||||||
catch { /* Quelle evtl. leer */ }
|
catch { /* Quelle evtl. leer */ }
|
||||||
@@ -3145,6 +3581,7 @@ function runPwsh(command, env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('instances:export', async (_e, id) => {
|
ipcMain.handle('instances:export', async (_e, id) => {
|
||||||
|
if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
|
||||||
const inst = store.getInstance(id);
|
const inst = store.getInstance(id);
|
||||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||||
const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_');
|
const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_');
|
||||||
@@ -3325,11 +3762,24 @@ async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten })
|
|||||||
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
|
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
|
||||||
if (!inside(dest)) continue;
|
if (!inside(dest)) continue;
|
||||||
broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
|
broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
|
||||||
|
// Nur über HTTPS und nur mit passender Prüfsumme aus dem Paket: so landet
|
||||||
|
// keine unterwegs ausgetauschte oder manipulierte Mod im mods-Ordner.
|
||||||
|
const hashes = f.hashes || {};
|
||||||
|
const algo = hashes.sha512 ? 'sha512' : (hashes.sha1 ? 'sha1' : null);
|
||||||
|
if (!/^https:\/\//i.test(String(url)) || !algo) {
|
||||||
|
console.warn(`[Modpack] ${rel} übersprungen (keine sichere Quelle/Prüfsumme)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
|
||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
|
const inhalt = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (require('crypto').createHash(algo).update(inhalt).digest('hex') !== String(hashes[algo]).toLowerCase()) {
|
||||||
|
console.warn(`[Modpack] ${rel} verworfen: Prüfsumme stimmt nicht`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
fs.writeFileSync(dest, inhalt);
|
||||||
dateien.push(rel);
|
dateien.push(rel);
|
||||||
} catch { /* einzelne Datei überspringen */ }
|
} catch { /* einzelne Datei überspringen */ }
|
||||||
}
|
}
|
||||||
@@ -3541,7 +3991,7 @@ async function importCurseForge(buf, zip, zugang) {
|
|||||||
try {
|
try {
|
||||||
// Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf
|
// Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf
|
||||||
// einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server.
|
// einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server.
|
||||||
await services.downloadFile(info.url, dest, info.kopfzeilen);
|
await services.downloadFile(info.url, dest, info.kopfzeilen, info.hashes);
|
||||||
geladen++;
|
geladen++;
|
||||||
} catch {
|
} catch {
|
||||||
fehlend.push(f.fileID);
|
fehlend.push(f.fileID);
|
||||||
@@ -3597,7 +4047,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' });
|
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' });
|
||||||
const buf = await services.downloadBuffer(file.url);
|
const buf = await services.downloadBuffer(file.url, null, file.hashes);
|
||||||
const zip = forge.readZipIndex(buf);
|
const zip = forge.readZipIndex(buf);
|
||||||
if (!zip['modrinth.index.json']) {
|
if (!zip['modrinth.index.json']) {
|
||||||
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
|
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
|
||||||
@@ -3648,7 +4098,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
|
|||||||
}
|
}
|
||||||
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' });
|
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' });
|
||||||
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
||||||
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen);
|
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen, file.hashes);
|
||||||
const zip = forge.readZipIndex(buf);
|
const zip = forge.readZipIndex(buf);
|
||||||
if (!zip['manifest.json']) {
|
if (!zip['manifest.json']) {
|
||||||
return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' };
|
return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' };
|
||||||
@@ -3827,15 +4277,25 @@ async function importInstanceZipFromPath(filePath) {
|
|||||||
notes: imported.notes || '',
|
notes: imported.notes || '',
|
||||||
minecraft: imported.minecraft || {},
|
minecraft: imported.minecraft || {},
|
||||||
});
|
});
|
||||||
if (imported.java) store.updateInstance(created.id, { java: imported.java });
|
// Aus fremden Archiven nur den Arbeitsspeicher übernehmen. Java-Pfad und
|
||||||
|
// JVM-Argumente bleiben leer: ein präpariertes Archiv könnte sonst beim
|
||||||
|
// nächsten Start ein beliebiges Programm ausführen lassen (eigene "java.exe",
|
||||||
|
// -javaagent:…, -XX:OnOutOfMemoryError=…).
|
||||||
|
if (imported.java && typeof imported.java === 'object') {
|
||||||
|
const speicher = (v) => (Number.isFinite(Number(v)) && Number(v) >= 256 && Number(v) <= 262144 ? Math.round(Number(v)) : null);
|
||||||
|
store.updateInstance(created.id, { java: { minMemMb: speicher(imported.java.minMemMb), maxMemMb: speicher(imported.java.maxMemMb) } });
|
||||||
|
}
|
||||||
|
|
||||||
const mcSrc = path.join(base, 'minecraft');
|
const mcSrc = path.join(base, 'minecraft');
|
||||||
if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
|
if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
|
||||||
const modsSrc = path.join(base, 'mods.json');
|
const modsSrc = path.join(base, 'mods.json');
|
||||||
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
|
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
|
||||||
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) {
|
// nur ein schlichter Bild-Dateiname – kein "../" o. Ä., das aus dem Ordner hinausführt
|
||||||
fs.copyFileSync(path.join(base, imported.customImage), path.join(store.instancePath(created.id), imported.customImage));
|
const bildName = typeof imported.customImage === 'string' ? imported.customImage : '';
|
||||||
store.updateInstance(created.id, { customImage: imported.customImage });
|
if (/^[\w.\- ]+\.(png|jpe?g|gif|webp)$/i.test(bildName) && bildName === path.basename(bildName)
|
||||||
|
&& fs.existsSync(path.join(base, bildName))) {
|
||||||
|
fs.copyFileSync(path.join(base, bildName), path.join(store.instancePath(created.id), bildName));
|
||||||
|
store.updateInstance(created.id, { customImage: bildName });
|
||||||
}
|
}
|
||||||
return { ok: true, instance: store.getInstance(created.id) };
|
return { ok: true, instance: store.getInstance(created.id) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -4079,6 +4539,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
if (!inst.minecraft.version) {
|
if (!inst.minecraft.version) {
|
||||||
return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' };
|
return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' };
|
||||||
}
|
}
|
||||||
|
// Manipulationsschutz: gründliche Prüfung (alle Mods neu gehasht) vor jedem Start
|
||||||
|
{
|
||||||
|
const r = await integritaet.pruefe(id);
|
||||||
|
if (r.status === 'gesperrt') {
|
||||||
|
if (r.neuGesperrt) meldeGesperrt(id);
|
||||||
|
return { ok: false, gesperrt: true, message: GESPERRT_TEXT };
|
||||||
|
}
|
||||||
|
}
|
||||||
const settings = store.getSettings();
|
const settings = store.getSettings();
|
||||||
|
|
||||||
// Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung
|
// Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung
|
||||||
@@ -4103,9 +4571,18 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
// Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten)
|
// Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten)
|
||||||
let modUpdates = null;
|
let modUpdates = null;
|
||||||
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
|
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
|
||||||
|
if (modUpdates) await integritaet.versiegele(id).catch(() => {}); // eigene Änderung, geprüft wurde oben
|
||||||
|
|
||||||
|
// vorgemerkte Mod-Änderungen aus der letzten Sitzung (falls dort nicht mehr umgesetzt)
|
||||||
|
if (!laufendeInstanzen.has(id)) wendeAusstehendeModsAn(id);
|
||||||
|
// Mod-Schalter im Pausemenü (nur Instanzen mit Mod-Loader)
|
||||||
|
const modSitzung = settings.modSchalter !== false && inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
|
||||||
|
? await modschalter.neueSitzung(id).catch(() => null) : null;
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(),
|
javaAgent: agentArgument(settings, inst.minecraft.version, modSitzung && modSitzung.adresse),
|
||||||
|
// Ressourcenpaket für Menü-Logo, Sprüche und Knöpfe anlegen bzw. entfernen
|
||||||
|
vorStart: (info, protokoll) => spieldesign.vorbereiten(Object.assign({ an: settings.aeromcDesign !== false, mods: !!modSitzung, protokoll }, info)),
|
||||||
versionId: inst.minecraft.version,
|
versionId: inst.minecraft.version,
|
||||||
loader: inst.minecraft.loader || 'vanilla',
|
loader: inst.minecraft.loader || 'vanilla',
|
||||||
loaderVersion: inst.minecraft.loaderVersion || '',
|
loaderVersion: inst.minecraft.loaderVersion || '',
|
||||||
@@ -4126,6 +4603,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
|
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
|
||||||
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
|
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
|
||||||
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
|
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
|
||||||
|
quickPlayWelt: (launchOpts && launchOpts.quickPlayWelt) ? String(launchOpts.quickPlayWelt) : '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
|
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
|
||||||
@@ -4153,9 +4631,11 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
try {
|
try {
|
||||||
let launchStart = 0;
|
let launchStart = 0;
|
||||||
const res = await launcher.prepareAndLaunch(opts, (evt) => {
|
const res = await launcher.prepareAndLaunch(opts, (evt) => {
|
||||||
|
if (neustartBild.has(id)) neustartFortschritt(id, evt);
|
||||||
if (evt.phase === 'launch') {
|
if (evt.phase === 'launch') {
|
||||||
launchStart = Date.now();
|
launchStart = Date.now();
|
||||||
laufendeInstanzen.add(id);
|
laufendeInstanzen.add(id);
|
||||||
|
sitzungsInfo.set(id, { start: Date.now(), kontoId });
|
||||||
meldeLaufende();
|
meldeLaufende();
|
||||||
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
|
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
|
||||||
discordpresence.setActivity({
|
discordpresence.setActivity({
|
||||||
@@ -4194,6 +4674,34 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
|
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
|
||||||
laufendeInstanzen.delete(id);
|
laufendeInstanzen.delete(id);
|
||||||
meldeLaufende();
|
meldeLaufende();
|
||||||
|
if (modSitzung) modschalter.beende(modSitzung.token);
|
||||||
|
const offenesModFenster = modFenster.get(id);
|
||||||
|
if (offenesModFenster && !offenesModFenster.isDestroyed()) offenesModFenster.destroy();
|
||||||
|
{
|
||||||
|
const neuStarten = neustartNachBeenden.delete(id);
|
||||||
|
// jetzt bestimmen, solange die Sitzungsdaten noch stimmen
|
||||||
|
const zielBeimBeenden = neuStarten ? neustartZiel(id) : null;
|
||||||
|
sitzungsInfo.delete(id);
|
||||||
|
if (neuStarten) {
|
||||||
|
const bild = neustartBild.get(id);
|
||||||
|
if (bild) bild.ziel = zielBeimBeenden;
|
||||||
|
neustartStatus(id, 'Mods werden übernommen …', null);
|
||||||
|
} else if (neustartBild.has(id)) {
|
||||||
|
schliesseNeustartBild(id); // neues Spiel nach einem Neustart wieder beendet (z. B. Absturz)
|
||||||
|
}
|
||||||
|
// kurz warten, bis Windows die Mod-Dateien freigegeben hat
|
||||||
|
setTimeout(() => {
|
||||||
|
let erg = wendeAusstehendeModsAn(id);
|
||||||
|
if (erg.offen) { setTimeout(() => { erg = wendeAusstehendeModsAn(id); }, 2000); }
|
||||||
|
if (erg.umgesetzt && !neuStarten) broadcast('ui:toast', { text: `${erg.umgesetzt} Mod-Änderung${erg.umgesetzt === 1 ? '' : 'en'} übernommen – gilt ab dem nächsten Start.` });
|
||||||
|
if (neuStarten && mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
neustartStatus(id, 'Minecraft startet neu …', null);
|
||||||
|
mainWindow.webContents.send('modschalter:neustarten', { id, ziel: zielBeimBeenden });
|
||||||
|
} else if (neuStarten) {
|
||||||
|
schliesseNeustartBild(id);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
// beim nächsten Start dieses Kontos sollen bereits online befindliche
|
// beim nächsten Start dieses Kontos sollen bereits online befindliche
|
||||||
// Freunde wieder gemeldet werden
|
// Freunde wieder gemeldet werden
|
||||||
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
|
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
|
||||||
@@ -4218,6 +4726,9 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
if (m) {
|
if (m) {
|
||||||
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
|
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
|
||||||
letzteQuickPlayServer.set(id, server);
|
letzteQuickPlayServer.set(id, server);
|
||||||
|
letzteVerbindungZeit.set(id, Date.now());
|
||||||
|
const bild = neustartBild.get(id);
|
||||||
|
if (bild && !bild.verbundenSeit) { bild.verbundenSeit = Date.now(); neustartStatus(id, `Verbinde mit ${server} …`, null); }
|
||||||
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
|
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
|
||||||
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
|
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
|
||||||
broadcast('recording:serverDetected', { id, server });
|
broadcast('recording:serverDetected', { id, server });
|
||||||
@@ -4239,6 +4750,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
|||||||
}
|
}
|
||||||
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
|
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
|
||||||
if (res.ok && res.pid) laufendePid.set(id, res.pid);
|
if (res.ok && res.pid) laufendePid.set(id, res.pid);
|
||||||
|
if (modSitzung) { if (res.ok && res.pid) modschalter.setzePid(modSitzung.token, res.pid); else modschalter.beende(modSitzung.token); }
|
||||||
|
if (neustartBild.has(id)) { if (res.ok && res.pid) folgeNeuemSpiel(id, res.pid); else schliesseNeustartBild(id); }
|
||||||
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
|
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -4611,12 +5124,17 @@ ipcMain.handle('update:check', () => pruefeAufUpdates());
|
|||||||
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
|
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
|
||||||
|
|
||||||
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
|
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
|
||||||
ipcMain.handle('update:install', async (_e, asset) => {
|
// Die Download-Adresse kommt bewusst nicht aus dem Fenster, sondern aus der
|
||||||
|
// eigenen Update-Prüfung – sonst könnte jeder Aufruf von window.api eine
|
||||||
|
// beliebige Datei als "Update" ausführen lassen.
|
||||||
|
ipcMain.handle('update:install', async () => {
|
||||||
try {
|
try {
|
||||||
|
const asset = pendingUpdateInfo && pendingUpdateInfo.available ? pendingUpdateInfo.asset : null;
|
||||||
|
if (!asset) throw new Error('Kein geprüftes Update vorhanden – bitte erneut nach Updates suchen.');
|
||||||
const file = await updater.downloadAndInstall(asset, (p) => {
|
const file = await updater.downloadAndInstall(asset, (p) => {
|
||||||
broadcast('update:progress', p);
|
broadcast('update:progress', p);
|
||||||
});
|
});
|
||||||
const res = updater.applyUpdate(file);
|
const res = await updater.applyUpdate(file);
|
||||||
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
|
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
|
||||||
setTimeout(() => app.quit(), 1200);
|
setTimeout(() => app.quit(), 1200);
|
||||||
return { ok: true, path: file.path, kind: file.kind, mode: res.started };
|
return { ok: true, path: file.path, kind: file.kind, mode: res.started };
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* modschalter.js – Mods im laufenden Spiel an- und abschalten
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“ (siehe
|
||||||
|
* agent/ModsUmbau.java). Ein Klick ruft eine Adresse dieses kleinen, nur lokal
|
||||||
|
* erreichbaren Empfängers auf; der Launcher öffnet daraufhin das Mod-Fenster
|
||||||
|
* über dem Spiel.
|
||||||
|
*
|
||||||
|
* Solange Minecraft läuft, sind die Mod-Dateien gesperrt und geladene Mods
|
||||||
|
* lassen sich ohnehin nicht entladen. Änderungen werden deshalb vorgemerkt
|
||||||
|
* (instance.json -> modsAusstehend) und beim Beenden umgesetzt – das Spiel
|
||||||
|
* läuft weiter, beim nächsten Start gilt der neue Stand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
let server = null;
|
||||||
|
let port = 0;
|
||||||
|
const sitzungen = new Map(); // token -> { instanceId, pid }
|
||||||
|
let beimKlick = null; // (sitzung) => void
|
||||||
|
|
||||||
|
function starteServer() {
|
||||||
|
if (server) return Promise.resolve(port);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server = http.createServer((req, res) => {
|
||||||
|
const m = /^\/mods\/([a-f0-9]{32})$/.exec(req.url || '');
|
||||||
|
const s = m && sitzungen.get(m[1]);
|
||||||
|
if (!s) { res.writeHead(404); res.end(); return; }
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('ok');
|
||||||
|
if (beimKlick) { try { beimKlick(s); } catch (err) { console.error('Mod-Schalter:', err); } }
|
||||||
|
});
|
||||||
|
server.on('error', (err) => { server = null; reject(err); });
|
||||||
|
server.listen(0, '127.0.0.1', () => { port = server.address().port; resolve(port); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neue Sitzung für einen Spielstart -> { token, adresse } (für das Agent-Argument) */
|
||||||
|
async function neueSitzung(instanceId) {
|
||||||
|
await starteServer();
|
||||||
|
const token = crypto.randomBytes(16).toString('hex');
|
||||||
|
sitzungen.set(token, { instanceId, pid: null, token });
|
||||||
|
return { token, adresse: `http://127.0.0.1:${port}/mods/${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setzePid(token, pid) {
|
||||||
|
const s = sitzungen.get(token);
|
||||||
|
if (s) s.pid = pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beende(token) {
|
||||||
|
sitzungen.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitzungFuer(instanceId) {
|
||||||
|
for (const s of sitzungen.values()) if (s.instanceId === instanceId && s.pid) return s;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beiKlick(fn) { beimKlick = fn; }
|
||||||
|
|
||||||
|
module.exports = { neueSitzung, setzePid, beende, sitzungFuer, beiKlick };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self'" />
|
||||||
|
<title>AeroMC</title>
|
||||||
|
<!--
|
||||||
|
Neustart-Bildschirm (Mod-Schalter): liegt über dem Minecraft-Fenster, während
|
||||||
|
das Spiel beendet, die Mods umgestellt und Minecraft wieder gestartet wird –
|
||||||
|
bis man wieder auf dem Server bzw. in der Welt ist. Gleiches Bild wie der
|
||||||
|
Ladebildschirm im Spiel; Balken und Text an derselben Stelle (83,25 % der Höhe).
|
||||||
|
Der Launcher steuert die Seite über setze({ text, prozent }) und ausblenden().
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0e1a; cursor: default; user-select: none; }
|
||||||
|
#bild { position: fixed; inset: 0; background: #0a0e1a url('assets/spieldesign/neustart.jpg') center / cover no-repeat; }
|
||||||
|
#ganz { position: fixed; inset: 0; opacity: 0; transition: opacity .35s ease; }
|
||||||
|
#ganz.sichtbar { opacity: 1; }
|
||||||
|
#balken {
|
||||||
|
position: absolute; left: 22%; right: 22%; top: 83.25%; height: max(8px, 1.15vh); transform: translateY(-50%);
|
||||||
|
border: max(1px, .18vh) solid #4fd2ff; padding: max(1px, .18vh); box-sizing: content-box; overflow: hidden;
|
||||||
|
}
|
||||||
|
#fuellung { height: 100%; width: 0; background: #4fd2ff; transition: width .4s ease; }
|
||||||
|
#balken.unbestimmt #fuellung { width: 30%; animation: wandern 1.3s ease-in-out infinite; }
|
||||||
|
@keyframes wandern { 0% { transform: translateX(-110%); } 100% { transform: translateX(370%); } }
|
||||||
|
#text {
|
||||||
|
position: absolute; left: 50%; top: 88.5%; transform: translateX(-50%);
|
||||||
|
padding: .55vh 1.6vh .7vh; border-radius: .9vh; background: rgba(6, 12, 28, .7);
|
||||||
|
color: #ecf4ff; font: 600 max(13px, 3.1vh) 'Segoe UI', sans-serif; white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="ganz">
|
||||||
|
<div id="bild"></div>
|
||||||
|
<div id="balken" class="unbestimmt"><div id="fuellung"></div></div>
|
||||||
|
<div id="text">Mods werden übernommen …</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function setze(stand) {
|
||||||
|
if (stand.text) document.getElementById('text').textContent = stand.text;
|
||||||
|
const balken = document.getElementById('balken');
|
||||||
|
const hatProzent = typeof stand.prozent === 'number';
|
||||||
|
balken.classList.toggle('unbestimmt', !hatProzent);
|
||||||
|
document.getElementById('fuellung').style.width = hatProzent ? Math.max(2, Math.min(100, stand.prozent)) + '%' : '';
|
||||||
|
}
|
||||||
|
function ausblenden() { document.getElementById('ganz').classList.remove('sichtbar'); }
|
||||||
|
requestAnimationFrame(() => document.getElementById('ganz').classList.add('sichtbar'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||||
|
|
||||||
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
||||||
// damit man sie auch ohne offene DevTools sieht
|
// damit man sie auch ohne offene DevTools sieht
|
||||||
@@ -119,6 +119,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||||
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
||||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||||
|
integritaetWiederherstellen: (id) => ipcRenderer.invoke('integritaet:wiederherstellen', id),
|
||||||
|
// Pfad einer per Drag & Drop abgelegten Datei (File.path gibt es seit Electron 32 nicht mehr)
|
||||||
|
pfadVonDatei: (datei) => { try { return webUtils.getPathForFile(datei) || ''; } catch { return ''; } },
|
||||||
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
||||||
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||||
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
||||||
@@ -170,6 +173,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
|
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
|
||||||
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
|
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
|
||||||
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
|
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
|
||||||
|
modschalterDaten: (id) => ipcRenderer.invoke('modschalter:daten', id),
|
||||||
|
modschalterNeustart: (id) => ipcRenderer.invoke('modschalter:neustart', id),
|
||||||
|
modschalterSchliessen: () => ipcRenderer.send('modschalter:schliessen'),
|
||||||
|
onModschalterNeustarten: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d || {});
|
||||||
|
ipcRenderer.on('modschalter:neustarten', l);
|
||||||
|
return () => ipcRenderer.removeListener('modschalter:neustarten', l);
|
||||||
|
},
|
||||||
onLaufendeInstanzen: (cb) => {
|
onLaufendeInstanzen: (cb) => {
|
||||||
const l = (_e, d) => cb(d || []);
|
const l = (_e, d) => cb(d || []);
|
||||||
ipcRenderer.on('game:laufend', l);
|
ipcRenderer.on('game:laufend', l);
|
||||||
@@ -309,7 +320,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Updates
|
// Updates
|
||||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||||
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
||||||
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
|
updateInstall: () => ipcRenderer.invoke('update:install'),
|
||||||
onUpdateAvailable: (cb) => {
|
onUpdateAvailable: (cb) => {
|
||||||
const l = (_e, d) => cb(d);
|
const l = (_e, d) => cb(d);
|
||||||
ipcRenderer.on('update:available', l);
|
ipcRenderer.on('update:available', l);
|
||||||
|
|||||||
@@ -1702,7 +1702,11 @@ function showUpdateDialog(info) {
|
|||||||
el('upd-install').textContent = 'Installieren';
|
el('upd-install').textContent = 'Installieren';
|
||||||
el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
|
el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
|
||||||
el('upd-hint').textContent = hatPaket
|
el('upd-hint').textContent = hatPaket
|
||||||
? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.'
|
? 'Der Launcher lädt das Update herunter, prüft die Signatur und startet die Installation automatisch.'
|
||||||
|
: info.signaturHinweis
|
||||||
|
? info.signaturHinweis
|
||||||
|
: info.unsigniert
|
||||||
|
? 'Dieses Release ist nicht signiert. Aus Sicherheitsgründen wird es nicht automatisch installiert – bitte nur von der offiziellen Seite laden.'
|
||||||
: (channel
|
: (channel
|
||||||
? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.`
|
? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.`
|
||||||
: 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.');
|
: 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.');
|
||||||
@@ -1729,7 +1733,7 @@ async function startUpdate() {
|
|||||||
el('upd-progress').classList.remove('hidden');
|
el('upd-progress').classList.remove('hidden');
|
||||||
el('upd-status').textContent = 'Wird heruntergeladen …';
|
el('upd-status').textContent = 'Wird heruntergeladen …';
|
||||||
|
|
||||||
const res = await window.api.updateInstall(pendingUpdate.asset);
|
const res = await window.api.updateInstall();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
el('upd-status').textContent = res.kind === 'portable'
|
el('upd-status').textContent = res.kind === 'portable'
|
||||||
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
|
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
|
||||||
@@ -2233,6 +2237,7 @@ async function refreshSelectionPanel() {
|
|||||||
|
|
||||||
if (count > 1) {
|
if (count > 1) {
|
||||||
currentDetailId = null;
|
currentDetailId = null;
|
||||||
|
zeigeSperre(null);
|
||||||
setSingleSelectionActionsDisabled(true);
|
setSingleSelectionActionsDisabled(true);
|
||||||
el('p-delete').disabled = false;
|
el('p-delete').disabled = false;
|
||||||
el('p-delete').textContent = `${count} Instanzen löschen`;
|
el('p-delete').textContent = `${count} Instanzen löschen`;
|
||||||
@@ -2283,6 +2288,49 @@ async function refreshSelectionPanel() {
|
|||||||
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
|
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
|
||||||
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
|
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
|
||||||
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
|
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
|
||||||
|
zeigeSperre(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manipulierte Instanz (siehe integritaet.js im Hauptprozess): nicht startbar,
|
||||||
|
// nicht kopier-/export-/teilbar – nur ansehen, wiederherstellen oder löschen
|
||||||
|
const GESPERRT_AUS = ['p-play', 'p-offline', 'p-edit', 'p-mods', 'p-modpack', 'p-record', 'p-servers', 'p-shortcut', 'p-export', 'p-share', 'p-copy'];
|
||||||
|
|
||||||
|
function zeigeSperre(inst) {
|
||||||
|
const sperre = inst && inst.gesperrt && inst.gesperrt.gesperrt ? inst.gesperrt : null;
|
||||||
|
el('p-gesperrt').classList.toggle('hidden', !sperre);
|
||||||
|
el('p-icon').classList.toggle('gesperrt', !!sperre);
|
||||||
|
['p-play', 'p-offline'].forEach((id) => el(id).classList.toggle('hidden', !!sperre));
|
||||||
|
el('p-share').disabled = false;
|
||||||
|
el('p-record').disabled = false;
|
||||||
|
if (!sperre) return;
|
||||||
|
GESPERRT_AUS.forEach((id) => { el(id).disabled = true; });
|
||||||
|
if (!el('p-icon').querySelector('.side-verbot')) {
|
||||||
|
el('p-icon').insertAdjacentHTML('beforeend', '<img class="side-verbot" src="assets/verbot.svg" alt="" />');
|
||||||
|
}
|
||||||
|
const gruende = sperre.gruende || [];
|
||||||
|
const liste = el('p-gesperrt-gruende');
|
||||||
|
liste.innerHTML = gruende.slice(0, 5).map((g) => `<li>${escapeHtml(g)}</li>`).join('')
|
||||||
|
+ (gruende.length > 5 ? `<li>… und ${gruende.length - 5} weitere</li>` : '')
|
||||||
|
+ (sperre.seit ? `<li class="dim">Erkannt: ${escapeHtml(fmtDate(sperre.seit))}</li>` : '');
|
||||||
|
el('status-play').textContent = 'Gesperrt – bitte aus einer Sicherung wiederherstellen';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wiederherstellenAusSicherung() {
|
||||||
|
const id = currentDetailId;
|
||||||
|
if (!id) return;
|
||||||
|
const knopf = el('p-wiederherstellen');
|
||||||
|
knopf.disabled = true;
|
||||||
|
knopf.textContent = 'Sicherungen werden geprüft …';
|
||||||
|
try {
|
||||||
|
const res = await window.api.integritaetWiederherstellen(id);
|
||||||
|
if (!res.ok) { toast(res.message || 'Wiederherstellen fehlgeschlagen.', true); return; }
|
||||||
|
toast(`Wiederhergestellt aus ${res.sicherung} als „${res.name}“. Die gesperrte Instanz kannst du jetzt löschen.`);
|
||||||
|
await reload();
|
||||||
|
await selectInstance(res.id);
|
||||||
|
} finally {
|
||||||
|
knopf.disabled = false;
|
||||||
|
knopf.textContent = 'Aus Sicherung wiederherstellen';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySelection(ids) {
|
async function applySelection(ids) {
|
||||||
@@ -2313,13 +2361,17 @@ function buildCard(inst) {
|
|||||||
const taste = schnellstartTasteVon(inst.id);
|
const taste = schnellstartTasteVon(inst.id);
|
||||||
if (taste) tip += `\nSchnellstart: Strg+${taste}`;
|
if (taste) tip += `\nSchnellstart: Strg+${taste}`;
|
||||||
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
|
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
|
||||||
|
const gesperrt = !!(inst.gesperrt && inst.gesperrt.gesperrt);
|
||||||
|
if (gesperrt) tip = 'GESPERRT – außerhalb des Launchers verändert\n' + tip;
|
||||||
card.title = tip;
|
card.title = tip;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
${iconHtml}
|
${iconHtml}
|
||||||
<i class="card-riss" aria-hidden="true"></i>
|
<i class="card-riss" aria-hidden="true"></i>
|
||||||
|
${gesperrt ? '<img class="card-verbot" src="assets/verbot.svg" alt="Gesperrt" />' : ''}
|
||||||
<div class="card-name">${escapeHtml(inst.name)}</div>
|
<div class="card-name">${escapeHtml(inst.name)}</div>
|
||||||
${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`;
|
${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`;
|
||||||
|
if (gesperrt) card.classList.add('gesperrt');
|
||||||
if (laufendeIds.has(inst.id)) card.classList.add('laeuft');
|
if (laufendeIds.has(inst.id)) card.classList.add('laeuft');
|
||||||
if (startStand.has(inst.id)) zeigeAbbau(card);
|
if (startStand.has(inst.id)) zeigeAbbau(card);
|
||||||
|
|
||||||
@@ -2516,9 +2568,13 @@ function baueGruppenSektion(knoten, tiefe, showHeaders) {
|
|||||||
|
|
||||||
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
|
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
|
||||||
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
|
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
|
||||||
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
|
// Dateien aus dem Explorer gehören nicht hierher, sondern zum Datei-Import
|
||||||
|
// (wireFileDrop) – sonst würde die Gruppe sie verschlucken
|
||||||
|
const istDateiZug = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
|
||||||
|
section.addEventListener('dragover', (e) => { if (istDateiZug(e)) return; e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
|
||||||
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
|
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
|
||||||
section.addEventListener('drop', async (e) => {
|
section.addEventListener('drop', async (e) => {
|
||||||
|
if (istDateiZug(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
section.classList.remove('drop-hover');
|
section.classList.remove('drop-hover');
|
||||||
@@ -2860,6 +2916,11 @@ let launching = false;
|
|||||||
|
|
||||||
async function launch(id, accountId, launchOpts) {
|
async function launch(id, accountId, launchOpts) {
|
||||||
if (launching) return;
|
if (launching) return;
|
||||||
|
const lokal = instances.find((i) => i.id === id);
|
||||||
|
if (lokal && lokal.gesperrt && lokal.gesperrt.gesperrt) {
|
||||||
|
toast('Diese Instanz ist gesperrt, weil sie verändert wurde – bitte aus einer Sicherung wiederherstellen.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
launching = true;
|
launching = true;
|
||||||
status('Vorbereiten …');
|
status('Vorbereiten …');
|
||||||
el('status-right').textContent = '';
|
el('status-right').textContent = '';
|
||||||
@@ -4987,6 +5048,101 @@ async function installiereTeilCode() {
|
|||||||
el('code-preview').classList.add('hidden');
|
el('code-preview').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Mod-Schalter (Fenster über dem laufenden Spiel) ----------
|
||||||
|
// Zwei Listen wie beim Ressourcenpaket-Bildschirm: ein Klick schiebt eine Mod in
|
||||||
|
// die andere Liste. Läuft das Spiel, merkt der Launcher die Änderung vor und
|
||||||
|
// setzt sie beim Beenden um (geladene Mods lassen sich nicht entladen).
|
||||||
|
let msInstanz = null;
|
||||||
|
let msDaten = null;
|
||||||
|
let msStart = null; // Stand beim Öffnen: filename -> an
|
||||||
|
async function openModschalter(instanceId) {
|
||||||
|
if (POPOUT !== 'modschalter') return;
|
||||||
|
msInstanz = instanceId || new URLSearchParams(location.search).get('instanceId');
|
||||||
|
msStart = null;
|
||||||
|
el('ms-suche').value = '';
|
||||||
|
show('modal-modschalter');
|
||||||
|
await ladeModschalter();
|
||||||
|
el('ms-suche').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeModschalter(bewegt) {
|
||||||
|
const d = await window.api.modschalterDaten(msInstanz);
|
||||||
|
if (!d.ok) { el('ms-status').textContent = d.message || 'Instanz nicht gefunden.'; return; }
|
||||||
|
msDaten = d;
|
||||||
|
if (!msStart) msStart = new Map(d.mods.map((m) => [m.filename, m.enabled !== false]));
|
||||||
|
el('ms-instanz').textContent = `${d.name} · ${d.version}`;
|
||||||
|
zeichneModschalter(bewegt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneModschalter(bewegt) {
|
||||||
|
const q = el('ms-suche').value.trim().toLowerCase();
|
||||||
|
const passt = (m) => !q || `${m.title || ''} ${m.filename}`.toLowerCase().includes(q);
|
||||||
|
const mods = msDaten.mods.filter((m) => !m.fehlt).sort((a, b) => (a.title || a.filename).localeCompare(b.title || b.filename, 'de'));
|
||||||
|
const an = mods.filter((m) => m.enabled !== false), aus = mods.filter((m) => m.enabled === false);
|
||||||
|
el('ms-zahl-an').textContent = `(${an.length})`;
|
||||||
|
el('ms-zahl-aus').textContent = `(${aus.length})`;
|
||||||
|
for (const [box, liste, istAn] of [[el('ms-an'), an, true], [el('ms-aus'), aus, false]]) {
|
||||||
|
box.innerHTML = '';
|
||||||
|
const sichtbar = liste.filter(passt);
|
||||||
|
if (!sichtbar.length) {
|
||||||
|
box.innerHTML = `<div class="ms-leer">${q ? 'Keine Treffer.' : istAn ? 'Keine aktiven Mods.' : 'Hierher verschobene Mods werden deaktiviert.'}</div>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const m of sichtbar) {
|
||||||
|
const zeile = document.createElement('div');
|
||||||
|
zeile.className = 'ms-mod' + (bewegt === m.filename ? ' kommt' : '');
|
||||||
|
zeile.style.setProperty('--ms-von', istAn ? '14px' : '-14px');
|
||||||
|
zeile.setAttribute('role', 'listitem');
|
||||||
|
zeile.title = istAn ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren';
|
||||||
|
const geaendert = msStart && msStart.get(m.filename) !== (m.enabled !== false);
|
||||||
|
zeile.innerHTML = `<div class="ms-icon">${m.iconUrl ? `<img src="${escapeHtml(m.iconUrl)}" alt="" />` : '🧩'}</div>
|
||||||
|
<div class="ms-info"><div class="ms-name">${escapeHtml(m.title || m.filename)}${geaendert ? '<span class="ms-neu">ab nächstem Start</span>' : ''}</div>
|
||||||
|
<div class="ms-meta">${escapeHtml([m.versionNumber && m.versionNumber !== 'Importiert' ? m.versionNumber : '', m.filename].filter(Boolean).join(' · '))}</div>
|
||||||
|
<div class="ms-warn"></div></div>
|
||||||
|
<button class="ms-pfeil" tabindex="-1">${istAn ? '›' : '‹'}</button>`;
|
||||||
|
const img = zeile.querySelector('img');
|
||||||
|
if (img) img.addEventListener('error', () => { img.parentElement.textContent = '🧩'; });
|
||||||
|
zeile.addEventListener('click', () => schalteMod(m, !istAn, zeile));
|
||||||
|
box.appendChild(zeile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const geaendert = msStart ? msDaten.mods.filter((m) => msStart.get(m.filename) !== (m.enabled !== false)).length : 0;
|
||||||
|
el('ms-neustart').classList.toggle('hidden', !(msDaten.laeuft && geaendert));
|
||||||
|
const z = msDaten.ziel;
|
||||||
|
el('ms-neustart').title = z && z.quickPlayServer
|
||||||
|
? `Minecraft speichert, startet mit den neuen Mods neu und verbindet wieder mit ${z.quickPlayServer}`
|
||||||
|
: z && z.quickPlayWelt && msDaten.quickPlayWelt
|
||||||
|
? `Minecraft speichert, startet mit den neuen Mods neu und öffnet wieder die Welt „${z.quickPlayWelt}“`
|
||||||
|
: 'Minecraft speichert und startet mit den neuen Mods neu (ins Hauptmenü)';
|
||||||
|
el('ms-status').textContent = geaendert
|
||||||
|
? (msDaten.laeuft
|
||||||
|
? `${geaendert} Änderung${geaendert === 1 ? '' : 'en'} – gilt ab dem nächsten Start (das Spiel läuft weiter)`
|
||||||
|
+ (z && z.quickPlayServer ? ` · Neustart verbindet wieder mit ${z.quickPlayServer}` : z && z.quickPlayWelt && msDaten.quickPlayWelt ? ` · Neustart öffnet wieder „${z.quickPlayWelt}“` : '')
|
||||||
|
: `${geaendert} Änderung${geaendert === 1 ? '' : 'en'} übernommen`)
|
||||||
|
: 'Klicke eine Mod an, um sie in die andere Liste zu verschieben.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schalteMod(m, an, zeile) {
|
||||||
|
if (!an) {
|
||||||
|
// Warnen, wenn andere Mods diese brauchen – verschoben wird trotzdem
|
||||||
|
const brauchen = await window.api.modDependents(msInstanz, m.filename, 'mod').catch(() => []);
|
||||||
|
if (brauchen && brauchen.length) toast(`${m.title || m.filename} wird gebraucht von: ${brauchen.slice(0, 3).join(', ')}${brauchen.length > 3 ? ' …' : ''}`, true);
|
||||||
|
}
|
||||||
|
if (zeile) zeile.style.pointerEvents = 'none';
|
||||||
|
const res = await window.api.modSetEnabled(msInstanz, m.filename, an, 'mod');
|
||||||
|
if (!res.ok) toast(res.message || 'Umschalten fehlgeschlagen.', true);
|
||||||
|
await ladeModschalter(m.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliesseModschalter() { window.api.modschalterSchliessen(); }
|
||||||
|
|
||||||
|
async function modschalterNeustart() {
|
||||||
|
el('ms-neustart').disabled = true;
|
||||||
|
el('ms-status').textContent = 'Minecraft speichert und startet neu …';
|
||||||
|
const res = await window.api.modschalterNeustart(msInstanz);
|
||||||
|
if (!res.ok) { el('ms-neustart').disabled = false; toast(res.message || 'Neustart nicht möglich.', true); }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Spielzeit-Statistik ----------
|
// ---------- Spielzeit-Statistik ----------
|
||||||
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
|
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
|
||||||
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
|
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
|
||||||
@@ -5704,6 +5860,7 @@ async function refreshInstalled() {
|
|||||||
const kennzeichen = [];
|
const kennzeichen = [];
|
||||||
if (up) kennzeichen.push('<span class="mod-new">Update</span>');
|
if (up) kennzeichen.push('<span class="mod-new">Update</span>');
|
||||||
if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
|
if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
|
||||||
|
if (m.ausstehend) kennzeichen.push('<span class="mod-new" title="Das Spiel läuft – die Änderung wird beim Beenden übernommen">ab nächstem Start</span>');
|
||||||
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
|
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
|
||||||
// OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf
|
// OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf
|
||||||
// deren Downloads) – von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf.
|
// deren Downloads) – von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf.
|
||||||
@@ -5733,7 +5890,7 @@ async function refreshInstalled() {
|
|||||||
schalter.addEventListener('click', async () => {
|
schalter.addEventListener('click', async () => {
|
||||||
schalter.disabled = true;
|
schalter.disabled = true;
|
||||||
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt);
|
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt);
|
||||||
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title);
|
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title + (res.ausstehend ? ' – gilt ab dem nächsten Start' : ''));
|
||||||
else toast(res.message || 'Umschalten fehlgeschlagen.', true);
|
else toast(res.message || 'Umschalten fehlgeschlagen.', true);
|
||||||
await refreshInstalled();
|
await refreshInstalled();
|
||||||
});
|
});
|
||||||
@@ -5981,7 +6138,9 @@ async function openSettings(pane) {
|
|||||||
el('s-friends').checked = s.friendsEnabled !== false;
|
el('s-friends').checked = s.friendsEnabled !== false;
|
||||||
el('s-friends-server').checked = s.friendsShareServer !== false;
|
el('s-friends-server').checked = s.friendsShareServer !== false;
|
||||||
el('s-auto-mod-updates').checked = !!s.autoModUpdates;
|
el('s-auto-mod-updates').checked = !!s.autoModUpdates;
|
||||||
|
el('s-mod-schalter').checked = s.modSchalter !== false;
|
||||||
el('s-aeromc-capes').checked = s.aeromcCapes !== false;
|
el('s-aeromc-capes').checked = s.aeromcCapes !== false;
|
||||||
|
el('s-aeromc-design').checked = s.aeromcDesign !== false;
|
||||||
el('s-autostart').checked = !!s.autostart;
|
el('s-autostart').checked = !!s.autostart;
|
||||||
el('s-auto-java').checked = s.autoJava !== false;
|
el('s-auto-java').checked = s.autoJava !== false;
|
||||||
el('s-autobackup').checked = !!s.autoBackupEnabled;
|
el('s-autobackup').checked = !!s.autoBackupEnabled;
|
||||||
@@ -6117,7 +6276,9 @@ async function saveSettings() {
|
|||||||
friendsEnabled: el('s-friends').checked,
|
friendsEnabled: el('s-friends').checked,
|
||||||
friendsShareServer: el('s-friends-server').checked,
|
friendsShareServer: el('s-friends-server').checked,
|
||||||
autoModUpdates: el('s-auto-mod-updates').checked,
|
autoModUpdates: el('s-auto-mod-updates').checked,
|
||||||
|
modSchalter: el('s-mod-schalter').checked,
|
||||||
aeromcCapes: el('s-aeromc-capes').checked,
|
aeromcCapes: el('s-aeromc-capes').checked,
|
||||||
|
aeromcDesign: el('s-aeromc-design').checked,
|
||||||
autostart: el('s-autostart').checked,
|
autostart: el('s-autostart').checked,
|
||||||
autoJava: el('s-auto-java').checked,
|
autoJava: el('s-auto-java').checked,
|
||||||
autoBackupEnabled: el('s-autobackup').checked,
|
autoBackupEnabled: el('s-autobackup').checked,
|
||||||
@@ -6950,6 +7111,7 @@ function wire() {
|
|||||||
|
|
||||||
// Rechte Aktionsleiste (Instanz-Aktionen)
|
// Rechte Aktionsleiste (Instanz-Aktionen)
|
||||||
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
||||||
|
el('p-wiederherstellen').addEventListener('click', wiederherstellenAusSicherung);
|
||||||
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
||||||
el('p-shortcut').addEventListener('click', async () => {
|
el('p-shortcut').addEventListener('click', async () => {
|
||||||
if (!currentDetailId) return;
|
if (!currentDetailId) return;
|
||||||
@@ -7457,8 +7619,8 @@ function wireFileDrop() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth = 0;
|
dragDepth = 0;
|
||||||
document.body.classList.remove('file-drop-active');
|
document.body.classList.remove('file-drop-active');
|
||||||
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path);
|
const pfade = [...(e.dataTransfer.files || [])].map((f) => window.api.pfadVonDatei(f)).filter(Boolean);
|
||||||
for (const f of files) await handleDroppedFile(f.path);
|
for (const pfad of pfade) await handleDroppedFile(pfad);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7523,6 +7685,7 @@ const POPOUT_OPENERS = {
|
|||||||
record: () => openRecording(),
|
record: () => openRecording(),
|
||||||
friends: () => openFriends(),
|
friends: () => openFriends(),
|
||||||
stats: (p) => openStats(p.reiter),
|
stats: (p) => openStats(p.reiter),
|
||||||
|
modschalter: (p) => openModschalter(p.instanceId),
|
||||||
};
|
};
|
||||||
|
|
||||||
function popoutBoot(params) {
|
function popoutBoot(params) {
|
||||||
@@ -7612,6 +7775,23 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
await refreshSkinBackground();
|
await refreshSkinBackground();
|
||||||
window.api.onLaunchProgress(onLaunchEvt);
|
window.api.onLaunchProgress(onLaunchEvt);
|
||||||
window.api.onErfolgeNeu(zeigeErfolgMeldung);
|
window.api.onErfolgeNeu(zeigeErfolgMeldung);
|
||||||
|
// Mod-Schalter
|
||||||
|
el('ms-suche').addEventListener('input', () => msDaten && zeichneModschalter());
|
||||||
|
el('ms-x').addEventListener('click', schliesseModschalter);
|
||||||
|
el('ms-fertig').addEventListener('click', schliesseModschalter);
|
||||||
|
el('ms-neustart').addEventListener('click', modschalterNeustart);
|
||||||
|
document.addEventListener('keydown', (e) => { if (POPOUT === 'modschalter' && e.key === 'Escape') schliesseModschalter(); });
|
||||||
|
// Neustart nach dem Mod-Schalter: wieder in dieselbe Welt bzw. auf denselben Server
|
||||||
|
if (!POPOUT) {
|
||||||
|
window.api.onModschalterNeustarten(({ id, ziel }) => {
|
||||||
|
if (!instances.some((i) => i.id === id)) return;
|
||||||
|
const wohin = ziel && ziel.quickPlayServer ? ` und verbindet wieder mit ${ziel.quickPlayServer}` : ziel && ziel.quickPlayWelt ? ` in die Welt „${ziel.quickPlayWelt}“` : '';
|
||||||
|
toast(`Mods übernommen – Minecraft startet neu${wohin} …`);
|
||||||
|
const opts = ziel && (ziel.quickPlayServer || ziel.quickPlayWelt)
|
||||||
|
? { quickPlayServer: ziel.quickPlayServer || '', quickPlayWelt: ziel.quickPlayWelt || '' } : null;
|
||||||
|
launch(id, (ziel && ziel.kontoId) || null, opts);
|
||||||
|
});
|
||||||
|
}
|
||||||
el('upd-notes').addEventListener('click', updateLinkKlick);
|
el('upd-notes').addEventListener('click', updateLinkKlick);
|
||||||
window.api.onLaufendeInstanzen(setzeLaufende);
|
window.api.onLaufendeInstanzen(setzeLaufende);
|
||||||
window.api.laufendeInstanzen().then(setzeLaufende).catch(() => {});
|
window.api.laufendeInstanzen().then(setzeLaufende).catch(() => {});
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
|
|||||||
versionNumber: v.version_number,
|
versionNumber: v.version_number,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
hashes: mrHashes(file),
|
||||||
sha1: file.hashes && file.hashes.sha1,
|
sha1: file.hashes && file.hashes.sha1,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dependencies: v.dependencies || [],
|
dependencies: v.dependencies || [],
|
||||||
@@ -324,6 +325,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
|
|||||||
versionNumber: v.version_number,
|
versionNumber: v.version_number,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
hashes: mrHashes(file),
|
||||||
sha1: file.hashes && file.hashes.sha1,
|
sha1: file.hashes && file.hashes.sha1,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dependencies: v.dependencies || [],
|
dependencies: v.dependencies || [],
|
||||||
@@ -350,23 +352,58 @@ async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
|
|||||||
name: v.name || v.version_number,
|
name: v.name || v.version_number,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
hashes: mrHashes(file),
|
||||||
size: file.size || 0,
|
size: file.size || 0,
|
||||||
gameVersions: v.game_versions || [],
|
gameVersions: v.game_versions || [],
|
||||||
loaders: v.loaders || [],
|
loaders: v.loaders || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadBuffer(url, extraHeaders) {
|
/*
|
||||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
* Prüfsumme eines Downloads kontrollieren. erwartet: { sha512?, sha1? } – die
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
* Werte stammen aus der Modrinth- bzw. CurseForge-API. Ohne Angabe wird nichts
|
||||||
return Buffer.from(await res.arrayBuffer());
|
* geprüft (z. B. Dateien, zu denen die Quelle keine Prüfsumme nennt).
|
||||||
|
*/
|
||||||
|
function pruefeDownload(buf, erwartet, name) {
|
||||||
|
if (!erwartet) return;
|
||||||
|
for (const algo of ['sha512', 'sha256', 'sha1']) {
|
||||||
|
const soll = erwartet[algo] && String(erwartet[algo]).toLowerCase();
|
||||||
|
if (!soll) continue;
|
||||||
|
const ist = require('crypto').createHash(algo).update(buf).digest('hex');
|
||||||
|
if (ist !== soll) {
|
||||||
|
throw new Error(`Prüfsumme von ${name || 'Download'} stimmt nicht – Datei verworfen (beschädigt oder manipuliert).`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
// CurseForge liefert Prüfsummen als Liste: algo 1 = SHA-1, 2 = MD5
|
||||||
async function downloadFile(url, destPath, extraHeaders) {
|
function cfHashes(datei) {
|
||||||
|
const sha1 = ((datei && datei.hashes) || []).find((h) => h.algo === 1 && h.value);
|
||||||
|
return sha1 ? { sha1: sha1.value } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modrinth: { sha512, sha1 }
|
||||||
|
function mrHashes(datei) {
|
||||||
|
const h = (datei && datei.hashes) || {};
|
||||||
|
return h.sha512 || h.sha1 ? { sha512: h.sha512, sha1: h.sha1 } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBuffer(url, extraHeaders, erwartet) {
|
||||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
pruefeDownload(buf, erwartet, decodeURIComponent(String(url).split('/').pop() || ''));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
||||||
|
// erwartet: Prüfsummen (siehe pruefeDownload) – geschrieben wird nur, was passt
|
||||||
|
async function downloadFile(url, destPath, extraHeaders, erwartet) {
|
||||||
|
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||||
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
pruefeDownload(buf, erwartet, path.basename(destPath));
|
||||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||||
fs.writeFileSync(destPath, buf);
|
fs.writeFileSync(destPath, buf);
|
||||||
return buf.length;
|
return buf.length;
|
||||||
@@ -383,7 +420,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir,
|
|||||||
: `Keine passende Version für ${gameVersion} gefunden.` };
|
: `Keine passende Version für ${gameVersion} gefunden.` };
|
||||||
}
|
}
|
||||||
const dest = path.join(modsDir, file.filename);
|
const dest = path.join(modsDir, file.filename);
|
||||||
const bytes = await downloadFile(file.url, dest);
|
const bytes = await downloadFile(file.url, dest, null, file.hashes);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
dependencies: file.dependencies || [],
|
dependencies: file.dependencies || [],
|
||||||
@@ -738,6 +775,7 @@ async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
|
|||||||
fileId: match.id,
|
fileId: match.id,
|
||||||
filename: name,
|
filename: name,
|
||||||
url: dl,
|
url: dl,
|
||||||
|
hashes: cfHashes(match),
|
||||||
versionNumber: match.displayName || match.fileName || String(match.id),
|
versionNumber: match.displayName || match.fileName || String(match.id),
|
||||||
dependencies: match.dependencies || [],
|
dependencies: match.dependencies || [],
|
||||||
};
|
};
|
||||||
@@ -768,7 +806,7 @@ async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader
|
|||||||
// Download: direkt mit Key oder über Weiterleitung
|
// Download: direkt mit Key oder über Weiterleitung
|
||||||
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
||||||
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
|
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
|
||||||
const bytes = await downloadFile(url, dest, headers);
|
const bytes = await downloadFile(url, dest, headers, file.hashes);
|
||||||
|
|
||||||
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
|
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
|
||||||
const depIds = (file.dependencies || [])
|
const depIds = (file.dependencies || [])
|
||||||
@@ -826,6 +864,7 @@ async function curseforgeResolveFiles(zugang, files, onProgress) {
|
|||||||
map.set(Number(d.id), {
|
map.set(Number(d.id), {
|
||||||
fileName: d.fileName,
|
fileName: d.fileName,
|
||||||
url: zugang.downloadAdresse(roh),
|
url: zugang.downloadAdresse(roh),
|
||||||
|
hashes: cfHashes(d),
|
||||||
kopfzeilen: zugang.downloadKopfzeilen,
|
kopfzeilen: zugang.downloadKopfzeilen,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||