Upload via GUI (194 Dateien)

This commit is contained in:
2026-10-07 07:32:02 +00:00
parent 7e7a2021ed
commit 3ac5a6c14d
20 changed files with 2196 additions and 489 deletions
+541 -23
View File
@@ -7,6 +7,29 @@
*/
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen, globalShortcut } = require('electron');
// Vom Setup am Ende der Installation einmal aufgerufen: nur starten und sofort
// wieder beenden. Windows prüft die neue EXE dabei (dauert beim ersten Start
// mehrere Sekunden) – so passiert das während des Fortschrittsbalkens und der
// echte Start nach "Fertigstellen" ist gleich da. Siehe installer/Installer.cs.
if (process.argv.includes('--aeromc-vorwaermen')) process.exit(0);
// Sicherheit: Die fertige App nimmt keine Debug-Schalter an. Mit
// --remote-debugging-port oder --inspect könnte jedes andere Programm den
// Launcher fernsteuern (Konten, Instanzen, Befehle). Beim Entwickeln (npm start)
// bleiben sie erlaubt. Die Node-Varianten (--inspect, NODE_OPTIONS,
// ELECTRON_RUN_AS_NODE) sperren zusätzlich die Electron-Fuses, siehe
// "electronFuses" in package.json (dort auch die Integritätsprüfung von app.asar).
if (app.isPackaged) {
const verboten = ['remote-debugging-port', 'remote-debugging-pipe', 'remote-debugging-address', 'inspect', 'inspect-brk', 'inspect-port', 'js-flags'];
const gesetzt = verboten.find((s) => app.commandLine.hasSwitch(s))
|| process.argv.slice(1).find((a) => /^--(remote-debugging|inspect)/i.test(a));
if (gesetzt) {
console.error('AeroMC: Debug-Schalter sind in der fertigen App nicht erlaubt (' + gesetzt + ').');
process.exit(1);
}
}
const path = require('path');
const fs = require('fs');
const os = require('os');
@@ -26,6 +49,63 @@ const loaders = require('./loaders');
const forge = require('./forge');
const auth = require('./auth');
const updater = require('./updater');
const integritaet = require('./integritaet');
/*
* Manipulationsschutz (siehe integritaet.js): Kanäle, über die der Launcher
* selbst Mod-Dateien überschreibt oder Startdaten ändert. Vorher wird geprüft
* (eine schon manipulierte Instanz darf nicht "mitversiegelt" werden), danach
* das Siegel auf den neuen Stand gebracht. Gesperrte Instanzen lassen sich
* darüber gar nicht mehr verändern.
*/
const SIEGEL_KANAELE = new Set([
'mods:install', 'mods:addFile', 'mods:applyUpdates', 'mods:leistungsPaket',
'instances:installPerformancePack', 'instances:update', 'modpack:update',
]);
const GESPERRT_TEXT = 'Diese Instanz ist gesperrt, weil sie verändert wurde. Bitte aus einer Sicherung wiederherstellen und die gesperrte Instanz löschen.';
{
const handleOriginal = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (kanal, fn) => handleOriginal(kanal, !SIEGEL_KANAELE.has(kanal) ? fn : async (e, id, ...rest) => {
const vorher = await integritaet.pruefe(id);
if (vorher.status === 'gesperrt') {
if (vorher.neuGesperrt) meldeGesperrt(id);
return { ok: false, message: GESPERRT_TEXT };
}
try {
return await fn(e, id, ...rest);
} finally {
await integritaet.versiegele(id).catch(() => {});
}
});
}
// mit Gesperrt-Status für die Anzeige
function mitSperre(inst) {
return inst ? Object.assign({}, inst, { gesperrt: integritaet.status(inst.id) }) : inst;
}
function meldeGesperrt(id) {
broadcast('instances:changed');
const inst = store.getInstance(id);
try {
new Notification({
title: 'AeroMC – Instanz gesperrt',
body: `"${(inst && inst.name) || id}" wurde außerhalb des Launchers verändert und ist gesperrt.`,
icon: benachrichtigungsLogo || undefined,
}).show();
} catch { /* nur Hinweis */ }
}
// Beim Launcher-Start alle Instanzen im Hintergrund prüfen (schnell: nur
// geänderte Dateien werden neu gehasht). Beim Spielstart wird gründlich geprüft.
async function pruefeAlleInstanzen() {
for (const inst of store.listInstances()) {
try {
const r = await integritaet.pruefe(inst.id, { schnell: true });
if (r.neuGesperrt) meldeGesperrt(inst.id);
} catch (err) { console.warn('Integritätsprüfung', inst.id, err.message); }
}
}
const icons = require('./icons');
const bedrock = require('./bedrock');
const backup = require('./backup');
@@ -33,6 +113,8 @@ const mmcimport = require('./mmcimport');
const kopieren = require('./kopieren');
const moderkennung = require('./moderkennung');
const garderobe = require('./garderobe');
const spieldesign = require('./spieldesign');
const modschalter = require('./modschalter');
const erfolge = require('./erfolge');
const cfimport = require('./cfimport');
const mrimport = require('./mrimport');
@@ -602,6 +684,35 @@ ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
// aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V.
// Gilt für Haupt- und alle Popup-Fenster.
app.on('web-contents-created', (_e, contents) => {
// Sicherheit: Kein Fenster darf je eine fremde Seite laden. Die Fenster haben
// über preload.js Zugriff auf window.api – eine Webseite im selben Fenster
// hätte damit die volle Kontrolle über den Launcher. Links nach außen gehen
// deshalb immer in den Standardbrowser, alles andere wird verworfen.
const eigeneSeite = (adresse) => {
try {
const u = new URL(adresse);
if (u.protocol !== 'file:') return false;
const datei = path.normalize(decodeURIComponent(u.pathname.replace(/^\/([a-z]:)/i, '$1')));
return !path.relative(__dirname, datei).startsWith('..');
} catch { return false; }
};
const nachAussen = (adresse) => {
if (/^https?:\/\//i.test(String(adresse || ''))) shell.openExternal(adresse).catch(() => {});
};
contents.on('will-navigate', (ev, adresse) => {
if (eigeneSeite(adresse)) return;
ev.preventDefault();
nachAussen(adresse);
});
contents.on('will-redirect', (ev, adresse) => {
if (!eigeneSeite(adresse) && !String(adresse).startsWith('data:')) ev.preventDefault();
});
contents.setWindowOpenHandler(({ url: adresse }) => {
nachAussen(adresse);
return { action: 'deny' };
});
contents.on('will-attach-webview', (ev) => ev.preventDefault());
contents.on('context-menu', (_ev, params) => {
const auswahl = String(params.selectionText || '').trim();
if (!params.isEditable && !auswahl) return;
@@ -956,11 +1067,16 @@ app.whenReady().then(() => {
moderkennung.init(app.getPath('userData'));
garderobe.init(app.getPath('userData'));
erfolge.init(app.getPath('userData'));
auth.init(app.getPath('userData'), {
const tresor = {
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
});
};
auth.init(app.getPath('userData'), tresor);
try { integritaet.init(app.getPath('userData'), tresor, store); } catch (err) { console.warn('Manipulationsschutz:', err.message); }
setTimeout(() => { pruefeAlleInstanzen().catch(() => {}); }, 8000);
// Proxy-Passwort und API-Schlüssel ebenso (siehe store.setzeTresor)
store.setzeTresor(tresor);
applyProxy(store.getSettings());
discordpresence.configure(store.getSettings());
const autostartAn = !!store.getSettings().autostart;
@@ -1144,7 +1260,11 @@ ipcMain.handle('mods:search', async (_e, opts) => {
// Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
async function modsMitErkennung(id, art) {
const liste = store.listMods(id, art);
let liste = store.listMods(id, art);
if ((art || 'mod') === 'mod') {
const aus = ausstehendeMods(id);
liste = liste.map((m) => (m.filename in aus ? Object.assign({}, m, { enabled: !!aus[m.filename], ausstehend: true }) : m));
}
try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); }
catch { return liste; }
}
@@ -1806,6 +1926,12 @@ ipcMain.handle('crash:openFolder', (_e, id) => {
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
// Spiel läuft: Datei ist gesperrt -> vormerken, beim Beenden umsetzen
if ((art || 'mod') === 'mod' && laufendeInstanzen.has(id)) {
merkeModAenderung(id, filename, !!enabled);
broadcast('mods:changed', { id });
return { ok: true, enabled: !!enabled, ausstehend: true };
}
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
if (res.ok) return res;
const ordner = services.inhaltsArt(art || 'mod').ordner;
@@ -2227,15 +2353,22 @@ ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
function umhangAgent() {
try {
// Die Kopie im Datenordner läuft mit vollen Rechten im Spiel mit. Deshalb vor
// jedem Start Byte für Byte mit dem Original im Paket vergleichen – eine
// ausgetauschte oder veränderte Datei wird überschrieben, nie geladen.
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
const groesse = fs.statSync(quelle).size;
const inhalt = fs.readFileSync(quelle);
const kennung = require('crypto').createHash('sha256').update(inhalt).digest('hex').slice(0, 16);
const ordner = path.join(app.getPath('userData'), 'agent');
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
const ziel = path.join(ordner, `aeromc-capes-${kennung}.jar`);
let vorhanden = null;
try { vorhanden = fs.readFileSync(ziel); } catch { /* noch nicht da */ }
if (!vorhanden || !vorhanden.equals(inhalt)) {
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(ziel, fs.readFileSync(quelle));
fs.writeFileSync(ziel, inhalt);
if (!fs.readFileSync(ziel).equals(inhalt)) throw new Error('Agent-Kopie weicht vom Original ab.');
for (const alt of fs.readdirSync(ordner)) {
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
if (alt !== path.basename(ziel) && /^aeromc-capes-[0-9a-f]+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
}
}
return `${ziel}=${friends.BASIS_URL}`;
@@ -2245,6 +2378,289 @@ function umhangAgent() {
}
}
// -javaagent-Argument: Umhänge und/oder AeroMC-Look im Spiel (siehe agent/Agent.java)
function agentArgument(settings, version, modsAdresse) {
const umhaenge = settings.aeromcCapes !== false;
const design = settings.aeromcDesign !== false && spieldesign.unterstuetzt(version)
? spieldesign.agentOrdner(app.getPath('userData')) : '';
if (!umhaenge && !design && !modsAdresse) return '';
const agent = umhangAgent();
if (!agent) return '';
return agent + (umhaenge ? '' : '|capes=0') + (design ? '|design=' + design : '') + (modsAdresse ? '|mods=' + modsAdresse : '');
}
// ---------- Mod-Schalter im Spiel (siehe modschalter.js) ----------
// Während Minecraft läuft, sind die Mod-Dateien gesperrt: Änderungen werden in
// instance.json vorgemerkt und beim Beenden umgesetzt.
const modFenster = new Map(); // instanceId -> BrowserWindow
const neustartNachBeenden = new Set(); // instanceId -> nach dem Beenden direkt wieder starten
const letzteVerbindungZeit = new Map(); // instanceId -> Zeitpunkt der letzten Server-Verbindung
const sitzungsInfo = new Map(); // instanceId -> { start, kontoId } der laufenden Sitzung
// Wohin der Neustart führt: zuletzt verbundener Server oder zuletzt gespeicherte
// Welt dieser Sitzung – je nachdem, was jünger ist – samt Konto der Sitzung
function neustartZiel(id) {
const info = sitzungsInfo.get(id) || {};
const seit = info.start || 0;
const ziel = { kontoId: info.kontoId || null };
const welt = seit ? zuletztGespielteWelt(id, seit) : null;
const serverZeit = letzteVerbindungZeit.get(id) || 0;
const server = letzteQuickPlayServer.get(id);
if (server && serverZeit >= seit && (!welt || serverZeit > welt.zeit)) ziel.quickPlayServer = server;
else if (welt) ziel.quickPlayWelt = welt.name;
return ziel;
}
function ausstehendeMods(id) {
const inst = store.getInstance(id);
return (inst && inst.modsAusstehend) || {};
}
function merkeModAenderung(id, filename, an) {
const aus = Object.assign({}, ausstehendeMods(id));
const mod = store.listMods(id, 'mod').find((m) => m.filename === filename);
if (mod && mod.enabled === an) delete aus[filename]; // zurück auf den Stand der Datei
else aus[filename] = an;
store.updateInstance(id, { modsAusstehend: aus });
return Object.keys(aus).length;
}
function wendeAusstehendeModsAn(id) {
const aus = ausstehendeMods(id);
const namen = Object.keys(aus);
if (!namen.length) return { umgesetzt: 0, offen: 0 };
const rest = {};
let umgesetzt = 0;
for (const f of namen) {
const r = store.setModEnabled(id, f, !!aus[f], 'mod');
if (r.ok) umgesetzt++;
else if (r.reason !== 'not-found' && r.reason !== 'both-exist' && r.reason !== 'bad-name') rest[f] = aus[f];
}
store.updateInstance(id, { modsAusstehend: rest });
broadcast('mods:changed', { id });
return { umgesetzt, offen: Object.keys(rest).length };
}
// Welt, die während der Sitzung zuletzt gespeichert wurde (level.dat neuer als der Start)
function zuletztGespielteWelt(id, seit) {
let beste = null;
try {
const savesDir = path.join(store.gameDir(id), 'saves');
for (const e of fs.readdirSync(savesDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
try {
const zeit = fs.statSync(path.join(savesDir, e.name, 'level.dat')).mtimeMs;
if (zeit >= seit - 5000 && (!beste || zeit > beste.zeit)) beste = { name: e.name, zeit };
} catch { /* keine Welt */ }
}
} catch { /* kein saves-Ordner */ }
return beste;
}
async function oeffneModFenster(sitzung) {
const { instanceId: id, pid } = sitzung;
const mc = await findeMinecraftFenster(pid);
const b = mc ? mc.getBounds() : null;
const breite = b ? Math.max(640, Math.min(980, Math.round(b.width * 0.9))) : 900;
const hoehe = b ? Math.max(460, Math.min(680, Math.round(b.height * 0.9))) : 620;
const lage = b ? { x: Math.round(b.x + (b.width - breite) / 2), y: Math.round(b.y + (b.height - hoehe) / 2) } : {};
let win = modFenster.get(id);
if (win && !win.isDestroyed()) {
win.setBounds(Object.assign({ width: breite, height: hoehe }, lage));
win.webContents.send('popout:reopen', { instanceId: id });
win.show();
win.focus();
return;
}
win = new BrowserWindow(Object.assign({
width: breite, height: hoehe, minWidth: 560, minHeight: 400,
title: 'AeroMC – Mods', backgroundColor: fensterFarbe(), icon: windowIcon(),
frame: false, show: false, skipTaskbar: true, alwaysOnTop: true,
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false },
}, lage));
win.setAlwaysOnTop(true, 'screen-saver');
modFenster.set(id, win);
win.loadFile(path.join(__dirname, 'index.html'), { query: { popout: 'modschalter', instanceId: id } });
win.once('ready-to-show', () => { win.show(); win.focus(); });
// nach dem Schließen gleich wieder ins Spiel
const zurueck = async () => {
const f = await findeMinecraftFenster(pid);
if (f) { try { f.bringToTop(); } catch { /* egal */ } }
};
win.on('hide', zurueck);
win.on('closed', () => { modFenster.delete(id); zurueck(); });
}
modschalter.beiKlick((sitzung) => { oeffneModFenster(sitzung).catch((err) => console.error('Mod-Fenster:', err)); });
ipcMain.handle('modschalter:daten', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
return {
ok: true,
name: inst.name,
version: inst.minecraft.version,
loader: inst.minecraft.loader,
laeuft: laufendeInstanzen.has(id),
ziel: laufendeInstanzen.has(id) ? neustartZiel(id) : null,
quickPlayWelt: spieldesign.paketFormat(inst.minecraft.version) >= 15, // Welt direkt öffnen geht ab 1.20
mods: await modsMitErkennung(id, 'mod'),
};
});
// ---------- Neustart-Bildschirm (neustart.html) ----------
// Liegt über dem Spielfenster, während Minecraft beendet, die Mods umgestellt
// und das Spiel neu gestartet wird – bis man wieder auf dem Server bzw. in der
// Welt ist (Minecraft hängt dann "Mehrspieler"/"Einzelspieler" an den Fenstertitel).
const neustartBild = new Map(); // instanceId -> { win, ziel, folger, notfall, fensterSeit, verbundenSeit }
const START_TEXTE = {
version: 'Minecraft wird vorbereitet', client: 'Minecraft wird vorbereitet', libraries: 'Bibliotheken werden geprüft',
assets: 'Spieldaten werden geprüft', loader: 'Mod-Loader wird vorbereitet', java: 'Java wird vorbereitet',
};
const START_ANTEILE = { version: [0, 6], client: [6, 14], libraries: [14, 45], assets: [45, 80], loader: [80, 90], java: [90, 96] };
// Fensterrechteck (physische Pixel) -> Electron-Koordinaten
function alsDip(r) {
try { return screen.screenToDipRect(null, r); } catch { return r; }
}
async function zeigeNeustartBild(id, pid) {
schliesseNeustartBild(id, true);
const mc = await findeMinecraftFenster(pid);
// Fensterzustand merken – Minecraft selbst weiß nach dem Neustart nicht mehr,
// dass es maximiert war, und käme in der Standardgröße wieder
let altesFenster = null;
if (mc) {
try {
const roh = mc.getBounds();
const dip = alsDip(roh);
const wa = screen.getDisplayMatching(dip).workArea;
const maximiert = dip.x <= wa.x && dip.y <= wa.y && dip.x + dip.width >= wa.x + wa.width && dip.y + dip.height >= wa.y + wa.height;
altesFenster = { bounds: roh, maximiert };
} catch { /* ohne Wiederherstellung */ }
}
let b = mc ? alsDip(mc.getBounds()) : null;
if (!b || b.width < 200 || b.height < 150) {
const a = screen.getPrimaryDisplay().workArea;
b = { x: Math.round(a.x + (a.width - 960) / 2), y: Math.round(a.y + (a.height - 540) / 2), width: 960, height: 540 };
}
const win = new BrowserWindow({
x: b.x, y: b.y, width: b.width, height: b.height,
frame: false, resizable: false, skipTaskbar: true, alwaysOnTop: true, focusable: false, show: false,
backgroundColor: '#0a0e1a', icon: windowIcon(),
webPreferences: { contextIsolation: true, sandbox: true },
});
win.setAlwaysOnTop(true, 'screen-saver');
win.loadFile(path.join(__dirname, 'neustart.html'));
win.once('ready-to-show', () => { if (!win.isDestroyed()) win.showInactive(); });
const eintrag = { win, ziel: null, folger: null, fensterSeit: 0, verbundenSeit: 0, altesFenster };
// Sicherheitsnetz: nie länger als 3 Minuten stehen lassen
eintrag.notfall = setTimeout(() => schliesseNeustartBild(id), 180000);
neustartBild.set(id, eintrag);
}
function neustartStatus(id, text, prozent) {
const e = neustartBild.get(id);
if (!e || e.win.isDestroyed()) return;
const js = `typeof setze === 'function' && setze(${JSON.stringify({ text, prozent: typeof prozent === 'number' ? prozent : null })})`;
if (e.win.webContents.isLoading()) e.win.webContents.once('did-finish-load', () => e.win.webContents.executeJavaScript(js).catch(() => {}));
else e.win.webContents.executeJavaScript(js).catch(() => {});
}
function schliesseNeustartBild(id, sofort) {
const e = neustartBild.get(id);
if (!e) return;
neustartBild.delete(id);
clearTimeout(e.notfall);
clearInterval(e.folger);
if (e.win.isDestroyed()) return;
if (sofort) { e.win.destroy(); return; }
e.win.webContents.executeJavaScript('typeof ausblenden === "function" && ausblenden()').catch(() => {});
setTimeout(() => { if (!e.win.isDestroyed()) e.win.destroy(); }, 420);
}
// Startfortschritt des Neustarts anzeigen (aus dem launch:progress der neuen Sitzung)
function neustartFortschritt(id, evt) {
if (!neustartBild.has(id)) return;
if (START_TEXTE[evt.phase]) {
const [von, bis] = START_ANTEILE[evt.phase];
const anteil = evt.total ? Math.min(1, evt.current / evt.total) : 0;
const prozent = Math.round(von + (bis - von) * anteil);
neustartStatus(id, `${START_TEXTE[evt.phase]} … ${prozent} %`, prozent);
} else if (evt.phase === 'launch') {
neustartStatus(id, 'Minecraft startet …', null);
}
}
// dem neuen Spielfenster folgen und ausblenden, sobald man wieder drin ist
function folgeNeuemSpiel(id, pid) {
const e = neustartBild.get(id);
if (!e) return;
let windowManager = null;
try { ({ windowManager } = require('node-window-manager')); } catch { /* ohne Fensterverfolgung */ }
const ziel = e.ziel || {};
e.folger = setInterval(() => {
if (e.win.isDestroyed()) { clearInterval(e.folger); return; }
let fenster = null;
// nur das Spielfenster – Java hat daneben u. a. ein (verstecktes) Konsolenfenster
try { fenster = windowManager && windowManager.getWindows().find((w) => w.processId === pid && /Minecraft/i.test(w.getTitle() || '') && w.isVisible()); } catch { /* weiter versuchen */ }
if (!fenster) return;
const jetzt = Date.now();
if (!e.fensterSeit) {
e.fensterSeit = jetzt;
// Größe/Lage bzw. „maximiert“ wie vor dem Neustart – kurz darauf noch einmal,
// falls Minecraft die Fenstergröße beim Hochfahren selbst noch setzt
const herstellen = () => {
const alt = e.altesFenster;
if (!alt) return;
try { if (alt.maximiert) fenster.maximize(); else fenster.setBounds(alt.bounds); } catch { /* egal */ }
};
herstellen();
setTimeout(herstellen, 1500);
}
try {
const r = alsDip(fenster.getBounds());
if (r.width > 200 && r.height > 150) e.win.setBounds(r);
} catch { /* Fenster gerade im Umbau */ }
let titel = '';
try { titel = fenster.getTitle() || ''; } catch { /* egal */ }
const imSpiel = titel.replace(/^AeroMC - /, '').split(' - ').length >= 2;
if (!ziel.quickPlayServer && !ziel.quickPlayWelt) {
// kein Ziel: das Spiel zeigt ab jetzt seinen eigenen (AeroMC-)Ladebildschirm
if (jetzt - e.fensterSeit > 1500) schliesseNeustartBild(id);
return;
}
if (imSpiel) {
neustartStatus(id, 'Willkommen zurück!', 100);
setTimeout(() => schliesseNeustartBild(id), 500);
clearInterval(e.folger);
return;
}
if (!e.verbundenSeit) {
neustartStatus(id, ziel.quickPlayServer ? `Verbinde mit ${ziel.quickPlayServer} …` : `Welt „${ziel.quickPlayWelt}“ wird geladen …`, null);
}
// ältere Versionen (1.14) schreiben nichts in den Titel -> nach dem Verbinden kurz warten
if (e.verbundenSeit && jetzt - e.verbundenSeit > 9000) schliesseNeustartBild(id);
if (jetzt - e.fensterSeit > 90000) schliesseNeustartBild(id);
}, 400);
}
ipcMain.handle('modschalter:neustart', async (_e, id) => {
const pid = laufendePid.get(id);
if (!pid) return { ok: false, message: 'Das Spiel läuft nicht.' };
neustartNachBeenden.add(id);
const win = modFenster.get(id);
if (win && !win.isDestroyed()) win.destroy();
await zeigeNeustartBild(id, pid).catch(() => {});
neustartStatus(id, 'Minecraft wird beendet …', null);
// ohne /F: Windows bittet das Fenster ums Schließen – Minecraft speichert und beendet sich sauber
const { spawn } = require('child_process');
spawn('taskkill', ['/PID', String(pid)], { windowsHide: true }).on('error', () => {});
return { ok: true };
});
ipcMain.on('modschalter:schliessen', (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w && !w.isDestroyed()) w.destroy();
});
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
function umhangFehler(res) {
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
@@ -2376,7 +2792,9 @@ async function findeMinecraftFenster(pid) {
for (let versuch = 1; versuch <= 4; versuch++) {
let alleFenster;
try { alleFenster = windowManager.getWindows(); } catch { alleFenster = []; }
const treffer = alleFenster.find((w) => w.processId === pid);
// bevorzugt das sichtbare Spielfenster (Java hat daneben u. a. ein verstecktes Konsolenfenster)
const vomProzess = alleFenster.filter((w) => w.processId === pid);
const treffer = vomProzess.find((w) => { try { return w.isVisible() && /Minecraft/i.test(w.getTitle() || ''); } catch { return false; } }) || vomProzess[0];
if (treffer) return treffer;
if (versuch < 4) await new Promise((r) => setTimeout(r, 300));
}
@@ -2695,6 +3113,7 @@ function teilBeschreibung(id) {
}
ipcMain.handle('share:create', async (_e, id) => {
if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
const beschreibung = teilBeschreibung(id);
if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' };
@@ -3034,9 +3453,9 @@ ipcMain.handle('auth:login', async () => {
// IPC – Instanzen
// ---------------------------------------------------------------------------
ipcMain.handle('instances:list', () => store.listInstances());
ipcMain.handle('instances:list', () => store.listInstances().map(mitSperre));
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id));
ipcMain.handle('instances:get', (_e, id) => mitSperre(store.getInstance(id)));
ipcMain.handle('instances:create', (_e, data) => {
const res = store.createInstance(data);
@@ -3050,9 +3469,26 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
return res;
});
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
ipcMain.handle('instances:delete', (_e, id) => {
const res = store.deleteInstance(id);
integritaet.entferne(id);
return res;
});
// Gesperrte Instanz als neue Instanz aus der neuesten sauberen Sicherung holen
ipcMain.handle('integritaet:wiederherstellen', async (_e, id) => {
try {
const sicherungen = backup.listGlobalBackups(app.getPath('documents')).map((b) => b.path);
const res = await integritaet.stelleWiederHer(id, sicherungen);
broadcast('instances:changed');
return Object.assign({ ok: true }, res);
} catch (err) {
return { ok: false, message: err.message };
}
});
ipcMain.handle('instances:duplicate', async (_e, id) => {
if (integritaet.istGesperrt(id)) throw new Error(GESPERRT_TEXT);
const kopie = store.duplicateInstance(id, true);
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
catch { /* Quelle evtl. leer */ }
@@ -3145,6 +3581,7 @@ function runPwsh(command, env) {
}
ipcMain.handle('instances:export', async (_e, id) => {
if (integritaet.istGesperrt(id)) return { ok: false, message: GESPERRT_TEXT };
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_');
@@ -3325,11 +3762,24 @@ async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten })
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue;
broadcast('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
// Nur über HTTPS und nur mit passender Prüfsumme aus dem Paket: so landet
// keine unterwegs ausgetauschte oder manipulierte Mod im mods-Ordner.
const hashes = f.hashes || {};
const algo = hashes.sha512 ? 'sha512' : (hashes.sha1 ? 'sha1' : null);
if (!/^https:\/\//i.test(String(url)) || !algo) {
console.warn(`[Modpack] ${rel} übersprungen (keine sichere Quelle/Prüfsumme)`);
continue;
}
try {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) continue;
const inhalt = Buffer.from(await res.arrayBuffer());
if (require('crypto').createHash(algo).update(inhalt).digest('hex') !== String(hashes[algo]).toLowerCase()) {
console.warn(`[Modpack] ${rel} verworfen: Prüfsumme stimmt nicht`);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
fs.writeFileSync(dest, inhalt);
dateien.push(rel);
} catch { /* einzelne Datei überspringen */ }
}
@@ -3541,7 +3991,7 @@ async function importCurseForge(buf, zip, zugang) {
try {
// Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf
// einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server.
await services.downloadFile(info.url, dest, info.kopfzeilen);
await services.downloadFile(info.url, dest, info.kopfzeilen, info.hashes);
geladen++;
} catch {
fehlend.push(f.fileID);
@@ -3597,7 +4047,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
};
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …' });
const buf = await services.downloadBuffer(file.url);
const buf = await services.downloadBuffer(file.url, null, file.hashes);
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) {
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
@@ -3648,7 +4098,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' });
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen);
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen, file.hashes);
const zip = forge.readZipIndex(buf);
if (!zip['manifest.json']) {
return { ok: false, message: 'Die geladene Datei enthält kein manifest.json – kein gültiges CurseForge-Modpack.' };
@@ -3827,15 +4277,25 @@ async function importInstanceZipFromPath(filePath) {
notes: imported.notes || '',
minecraft: imported.minecraft || {},
});
if (imported.java) store.updateInstance(created.id, { java: imported.java });
// Aus fremden Archiven nur den Arbeitsspeicher übernehmen. Java-Pfad und
// JVM-Argumente bleiben leer: ein präpariertes Archiv könnte sonst beim
// nächsten Start ein beliebiges Programm ausführen lassen (eigene "java.exe",
// -javaagent:…, -XX:OnOutOfMemoryError=…).
if (imported.java && typeof imported.java === 'object') {
const speicher = (v) => (Number.isFinite(Number(v)) && Number(v) >= 256 && Number(v) <= 262144 ? Math.round(Number(v)) : null);
store.updateInstance(created.id, { java: { minMemMb: speicher(imported.java.minMemMb), maxMemMb: speicher(imported.java.maxMemMb) } });
}
const mcSrc = path.join(base, 'minecraft');
if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
const modsSrc = path.join(base, 'mods.json');
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) {
fs.copyFileSync(path.join(base, imported.customImage), path.join(store.instancePath(created.id), imported.customImage));
store.updateInstance(created.id, { customImage: imported.customImage });
// nur ein schlichter Bild-Dateiname – kein "../" o. Ä., das aus dem Ordner hinausführt
const bildName = typeof imported.customImage === 'string' ? imported.customImage : '';
if (/^[\w.\- ]+\.(png|jpe?g|gif|webp)$/i.test(bildName) && bildName === path.basename(bildName)
&& fs.existsSync(path.join(base, bildName))) {
fs.copyFileSync(path.join(base, bildName), path.join(store.instancePath(created.id), bildName));
store.updateInstance(created.id, { customImage: bildName });
}
return { ok: true, instance: store.getInstance(created.id) };
} catch (err) {
@@ -4079,6 +4539,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (!inst.minecraft.version) {
return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' };
}
// Manipulationsschutz: gründliche Prüfung (alle Mods neu gehasht) vor jedem Start
{
const r = await integritaet.pruefe(id);
if (r.status === 'gesperrt') {
if (r.neuGesperrt) meldeGesperrt(id);
return { ok: false, gesperrt: true, message: GESPERRT_TEXT };
}
}
const settings = store.getSettings();
// Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung
@@ -4103,9 +4571,18 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// Auf Wunsch vor dem Start Mods aktualisieren (Fehler/Zeitüberschreitung -> einfach ohne Update starten)
let modUpdates = null;
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
if (modUpdates) await integritaet.versiegele(id).catch(() => {}); // eigene Änderung, geprüft wurde oben
// vorgemerkte Mod-Änderungen aus der letzten Sitzung (falls dort nicht mehr umgesetzt)
if (!laufendeInstanzen.has(id)) wendeAusstehendeModsAn(id);
// Mod-Schalter im Pausemenü (nur Instanzen mit Mod-Loader)
const modSitzung = settings.modSchalter !== false && inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? await modschalter.neueSitzung(id).catch(() => null) : null;
const opts = {
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(),
javaAgent: agentArgument(settings, inst.minecraft.version, modSitzung && modSitzung.adresse),
// Ressourcenpaket für Menü-Logo, Sprüche und Knöpfe anlegen bzw. entfernen
vorStart: (info, protokoll) => spieldesign.vorbereiten(Object.assign({ an: settings.aeromcDesign !== false, mods: !!modSitzung, protokoll }, info)),
versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '',
@@ -4126,6 +4603,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
quickPlayServer: (launchOpts && launchOpts.quickPlayServer) ? String(launchOpts.quickPlayServer).trim() : '',
quickPlayWelt: (launchOpts && launchOpts.quickPlayWelt) ? String(launchOpts.quickPlayWelt) : '',
};
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
@@ -4153,9 +4631,11 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
try {
let launchStart = 0;
const res = await launcher.prepareAndLaunch(opts, (evt) => {
if (neustartBild.has(id)) neustartFortschritt(id, evt);
if (evt.phase === 'launch') {
launchStart = Date.now();
laufendeInstanzen.add(id);
sitzungsInfo.set(id, { start: Date.now(), kontoId });
meldeLaufende();
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
discordpresence.setActivity({
@@ -4194,6 +4674,34 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
laufendeInstanzen.delete(id);
meldeLaufende();
if (modSitzung) modschalter.beende(modSitzung.token);
const offenesModFenster = modFenster.get(id);
if (offenesModFenster && !offenesModFenster.isDestroyed()) offenesModFenster.destroy();
{
const neuStarten = neustartNachBeenden.delete(id);
// jetzt bestimmen, solange die Sitzungsdaten noch stimmen
const zielBeimBeenden = neuStarten ? neustartZiel(id) : null;
sitzungsInfo.delete(id);
if (neuStarten) {
const bild = neustartBild.get(id);
if (bild) bild.ziel = zielBeimBeenden;
neustartStatus(id, 'Mods werden übernommen …', null);
} else if (neustartBild.has(id)) {
schliesseNeustartBild(id); // neues Spiel nach einem Neustart wieder beendet (z. B. Absturz)
}
// kurz warten, bis Windows die Mod-Dateien freigegeben hat
setTimeout(() => {
let erg = wendeAusstehendeModsAn(id);
if (erg.offen) { setTimeout(() => { erg = wendeAusstehendeModsAn(id); }, 2000); }
if (erg.umgesetzt && !neuStarten) broadcast('ui:toast', { text: `${erg.umgesetzt} Mod-Änderung${erg.umgesetzt === 1 ? '' : 'en'} übernommen – gilt ab dem nächsten Start.` });
if (neuStarten && mainWindow && !mainWindow.isDestroyed()) {
neustartStatus(id, 'Minecraft startet neu …', null);
mainWindow.webContents.send('modschalter:neustarten', { id, ziel: zielBeimBeenden });
} else if (neuStarten) {
schliesseNeustartBild(id);
}
}, 1500);
}
// beim nächsten Start dieses Kontos sollen bereits online befindliche
// Freunde wieder gemeldet werden
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
@@ -4218,6 +4726,9 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (m) {
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
letzteQuickPlayServer.set(id, server);
letzteVerbindungZeit.set(id, Date.now());
const bild = neustartBild.get(id);
if (bild && !bild.verbundenSeit) { bild.verbundenSeit = Date.now(); neustartStatus(id, `Verbinde mit ${server} …`, null); }
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
broadcast('recording:serverDetected', { id, server });
@@ -4239,6 +4750,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
}
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
if (res.ok && res.pid) laufendePid.set(id, res.pid);
if (modSitzung) { if (res.ok && res.pid) modschalter.setzePid(modSitzung.token, res.pid); else modschalter.beende(modSitzung.token); }
if (neustartBild.has(id)) { if (res.ok && res.pid) folgeNeuemSpiel(id, res.pid); else schliesseNeustartBild(id); }
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
return res;
} catch (err) {
@@ -4611,12 +5124,17 @@ ipcMain.handle('update:check', () => pruefeAufUpdates());
ipcMain.handle('update:lastCheck', () => letzterUpdateCheck);
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
ipcMain.handle('update:install', async (_e, asset) => {
// Die Download-Adresse kommt bewusst nicht aus dem Fenster, sondern aus der
// eigenen Update-Prüfung – sonst könnte jeder Aufruf von window.api eine
// beliebige Datei als "Update" ausführen lassen.
ipcMain.handle('update:install', async () => {
try {
const asset = pendingUpdateInfo && pendingUpdateInfo.available ? pendingUpdateInfo.asset : null;
if (!asset) throw new Error('Kein geprüftes Update vorhanden – bitte erneut nach Updates suchen.');
const file = await updater.downloadAndInstall(asset, (p) => {
broadcast('update:progress', p);
});
const res = updater.applyUpdate(file);
const res = await updater.applyUpdate(file);
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
setTimeout(() => app.quit(), 1200);
return { ok: true, path: file.path, kind: file.kind, mode: res.started };