Files
AeroMc-Launcher/src/main.js
T

2084 lines
84 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/*
* main.js Electron Hauptprozess für ViperCraft
* Phase 1: Fenster, Instanz-Verwaltung, Einstellungen.
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFile } = require('child_process');
const pkg = require('../package.json');
const store = require('./store');
const services = require('./services');
const launcher = require('./launcher');
const loaders = require('./loaders');
const forge = require('./forge');
const auth = require('./auth');
const updater = require('./updater');
const icons = require('./icons');
const bedrock = require('./bedrock');
const backup = require('./backup');
const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport');
const mcimport = require('./mcimport');
const technicimport = require('./technicimport');
const technicdiscover = require('./technicdiscover');
const javadl = require('./javadl');
const crashreports = require('./crashreports');
const skins = require('./skins');
const servers = require('./servers');
const mcping = require('./mcping');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
globalThis.fetch = undici.fetch;
globalThis.Headers = undici.Headers;
globalThis.Request = undici.Request;
globalThis.Response = undici.Response;
// ---------------------------------------------------------------------------
// Automatische Überwachung der Anmelde-Dienste
// ---------------------------------------------------------------------------
const AUTH_ENDPOINTS = [
{ name: 'Microsoft-Login', url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode' },
{ name: 'Xbox Live', url: 'https://user.auth.xboxlive.com/user/authenticate' },
{ name: 'Minecraft-Dienste', url: 'https://api.minecraftservices.com/minecraft/profile' },
];
let authHealth = { ok: true, checked: null, down: [] };
async function checkAuthServices() {
const down = [];
for (const ep of AUTH_ENDPOINTS) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 8000);
// Antwort-Status ist egal (401/404 = Dienst lebt); nur Ausfall/5xx zählt als "down"
const res = await fetch(ep.url, { method: 'GET', signal: ctrl.signal, headers: { 'User-Agent': 'AeroMC-Launcher/0.28' } });
clearTimeout(t);
if (res.status >= 500) down.push(ep.name);
} catch {
down.push(ep.name);
}
}
authHealth = { ok: down.length === 0, checked: new Date().toISOString(), down };
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:health', authHealth);
return authHealth;
}
function applyProxy(s) {
try {
if (s.proxyEnabled && s.proxyHost && String(s.proxyPort).trim()) {
const cred = s.proxyUser ? `${encodeURIComponent(s.proxyUser)}:${encodeURIComponent(s.proxyPass || '')}@` : '';
undici.setGlobalDispatcher(new undici.ProxyAgent(`http://${cred}${s.proxyHost}:${s.proxyPort}`));
} else {
undici.setGlobalDispatcher(new undici.Agent());
}
} catch { /* ungültige Proxy-Konfig ignorieren */ }
}
let mainWindow = null;
// ---------------------------------------------------------------------------
// Verknüpfungen: "AeroMC.exe --launch <id>" startet eine Instanz direkt
// ---------------------------------------------------------------------------
// Nur eine Instanz des Launchers. Ein zweiter Aufruf (Doppelklick auf eine
// Verknüpfung) reicht seine Argumente an das laufende Fenster weiter.
const hatEinzelSperre = app.requestSingleInstanceLock();
if (!hatEinzelSperre) app.quit();
/*
* Instanz-Kennung aus den Startargumenten lesen.
*
* Wichtig: Bei einem zweiten Start sortiert Chromium die Argumente um
* alle Schalter wandern nach vorn, alles andere ans Ende. Aus
* --launch 1.21.11
* wird dabei
* --launch --allow-file-access-from-files <pfad> 1.21.11
* und die Kennung steht nicht mehr hinter ihrem Schalter. Deshalb wird die
* Form "--launch=<kennung>" verwendet: die bleibt ein einziges Argument.
*/
function instanzAusArgumenten(argv) {
const args = (argv || []).map(String);
for (const a of args) {
if (a.startsWith('--launch=')) return a.slice('--launch='.length);
}
// Notnagel für Verknüpfungen aus früheren Fassungen (und den Aufruf von
// Hand): Steht "--launch" allein da, ist die Kennung durch das Umsortieren
// ans Ende gerutscht das letzte Argument, das kein Schalter ist.
if (args.includes('--launch')) {
const i = args.indexOf('--launch');
if (args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1];
for (let j = args.length - 1; j > 0; j--) {
if (!args[j].startsWith('-')) return args[j];
}
}
return null;
}
// wie instanzAusArgumenten, nur für die optionale Server-Adresse einer
// Server-Verknüpfung ("--server=<ip>") bleibt aus demselben Grund als ein
// einziges "--server=..."-Argument zusammen
function serverAusArgumenten(argv) {
const args = (argv || []).map(String);
for (const a of args) {
if (a.startsWith('--server=')) return a.slice('--server='.length);
}
return null;
}
// Startwunsch an die Oberfläche geben dort hängt die gesamte Rückmeldung dran
function starteInstanzImFenster(id, serverIp) {
if (!id || !mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null });
}
app.on('second-instance', (_e, argv) => {
// showMainWindow() statt nur restore()+focus(): bei aktivem Autostart kann
// das Fenster echt versteckt sein (hide(), nicht nur minimiert) das
// braucht ein show(), sonst bleibt es beim zweiten Start unsichtbar
showMainWindow();
starteInstanzImFenster(instanzAusArgumenten(argv), serverAusArgumenten(argv));
});
// Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf
// den entpackten Temp-Ordner eine Verknüpfung dorthin wäre morgen tot.
function verknuepfungsZiel() {
if (updater.detectInstallKind() === 'portable') {
return updater.portableTarget() || process.execPath;
}
return process.execPath;
}
/*
* Baut die Argumente für eine Verknüpfung zusammen.
* Im Entwicklungsmodus (npm start) zeigt process.execPath nur auf die nackte
* electron.exe die weiß ohne Pfadangabe nicht, welche App sie laden soll,
* und zeigt stattdessen ihren eigenen Startbildschirm statt AeroMC. Im
* gepackten Build (Setup/Portable) ist die App fest eingebaut, daher dort
* kein Zusatz nötig.
*/
function verknuepfungsArgs(teile) {
const praefix = app.isPackaged ? '' : `"${path.join(__dirname, '..')}" `;
return praefix + teile.join(' ');
}
/*
* Icon für eine Verknüpfung. Im gepackten Build trägt die EXE selbst schon
* das richtige Icon als Ressource (funktioniert auch aus dem asar heraus).
* Im Entwicklungsmodus zeigt "ziel" auf die nackte electron.exe (Atom-Logo)
* dort stattdessen direkt die eigene .ico-Datei verwenden, die im
* Entwicklungsordner als normale Datei vorliegt.
*/
function verknuepfungsIcon(ziel) {
if (app.isPackaged) return ziel;
return windowIcon() || ziel;
}
function windowIcon() {
const png = path.join(__dirname, 'assets', 'logo.png');
const ico = path.join(__dirname, 'assets', 'icon.ico');
if (fs.existsSync(ico)) return ico;
if (fs.existsSync(png)) return png;
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
}
function createWindow(startHidden) {
mainWindow = new BrowserWindow({
width: 1200,
height: 820,
minWidth: 940,
minHeight: 640,
backgroundColor: '#1b1e24',
title: 'AeroMC',
icon: windowIcon(),
autoHideMenuBar: true,
show: !startHidden,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
mainWindow.loadFile(path.join(__dirname, 'index.html'));
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
// Infobereich, statt AeroMC wirklich zu beenden sonst wäre der
// Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist.
mainWindow.on('close', (e) => {
if (appIsQuitting || !store.getSettings().autostart) return;
e.preventDefault();
mainWindow.hide();
});
// mit "--launch <id>" gestartet -> Instanz anwerfen, sobald die Oberfläche
// ihren Empfänger dafür registriert hat. WICHTIG: nicht auf "did-finish-load"
// verlassen das feuert, sobald die Seite geladen ist, aber die Oberfläche
// registriert ihren IPC-Listener erst nach eigenem asynchronem Vorlauf
// (u. a. Skin-Hintergrund laden). Kommt der Startwunsch früher an als diese
// Registrierung, geht er kommentarlos verloren ein Wettlauf, der je nach
// Netzwerk-/Ladegeschwindigkeit mal gewinnt, mal nicht. Deshalb wartet der
// Hauptprozess hier auf ein explizites Bereit-Signal der Oberfläche.
const startId = instanzAusArgumenten(process.argv);
const startServer = serverAusArgumenten(process.argv);
if (startId) {
ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer));
}
}
let tray = null;
let appIsQuitting = false;
function showMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) { createWindow(false); return; }
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
function createTray() {
if (tray) return;
try {
const iconPfad = windowIcon();
const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty();
tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 }));
tray.setToolTip('AeroMC');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Öffnen', click: () => showMainWindow() },
{ type: 'separator' },
{ label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } },
]));
tray.on('click', () => showMainWindow());
} catch { /* z. B. wenn kein Icon vorhanden ist Autostart läuft dann ohne Tray-Symbol weiter */ }
}
function destroyTray() {
if (tray) { tray.destroy(); tray = null; }
}
// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden
function applyAutostart(enabled) {
try {
app.setLoginItemSettings({ openAtLogin: !!enabled, args: enabled ? ['--hidden'] : [] });
} catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ }
if (enabled) createTray();
else destroyTray();
}
app.whenReady().then(() => {
if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst
store.init(app.getPath('userData'));
services.setCacheDir(app.getPath('userData')); // Java-Suche zwischenspeichern
// selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen
services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java')));
// Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden)
auth.init(app.getPath('userData'), {
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
});
applyProxy(store.getSettings());
const autostartAn = !!store.getSettings().autostart;
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update)
applyAutostart(autostartAn);
const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden);
// Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung
setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800);
// echte Minecraft-Icons einmalig aus den eigenen Spieldateien holen
setTimeout(() => {
try {
const { shared, out } = iconDirs();
if (!fs.existsSync(out) || fs.readdirSync(out).length === 0) icons.extractIcons(shared, out);
} catch { /* noch keine Version heruntergeladen */ }
}, 1500);
// Reste früherer Update-Downloads aufräumen (mehrere hundert MB im Temp)
setTimeout(() => { try { updater.cleanupUpdateCache(); } catch { /* egal */ } }, 6000);
// Sicherung nach Zeitplan: kurz nach dem Start prüfen, danach stündlich.
// Die Prüfung ist billig gesichert wird nur, wenn wirklich fällig.
setTimeout(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 60000);
setInterval(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 3600000);
// automatisch auf Updates prüfen (kurz nach dem Start)
setTimeout(async () => {
try {
const info = await updater.checkForUpdate(app.getVersion());
if (mainWindow && !mainWindow.isDestroyed()) {
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
// kann nicht erst wenn der Nutzer manuell auf "Suchen" klickt
mainWindow.webContents.send('update:checked', info);
if (info.available) mainWindow.webContents.send('update:available', info);
}
} catch (err) {
// offline o. Ä. kein Absturz, aber die Info-Seite darf den Fehler zeigen
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) });
}
}
}, 4000);
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
setTimeout(() => checkAuthServices(), 2500);
setInterval(() => checkAuthServices(), 120000);
// Anmeldung im Hintergrund auffrischen -> kein erneuter Login beim Start nötig
const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
auth.getValidAccount(cid)
.then((acc) => {
if (acc && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:refreshed');
})
.catch(() => { /* offline o. Ä. Konto bleibt gespeichert */ });
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => { appIsQuitting = true; });
// ---------------------------------------------------------------------------
// Java-Erkennung (für Anzeige in den Einstellungen)
// ---------------------------------------------------------------------------
function detectJava(javaPath) {
return new Promise((resolve) => {
const cmd = javaPath && javaPath.trim() ? javaPath : 'java';
execFile(cmd, ['-version'], (err, stdout, stderr) => {
if (err) return resolve({ ok: false, path: cmd, version: null, error: err.message });
// "java -version" schreibt nach stderr
const out = (stderr || stdout || '').split('\n')[0].trim();
resolve({ ok: true, path: cmd, version: out });
});
});
}
// ---------------------------------------------------------------------------
// IPC Einstellungen
// ---------------------------------------------------------------------------
ipcMain.handle('settings:get', () => store.getSettings());
ipcMain.handle('settings:save', (_e, patch) => {
const s = store.saveSettings(patch);
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
return s;
});
ipcMain.handle('settings:instancesDir', () => store.getInstancesDir());
ipcMain.handle('java:detect', (_e, javaPath) => detectJava(javaPath));
// automatische Java-Suche über das System
ipcMain.handle('java:scan', () => services.scanJava(false)); // Knopf sucht immer frisch
// aus dem Zwischenspeicher für Dialoge, die nur die Liste anzeigen wollen
ipcMain.handle('java:list', () => services.scanJava(true));
// ---------------------------------------------------------------------------
// IPC Minecraft-Versionen (Mojang)
// ---------------------------------------------------------------------------
ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData')));
// ---------------------------------------------------------------------------
// IPC Mods (Modrinth)
// ---------------------------------------------------------------------------
ipcMain.handle('curseforge:testKey', async (_e, apiKey) => {
try { return await services.curseforgeTestKey(apiKey); }
catch (err) { return { ok: false, message: err.message || String(err) }; }
});
ipcMain.handle('mods:search', async (_e, opts) => {
const o = opts || {};
const source = o.source || 'modrinth';
try {
if (source === 'curseforge') {
const zugang = services.curseforgeZugang(store.getSettings());
return await services.curseforgeSearch(zugang, o);
}
return await services.modrinthSearch(o);
} catch (err) {
return {
total: 0,
hits: [],
error: (err && err.message) ? err.message : String(err),
needKey: !!(err && err.code === 'need-key'),
};
}
});
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
ipcMain.handle('mods:install', async (_e, id, project, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
// Nur Mods brauchen einen Loader Ressourcenpakete und Shader laufen auch
// in einer Vanilla-Instanz.
if (typ.loaderNoetig && (!loader || loader === 'vanilla')) {
return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' };
}
const modsDir = store.inhaltsDir(id, art || 'mod');
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId));
const added = [];
let firstError = null;
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
const zugang = istCf ? services.curseforgeZugang(store.getSettings()) : null;
// installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten
async function installOne(proj, isDep) {
if (done.has(proj.projectId)) return;
done.add(proj.projectId);
try {
let res;
if (String(proj.projectId || '').startsWith('cf-') || proj.source === 'curseforge') {
res = await services.curseforgeInstall({
zugang, projectId: proj.projectId, title: proj.title,
gameVersion, loader, modsDir, art: art || 'mod',
});
if (!res.ok) {
if (!isDep && !firstError) firstError = res.message;
return;
}
// merken, welche Pflicht-Abhängigkeiten dieser Mod mitbringt damit
// wir beim Entfernen warnen können, wenn ein anderer Mod ihn braucht
res.entry.requiredDeps = (res.dependencies || [])
.filter((d) => d.required && d.projectId).map((d) => d.projectId);
store.addMod(id, res.entry, art || 'mod');
added.push(res.entry);
for (const dep of res.dependencies || []) {
if (dep.required && dep.projectId && !done.has(dep.projectId)) {
await installOne({ projectId: dep.projectId, title: dep.projectId, source: 'curseforge' }, true);
}
}
} else {
res = await services.modrinthInstall({
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod',
});
if (!res.ok) {
if (!isDep && !firstError) firstError = res.message;
return;
}
res.entry.requiredDeps = (res.dependencies || [])
.filter((d) => d.dependency_type === 'required' && d.project_id).map((d) => d.project_id);
store.addMod(id, res.entry, art || 'mod');
added.push(res.entry);
for (const dep of res.dependencies || []) {
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
const title = await services.modrinthProjectTitle(dep.project_id);
await installOne({ projectId: dep.project_id, title }, true);
}
}
}
} catch (err) {
if (!isDep && !firstError) firstError = err.message;
}
}
await installOne(project, false);
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
});
/*
* Lokale Datei(en) direkt in den Inhaltsordner kopieren für Mods, die
* nicht über Modrinth/CurseForge zu finden sind (Dev-Build, privater Mod,
* per Hand zugeschickt). Die Datei taucht danach ganz normal in der
* "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt.
*/
ipcMain.handle('mods:addFile', async (_e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const filters = (art || 'mod') === 'mod'
? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }]
: [{ name: typ.label || 'Datei', extensions: ['zip'] }];
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Datei hinzufügen',
properties: ['openFile', 'multiSelections'],
filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]),
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const dir = store.inhaltsDir(id, art || 'mod');
fs.mkdirSync(dir, { recursive: true });
const added = [];
const failed = [];
for (const src of res.filePaths) {
try {
if (!/\.(jar|zip)$/i.test(src)) {
failed.push({ name: path.basename(src), message: 'Nur .jar/.zip-Dateien werden unterstützt.' });
continue;
}
const name = path.basename(src);
const ext = path.extname(name);
const base = name.slice(0, -ext.length);
let dest = path.join(dir, name);
// Namenskollision -> durchnummerieren statt eine vorhandene Datei zu überschreiben
let n = 2;
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext}`); n++; }
fs.copyFileSync(src, dest);
added.push(path.basename(dest));
} catch (err) {
failed.push({ name: path.basename(src), message: err.message });
}
}
return { ok: true, added, failed };
});
// verfügbare Loader-Versionen (Fabric/Quilt) für eine MC-Version
ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
try { return await loaders.listLoaderVersions(loader, mcVersion); }
catch { return []; }
});
ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod'));
// prüft, ob andere installierte Mods diesen hier als Pflicht-Abhängigkeit brauchen
ipcMain.handle('mods:dependents', (_e, id, filename, art) => store.findDependents(id, filename, art || 'mod'));
// Vorhandener Arbeitsspeicher Grundlage für die Prüfung der RAM-Angaben
function gesamtSpeicherMb() {
return Math.round(os.totalmem() / 1048576);
}
ipcMain.handle('system:memory', () => ({
totalMb: gesamtSpeicherMb(),
freeMb: Math.round(os.freemem() / 1048576),
}));
ipcMain.handle('system:checkMemory', (_e, minMb, maxMb) =>
services.pruefeSpeicher(minMb, maxMb, gesamtSpeicherMb()));
// Neuester Absturzbericht einer Instanz; seit = Startzeitpunkt in Millisekunden
ipcMain.handle('crash:latest', (_e, id, seit) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, grund: 'keine-instanz' };
return crashreports.lies(store.gameDir(id), Number(seit) || 0);
});
// ---- Serverliste (servers.dat) ----
ipcMain.handle('servers:list', (_e, id) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.lies(store.gameDir(id));
});
ipcMain.handle('servers:add', (_e, id, eintrag) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.fuegeHinzu(store.gameDir(id), eintrag || {});
});
ipcMain.handle('servers:remove', (_e, id, ip) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.entferne(store.gameDir(id), ip);
});
ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.benenneUm(store.gameDir(id), ip, neuerName);
});
// Live-Status eines Servers abfragen (MOTD/Spielerzahl/Version) wie der
// Vanilla-Mehrspieler-Bildschirm, ohne sich wirklich zu verbinden
ipcMain.handle('servers:ping', async (_e, ip) => {
const s = String(ip || '').trim();
const m = s.match(/^([^:]+)(?::(\d+))?$/);
if (!m) return { ok: false, message: 'Ungültige Adresse.' };
const host = m[1];
const port = m[2] ? Number(m[2]) : 25565;
try { return await mcping.pingServer(host, port); }
catch (err) { return { ok: false, message: err.message }; }
});
// denselben Server in weitere Instanzen übertragen
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
const erledigt = [];
const fehler = [];
for (const zielId of zielIds || []) {
const inst = store.getInstance(zielId);
if (!inst) { fehler.push({ id: zielId, message: 'Instanz nicht gefunden.' }); continue; }
const res = servers.uebertrage(store.gameDir(zielId), eintrag || {});
if (res.ok) erledigt.push(inst.name);
else fehler.push({ name: inst.name, message: res.message });
}
return { ok: true, erledigt, fehler };
});
// Logdateien der Instanz auch nach einem Neustart des Launchers
ipcMain.handle('logs:list', (_e, id) => {
if (!store.getInstance(id)) return [];
return crashreports.listeLogs(store.gameDir(id));
});
ipcMain.handle('logs:read', (_e, id, name) => {
if (!store.getInstance(id)) return { ok: false, grund: 'keine-instanz' };
return crashreports.liesLog(store.gameDir(id), name);
});
ipcMain.handle('logs:openFolder', (_e, id) => {
if (!store.getInstance(id)) return false;
const dir = crashreports.logOrdner(store.gameDir(id));
if (!fs.existsSync(dir)) return false;
shell.openPath(dir);
return true;
});
// Ordner mit den Absturzberichten öffnen
ipcMain.handle('crash:openFolder', (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return false;
const dir = crashreports.berichtsOrdner(store.gameDir(id));
if (!fs.existsSync(dir)) return false;
shell.openPath(dir);
return true;
});
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled, art) => {
const res = store.setModEnabled(id, filename, !!enabled, art || 'mod');
if (res.ok) return res;
const ordner = services.inhaltsArt(art || 'mod').ordner;
const gruende = {
'not-found': `Die Datei liegt nicht mehr im ${ordner}-Ordner.`,
'both-exist': `Es liegen beide Fassungen im ${ordner}-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.`,
'bad-name': 'Ungültiger Dateiname.',
};
return { ok: false, message: gruende[res.reason] || res.reason };
});
// sucht für die installierten Mods nach neueren Dateien (lädt noch nichts)
ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
try {
const res = await services.modrinthFindUpdates(store.listMods(id, art || 'mod'), gameVersion, loader, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
}, art || 'mod');
return { ok: true, ...res };
} catch (err) {
return { ok: false, message: 'Update-Suche fehlgeschlagen: ' + err.message };
}
});
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
const sorte = art || 'mod';
const modsDir = store.inhaltsDir(id, sorte);
const gewuenscht = new Set(projectIds || []);
const erneuert = [];
const fehler = [];
for (const mod of store.listMods(id, sorte)) {
if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue;
try {
const res = await services.modrinthInstall({
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir, art: sorte,
});
if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; }
store.addMod(id, res.entry, sorte);
// alte Datei erst weg, wenn die neue liegt und nie, wenn sie gleich heißt
if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename, sorte);
// war der Eintrag abgeschaltet, bleibt er es auch nach dem Update
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false, sorte);
erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber });
} catch (err) {
fehler.push({ title: mod.title, message: err.message });
}
}
return { ok: true, erneuert, fehler };
});
// ---------------------------------------------------------------------------
// IPC Microsoft-Login (Phase 3)
// ---------------------------------------------------------------------------
ipcMain.handle('auth:status', () => auth.currentAccount());
ipcMain.handle('auth:health', () => authHealth);
ipcMain.handle('auth:recheck', () => checkAuthServices());
// "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal
ipcMain.handle('auth:accounts', () => auth.listAccounts()
.map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) })));
ipcMain.handle('auth:setActive', (_e, id) => auth.setActive(id));
// ---- Skin des aktiven Kontos ----
// gültiges Zugangstoken des aktiven Kontos holen
async function aktivesToken() {
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
const acc = await auth.getValidAccount(clientId);
return acc && acc.mcAccessToken ? acc.mcAccessToken : null;
}
ipcMain.handle('skin:profile', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.holeProfil(token); }
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
});
ipcMain.handle('skin:choose', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Skin auswählen',
properties: ['openFile'],
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const pfad = res.filePaths[0];
// sofort prüfen, damit der Nutzer nicht erst nach dem Hochladen erfährt,
// dass die Datei nicht passt
const geprueft = skins.pruefeDatei(pfad);
if (!geprueft.ok) return geprueft;
// gleich als Bilddaten mitgeben, damit die Vorschau ohne Dateizugriff
// im Renderer auskommt
let dataUri = '';
try { dataUri = 'data:image/png;base64,' + fs.readFileSync(pfad).toString('base64'); } catch { /* egal */ }
return { ok: true, pfad, breite: geprueft.breite, hoehe: geprueft.hoehe, dataUri };
});
ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.setzeSkin(token, dateiPfad, variante); }
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
});
ipcMain.handle('skin:reset', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.setzeSkinZurueck(token); }
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
ipcMain.handle('auth:login', async () => {
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
try {
const account = await auth.login(
clientId,
(code) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:code', code);
},
() => {},
);
return { ok: true, account };
} catch (err) {
return { ok: false, message: err.message };
}
});
// ---------------------------------------------------------------------------
// IPC Instanzen
// ---------------------------------------------------------------------------
ipcMain.handle('instances:list', () => store.listInstances());
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id));
ipcMain.handle('instances:create', (_e, data) => store.createInstance(data));
ipcMain.handle('instances:update', (_e, id, patch) => store.updateInstance(id, patch));
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id));
ipcMain.handle('instances:openFolder', (_e, id) => {
const p = store.instancePath(id);
if (fs.existsSync(p)) shell.openPath(p);
return true;
});
ipcMain.handle('instances:openGameDir', (_e, id) => {
const p = store.gameDir(id);
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
shell.openPath(p);
return true;
});
// ---- Welten & Ressourcenpakete ----
function listDir(dir, dirsOnly) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => (dirsOnly ? e.isDirectory() : true) && !e.name.startsWith('.'))
.map((e) => {
let size = 0;
try { size = fs.statSync(path.join(dir, e.name)).size; } catch { /* egal */ }
return { name: e.name, isDir: e.isDirectory(), size };
});
}
ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true));
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false));
function safeSub(id, sub, name) {
const base = path.join(store.gameDir(id), sub);
const target = path.join(base, name);
// Pfad-Ausbruch verhindern
if (!target.startsWith(base + path.sep)) return null;
return target;
}
ipcMain.handle('instance:deleteContent', (_e, id, sub, name) => {
const target = safeSub(id, sub, name);
if (target && fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
return true;
});
ipcMain.handle('instance:openSubdir', (_e, id, sub) => {
const p = path.join(store.gameDir(id), sub);
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
shell.openPath(p);
return true;
});
// ---- Screenshots ----
ipcMain.handle('instance:screenshots', (_e, id) =>
listDir(path.join(store.gameDir(id), 'screenshots'), false)
.filter((f) => /\.(png|jpe?g|gif|webp)$/i.test(f.name))
.sort((a, b) => b.name.localeCompare(a.name)));
ipcMain.handle('instance:screenshotData', (_e, id, name) => {
const target = safeSub(id, 'screenshots', name);
try {
if (!target || !fs.existsSync(target)) return null;
if (fs.statSync(target).size > 12 * 1024 * 1024) return null;
let ext = path.extname(target).slice(1).toLowerCase();
if (ext === 'jpg') ext = 'jpeg';
return `data:image/${ext};base64,` + fs.readFileSync(target).toString('base64');
} catch { return null; }
});
ipcMain.handle('instance:openScreenshot', (_e, id, name) => {
const target = safeSub(id, 'screenshots', name);
if (target && fs.existsSync(target)) shell.openPath(target);
return true;
});
// ---- Instanz Import/Export (ZIP über Windows-Bordmittel) ----
function runPwsh(command, env) {
return new Promise((resolve, reject) => {
execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command],
{ env: Object.assign({}, process.env, env), windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
(err, stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve(stdout)));
});
}
ipcMain.handle('instances:export', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_');
const res = await dialog.showSaveDialog(mainWindow, {
title: 'Instanz exportieren',
defaultPath: safe + '.zip',
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
try {
if (fs.existsSync(res.filePath)) fs.unlinkSync(res.filePath);
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
{ VN_SRC: store.instancePath(id), VN_DST: res.filePath });
return { ok: true, path: res.filePath };
} catch (err) {
return { ok: false, message: 'Export fehlgeschlagen: ' + err.message };
}
});
/*
* Schnellsicherung EINER Welt als ZIP unabhängig vom globalen
* Backup-System, landet in einem eigenen Unterordner der Dokumente.
*/
ipcMain.handle('world:backup', async (_e, id, worldName) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const worldDir = path.join(store.gameDir(id), 'saves', String(worldName || ''));
if (path.dirname(worldDir) !== path.join(store.gameDir(id), 'saves') || !fs.existsSync(worldDir)) {
return { ok: false, message: 'Welt nicht gefunden.' };
}
try {
const zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen',
(inst.name || 'Instanz').replace(/[^\w.-]+/g, '_'));
fs.mkdirSync(zielOrdner, { recursive: true });
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
const safeWorld = String(worldName).replace(/[^\w.-]+/g, '_');
const zielDatei = path.join(zielOrdner, `${safeWorld}_${zeitstempel}.zip`);
if (fs.existsSync(zielDatei)) fs.unlinkSync(zielDatei);
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
{ VN_SRC: worldDir, VN_DST: zielDatei });
notify('Welt gesichert', `${worldName} (${inst.name}) wurde gesichert.`);
return { ok: true, path: zielDatei };
} catch (err) {
return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message };
}
});
// ---- Modpack-Import (Modrinth .mrpack) ----
async function importMrpack(buf, zip) {
const raw = forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, '');
const index = JSON.parse(raw);
const deps = index.dependencies || {};
let loader = 'vanilla', loaderVersion = '';
if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; }
else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; }
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
const created = store.createOrReplaceImportedInstance({
name: index.name || 'Modpack',
minecraft: { version: deps.minecraft || '', loader, loaderVersion },
});
const gameDir = store.gameDir(created.id);
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const eingespielt = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: false });
// Herkunft merken, damit sich das Paket später aktualisieren lässt
const herkunft = await modpackHerkunft(buf, index);
store.updateInstance(created.id, { modpack: Object.assign(herkunft, { dateien: eingespielt.dateien }) });
return {
ok: true,
instance: store.getInstance(created.id),
fileCount: eingespielt.dateien.length,
};
}
/*
* Spielt ein .mrpack in ein Spielverzeichnis ein.
* schonePlayerDaten: bei einer Aktualisierung bleiben Welten, Bildschirmfotos
* und die Optionen unangetastet sonst wären die Einstellungen des Spielers weg.
* -> { dateien: [relative Pfade] }
*/
const SPIELERDATEN = [/^saves\//i, /^screenshots\//i, /^logs\//i, /^options\.txt$/i, /^servers\.dat$/i];
async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten }) {
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const geschuetzt = (rel) => schonePlayerDaten && SPIELERDATEN.some((r) => r.test(rel));
const dateien = [];
const files = (index.files || []).filter((f) => !(f.env && f.env.client === 'unsupported'));
let i = 0;
for (const f of files) {
i++;
const url = f.downloads && f.downloads[0];
if (!url || !f.path) continue;
const rel = String(f.path).replace(/\\/g, '/');
if (geschuetzt(rel)) continue;
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
}
try {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
dateien.push(rel);
} catch { /* einzelne Datei überspringen */ }
}
// overrides einspielen
for (const prefix of ['overrides/', 'client-overrides/']) {
for (const name of Object.keys(zip)) {
if (!name.startsWith(prefix) || name.endsWith('/')) continue;
const rel = name.slice(prefix.length);
if (geschuetzt(rel)) continue;
const dest = path.join(gameDir, rel.replace(/\//g, path.sep));
if (!inside(dest)) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
dateien.push(rel);
}
}
return { dateien };
}
/*
* Woher stammt das Paket? Modrinth kennt jede veröffentlichte Datei an ihrer
* Prüfsumme darüber finden wir Projekt und Fassung, ohne den Nutzer zu fragen.
*/
async function modpackHerkunft(buf, index) {
const basis = {
quelle: 'modrinth',
name: index.name || '',
versionNummer: index.versionId || '',
projectId: '',
versionId: '',
};
try {
const sha1 = require('crypto').createHash('sha1').update(buf).digest('hex');
const res = await fetch(`https://api.modrinth.com/v2/version_file/${sha1}?algorithm=sha1`,
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
if (res.ok) {
const v = await res.json();
basis.projectId = v.project_id || '';
basis.versionId = v.id || '';
basis.versionNummer = v.version_number || basis.versionNummer;
}
} catch { /* offline oder unbekanntes Paket dann eben ohne Herkunft */ }
return basis;
}
// ---- Modpack aktualisieren (Modrinth) ----
/*
* Sucht eine neuere Fassung des installierten Modpacks.
* -> { ok, verfuegbar, aktuelleNummer, neueNummer, ... }
*/
ipcMain.handle('modpack:check', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const mp = inst.modpack;
if (!mp || !mp.projectId) {
return { ok: false, message: 'Für diese Instanz ist kein Modrinth-Modpack hinterlegt. '
+ 'Aktualisieren geht nur bei Instanzen, die aus einem .mrpack entstanden sind.' };
}
try {
const res = await fetch(`https://api.modrinth.com/v2/project/${mp.projectId}/version`,
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
if (!res.ok) return { ok: false, message: 'Modrinth antwortet mit HTTP ' + res.status };
const versionen = await res.json();
if (!Array.isArray(versionen) || !versionen.length) return { ok: true, verfuegbar: false };
const neueste = versionen[0];
const verfuegbar = neueste.id !== mp.versionId;
return {
ok: true,
verfuegbar,
name: mp.name,
aktuelleNummer: mp.versionNummer || '?',
neueNummer: neueste.version_number,
neueVersionId: neueste.id,
hinweise: neueste.changelog || '',
spielVersion: (neueste.game_versions || [])[0] || '',
};
} catch (err) {
return { ok: false, message: 'Suche fehlgeschlagen: ' + err.message };
}
});
/*
* Spielt eine neuere Fassung ein. Dateien der alten Fassung, die es nicht mehr
* gibt, werden entfernt; Welten, Bildschirmfotos und Optionen bleiben.
*/
ipcMain.handle('modpack:update', async (_e, id, versionId) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const mp = inst.modpack;
if (!mp || !mp.projectId) return { ok: false, message: 'Kein Modpack hinterlegt.' };
try {
const vres = await fetch(`https://api.modrinth.com/v2/version/${versionId}`,
{ headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } });
if (!vres.ok) return { ok: false, message: 'Fassung nicht abrufbar (HTTP ' + vres.status + ').' };
const version = await vres.json();
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
if (!datei || !datei.url) return { ok: false, message: 'Diese Fassung enthält keine Paketdatei.' };
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { id, phase: 'modpack', detail: 'Neue Fassung wird geladen …' });
}
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!dl.ok) return { ok: false, message: 'Download fehlgeschlagen (HTTP ' + dl.status + ').' };
const buf = Buffer.from(await dl.arrayBuffer());
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) return { ok: false, message: 'Die geladene Datei ist kein .mrpack.' };
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, ''));
const gameDir = store.gameDir(id);
const neu = await spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten: true });
// Was die alte Fassung mitbrachte und die neue nicht mehr kennt, muss weg
// sonst bleiben alte Mods liegen und der Loader stolpert darüber.
const behalten = new Set(neu.dateien.map((d) => d.toLowerCase()));
const entfernt = [];
for (const alt of mp.dateien || []) {
if (behalten.has(String(alt).toLowerCase())) continue;
if (SPIELERDATEN.some((r) => r.test(alt))) continue;
const p = path.join(gameDir, String(alt).replace(/\//g, path.sep));
if (!p.startsWith(gameDir + path.sep)) continue;
try {
if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); entfernt.push(alt); }
} catch { /* egal */ }
}
// Minecraft-Version und Loader können sich mit der neuen Fassung ändern
const deps = index.dependencies || {};
let loader = 'vanilla'; let loaderVersion = '';
if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; }
else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; }
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
store.updateInstance(id, {
minecraft: { version: deps.minecraft || inst.minecraft.version, loader, loaderVersion },
modpack: Object.assign({}, mp, {
versionId: version.id,
versionNummer: version.version_number,
dateien: neu.dateien,
}),
});
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
return {
ok: true,
neueNummer: version.version_number,
eingespielt: neu.dateien.length,
entfernt: entfernt.length,
spielVersion: deps.minecraft || '',
};
} catch (err) {
return { ok: false, message: 'Aktualisierung fehlgeschlagen: ' + err.message };
}
});
// ---- Modpack-Import (CurseForge .zip mit manifest.json) ----
// "forge-47.2.0" / "fabric-0.15.7" -> { loader, loaderVersion }
function loaderAusManifest(manifest) {
const eintraege = (manifest.minecraft && manifest.minecraft.modLoaders) || [];
const primaer = eintraege.find((l) => l && l.primary) || eintraege[0];
const id = String((primaer && primaer.id) || '');
const m = id.match(/^(forge|neoforge|fabric|quilt)-(.+)$/i);
if (!m) return { loader: 'vanilla', loaderVersion: '' };
return { loader: m[1].toLowerCase(), loaderVersion: m[2] };
}
async function importCurseForge(buf, zip, zugang) {
const raw = forge.readZipEntry(buf, zip['manifest.json']).toString('utf8').replace(/^/, '');
const manifest = JSON.parse(raw);
const { loader, loaderVersion } = loaderAusManifest(manifest);
const created = store.createOrReplaceImportedInstance({
name: manifest.name || 'CurseForge-Modpack',
minecraft: {
version: (manifest.minecraft && manifest.minecraft.version) || '',
loader,
loaderVersion,
},
});
const gameDir = store.gameDir(created.id);
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const melde = (detail, current, total) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current, total, detail });
}
};
// 1) Dateiliste über die CurseForge-API auflösen
const dateien = (manifest.files || []).filter((f) => f && f.fileID);
melde('Dateiliste abrufen', 0, dateien.length);
const aufgeloest = await services.curseforgeResolveFiles(zugang, dateien,
(p) => melde('Dateiliste abrufen', p.aufgeloest, p.gesamt));
// 2) Mods herunterladen
const modsDir = path.join(gameDir, 'mods');
let geladen = 0;
const fehlend = [];
let i = 0;
for (const f of dateien) {
i++;
const info = aufgeloest.get(Number(f.fileID));
if (!info) { fehlend.push(f.fileID); continue; }
const dest = path.join(modsDir, path.basename(info.fileName));
if (!inside(dest)) continue;
melde('Mods laden', i, dateien.length);
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);
geladen++;
} catch {
fehlend.push(f.fileID);
}
}
// 3) overrides einspielen
const prefix = (manifest.overrides || 'overrides').replace(/\/*$/, '') + '/';
for (const name of Object.keys(zip)) {
if (!name.startsWith(prefix) || name.endsWith('/')) continue;
const dest = path.join(gameDir, name.slice(prefix.length).replace(/\//g, path.sep));
if (!inside(dest)) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
}
return {
ok: true,
instance: store.getInstance(created.id),
fileCount: dateien.length,
geladen,
fehlend: fehlend.length,
};
}
ipcMain.handle('modpack:search', async (_e, opts) => {
try {
const q = Object.assign({}, opts || {}, { art: 'modpack' });
return await services.modrinthSearch(q);
} catch (err) {
return { total: 0, hits: [], error: err.message };
}
});
ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
try {
const projectId = project && (project.projectId || project.id);
if (!projectId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
});
}
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
if (!file) {
return {
ok: false,
message: gameVersion
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
: 'Keine Modpack-Version gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack',
detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …',
});
}
const buf = await services.downloadBuffer(file.url);
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) {
return { ok: false, message: 'Die geladene Datei ist kein gültiges Modrinth-Modpack (.mrpack).' };
}
const result = await importMrpack(buf, zip);
if (result && result.ok && result.instance) {
// Herkunft mit aktueller Version-ID ergänzen, falls der Index sie nicht trägt
const inst = result.instance;
const mp = Object.assign({}, inst.modpack || {}, {
projectId: projectId,
versionId: file.versionId,
versionNumber: file.versionNumber,
title: (project && project.title) || (inst.modpack && inst.modpack.title) || inst.name,
});
store.updateInstance(inst.id, { modpack: mp });
result.instance = store.getInstance(inst.id);
}
if (result && result.ok && result.instance) {
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
} catch (err) {
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
}
});
/*
* Kern des Modpack-Datei-Imports, unabhängig davon ob der Pfad aus einem
* Dateidialog oder per Drag&Drop aufs Fenster kommt.
* notModpack: true heißt "kein Modpack-Format erkannt" der Aufrufer kann
* dann noch andere ZIP-Interpretationen probieren (z. B. Instanz-Export).
*/
async function importModpackFromPath(filePath) {
try {
const buf = fs.readFileSync(filePath);
const zip = forge.readZipIndex(buf);
let result = null;
if (zip['modrinth.index.json']) {
result = await importMrpack(buf, zip);
} else if (zip['manifest.json']) {
const zugang = services.curseforgeZugang(store.getSettings());
if (!zugang) {
return { ok: false, needsKey: true,
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
}
result = await importCurseForge(buf, zip, zugang);
} else {
return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
}
if (result && result.ok && result.instance) {
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
} catch (err) {
return { ok: false, notModpack: true, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
}
}
ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Modpack importieren',
properties: ['openFile'],
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
return importModpackFromPath(res.filePaths[0]);
});
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
// wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk)
ipcMain.handle('instances:shortcut', (_e, id, wunschName) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (process.platform !== 'win32') {
return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' };
}
try {
const roh = (wunschName && wunschName.trim()) || inst.name || 'Instanz';
const name = String(roh).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz';
const datei = path.join(app.getPath('desktop'), name + '.lnk');
const ziel = verknuepfungsZiel();
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
target: ziel,
// Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium
// an ihrem Schalter bleibt (siehe instanzAusArgumenten)
args: verknuepfungsArgs([`--launch="${id}"`]),
cwd: path.dirname(ziel),
description: 'AeroMC ' + inst.name,
icon: verknuepfungsIcon(ziel),
iconIndex: 0,
});
if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' };
return { ok: true, path: datei };
} catch (err) {
return { ok: false, message: 'Verknüpfung fehlgeschlagen: ' + err.message };
}
});
/*
* Desktop-Verknüpfung, die eine Instanz startet UND direkt mit einem
* bestimmten Server verbindet (Quick Play, ab Minecraft 1.20). Technisch
* dasselbe wie instances:shortcut, nur mit zusätzlichem "--server="-Argument.
* wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk)
*/
ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!ip) return { ok: false, message: 'Keine Serveradresse angegeben.' };
if (process.platform !== 'win32') {
return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' };
}
try {
const rohName = (wunschName && wunschName.trim())
|| `${inst.name || 'Instanz'} - ${name || ip}`;
const dateiName = String(rohName).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Server';
const datei = path.join(app.getPath('desktop'), `${dateiName}.lnk`);
const ziel = verknuepfungsZiel();
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
target: ziel,
// Gleichheitsform für beide Kennungen, damit sie beim Umsortieren durch
// Chromium zusammenbleiben (siehe instanzAusArgumenten)
args: verknuepfungsArgs([`--launch="${id}"`, `--server="${ip}"`]),
cwd: path.dirname(ziel),
description: `AeroMC ${inst.name}${name || ip}`,
icon: verknuepfungsIcon(ziel),
iconIndex: 0,
});
if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' };
return { ok: true, path: datei };
} catch (err) {
return { ok: false, message: 'Verknüpfung fehlgeschlagen: ' + err.message };
}
});
/*
* Kern des ZIP-Instanz-Imports (AeroMC-Export oder einzelne MultiMC/Prism-
* Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per
* Drag&Drop aufs Fenster kommt.
*/
async function importInstanceZipFromPath(filePath) {
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
try {
fs.mkdirSync(tmp, { recursive: true });
await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force',
{ VN_SRC: filePath, VN_DST: tmp });
// instance.json finden (auch in Unterordner)
let base = tmp;
if (!fs.existsSync(path.join(base, 'instance.json'))) {
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
.find((p) => { try { return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, 'instance.json')); } catch { return false; } });
if (sub) base = sub;
}
// Kein eigener Export? Dann auf eine MultiMC/Prism-Instanz prüfen
// die bringt statt instance.json eine instance.cfg mit.
if (!fs.existsSync(path.join(base, 'instance.json'))) {
const mmc = mmcimport.scanSingle(tmp);
if (mmc.ok) {
const done = mmcimport.importOne(store, mmc.instance, true);
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: done.copied };
}
throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).');
}
const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8'));
const created = store.createOrReplaceImportedInstance({
name: imported.name || 'Import',
icon: imported.icon || 'grass',
notes: imported.notes || '',
minecraft: imported.minecraft || {},
});
if (imported.java) store.updateInstance(created.id, { java: imported.java });
const mcSrc = path.join(base, 'minecraft');
if (fs.existsSync(mcSrc)) fs.cpSync(mcSrc, store.gameDir(created.id), { recursive: true });
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 });
}
return { ok: true, instance: store.getInstance(created.id) };
} catch (err) {
return { ok: false, message: 'Import fehlgeschlagen: ' + err.message };
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
}
}
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
return importInstanceZipFromPath(res.filePaths[0]);
});
/*
* Datei per Drag&Drop aufs Fenster gezogen. Anhand der Endung/des Inhalts
* wird entschieden, was damit passieren soll:
* .jar -> als Mod/Ressourcenpaket/Shader in die übergebene
* Instanz kopieren (targetId/targetArt vom Renderer,
* je nachdem was gerade offen bzw. ausgewählt ist)
* .mrpack / .zip -> zuerst als Modpack versuchen (Modrinth/CurseForge),
* sonst als AeroMC-/MultiMC-Instanz-Export
*/
ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
if (!filePath || !fs.existsSync(filePath)) return { ok: false, message: 'Datei nicht gefunden.' };
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jar') {
if (!targetId) {
return { ok: false, message: 'Bitte zuerst eine Instanz öffnen (Mods-Dialog), dann die Datei dort hineinziehen.' };
}
const inst = store.getInstance(targetId);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const art = targetArt || 'mod';
const dir = store.inhaltsDir(targetId, art);
fs.mkdirSync(dir, { recursive: true });
try {
const name = path.basename(filePath);
const ext2 = path.extname(name);
const base = name.slice(0, -ext2.length);
let dest = path.join(dir, name);
let n = 2;
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext2}`); n++; }
fs.copyFileSync(filePath, dest);
return { ok: true, kind: 'mod', added: [path.basename(dest)] };
} catch (err) {
return { ok: false, message: err.message };
}
}
if (ext === '.mrpack' || ext === '.zip') {
const modpack = await importModpackFromPath(filePath);
// .mrpack ist immer ein Modpack bei Fehlschlag direkt die Meldung
// zeigen, statt es zusätzlich noch als Instanz-Export zu probieren
if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack);
// .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen
const instanceRes = await importInstanceZipFromPath(filePath);
return Object.assign({ kind: 'instance' }, instanceRes);
}
return { ok: false, message: 'Nicht unterstützter Dateityp: ' + (ext || '(ohne Endung)') };
});
/*
* Windows-Benachrichtigung für abgeschlossene Hintergrund-Aktionen (Backup,
* größere Downloads) nur wenn die Einstellung an ist UND das Fenster
* gerade nicht im Vordergrund ist (sonst sieht man's eh schon live).
*/
function notify(title, body) {
try {
if (store.getSettings().desktopNotifications === false) return;
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return;
if (!Notification.isSupported()) return;
const n = new Notification({ title, body, silent: false });
n.on('click', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
n.show();
} catch { /* rein informativ darf nie einen Ablauf stören */ }
}
// ---------------------------------------------------------------------------
// Sicherungen: von Hand und nach Zeitplan
// ---------------------------------------------------------------------------
let backupLaeuft = false;
/*
* Erstellt eine Sicherung aller Instanzen.
* still = kein Fortschritt an die Oberfläche (für den Zeitplan im Hintergrund)
*/
async function erstelleBackup({ still } = {}) {
if (backupLaeuft) return { ok: false, message: 'Es läuft bereits eine Sicherung.' };
backupLaeuft = true;
const destinationPath = backup.resolveAutoBackupPath(app.getPath('documents'));
try {
const result = await backup.createGlobalBackup({
instancesDir: store.getInstancesDir(),
userDataDir: app.getPath('userData'),
includeSettings: true,
destinationPath,
onProgress: (progress) => {
if (!still && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (!still && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' });
}
if (result && result.ok) notify('Backup abgeschlossen', 'Die Sicherung aller Instanzen ist fertig.');
return result;
} catch (err) {
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
} finally {
backupLaeuft = false;
}
}
/*
* Alte automatische Sicherungen wegräumen. Ohne das läuft die Platte voll
* jede Sicherung enthält sämtliche Instanzen.
*/
function raeumeAlteBackups(behalten) {
const anzahl = Math.max(1, Number(behalten) || 5);
try {
const alle = backup.listGlobalBackups(app.getPath('documents')); // neueste zuerst
for (const b of alle.slice(anzahl)) {
try { fs.rmSync(b.path, { force: true }); } catch { /* egal */ }
}
return Math.max(0, alle.length - anzahl);
} catch { return 0; }
}
const BACKUP_ABSTAND = { daily: 24 * 3600 * 1000, weekly: 7 * 24 * 3600 * 1000 };
// Ist laut Einstellungen eine Sicherung fällig?
function backupFaellig(settings, jetzt = Date.now()) {
if (!settings.autoBackupEnabled) return false;
const abstand = BACKUP_ABSTAND[settings.autoBackupEvery] || BACKUP_ABSTAND.daily;
const zuletzt = Date.parse(settings.autoBackupLast || '') || 0;
return jetzt - zuletzt >= abstand;
}
async function backupNachZeitplan(anlass) {
const settings = store.getSettings();
if (!settings.autoBackupEnabled) return null;
if (anlass === 'zeitplan' && !backupFaellig(settings)) return null;
if (backupLaeuft) return null;
const res = await erstelleBackup({ still: true });
if (res && res.ok) {
store.saveSettings({ autoBackupLast: new Date().toISOString() });
const geloescht = raeumeAlteBackups(settings.autoBackupKeep);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:auto', {
ok: true, anlass, datei: res.path || res.destinationPath || '', aufgeraeumt: geloescht,
});
}
} else if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:auto', { ok: false, anlass, message: res && res.message });
}
return res;
}
ipcMain.handle('backup:global', () => erstelleBackup({ still: false }));
ipcMain.handle('backup:list', async () => backup.listGlobalBackups(app.getPath('documents')));
ipcMain.handle('backup:restore', async (_e, sourcePath) => {
if (!sourcePath || !String(sourcePath).trim()) {
return { ok: false, message: 'Kein Backup ausgewählt.' };
}
try {
const currentSettings = store.getSettings();
const result = await backup.restoreGlobalBackup({
sourcePath,
instancesDir: store.getInstancesDir(),
userDataDir: app.getPath('userData'),
includeSettings: true,
instancesDirSetting: currentSettings.instancesDir || null,
onProgress: (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'restore', percent: 100, detail: 'Wiederherstellen abgeschlossen' });
}
return result;
} catch (err) {
return { ok: false, message: 'Wiederherstellen fehlgeschlagen: ' + err.message };
}
});
// Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden).
/*
* Welche Konten spielen gerade? Konto-ID -> Anzahl laufender Spiele.
* Wie bei MultiMC wird das Konto erst beim Start gelesen; ein laufendes Spiel
* behält seines. So kann man das aktive Konto wechseln und mit einem zweiten
* Konto parallel starten.
*/
const spielendeKonten = new Map();
function meldeKontenAenderung() {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('accounts:changed');
}
function kontoSpieltJetzt(kontoId) {
if (!kontoId) return;
spielendeKonten.set(kontoId, (spielendeKonten.get(kontoId) || 0) + 1);
meldeKontenAenderung();
}
function kontoSpieltNichtMehr(kontoId) {
if (!kontoId) return;
const rest = (spielendeKonten.get(kontoId) || 1) - 1;
if (rest > 0) spielendeKonten.set(kontoId, rest);
else spielendeKonten.delete(kontoId);
meldeKontenAenderung();
}
ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!inst.minecraft.version) {
return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' };
}
const settings = store.getSettings();
// Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung
// aussteigt. Greift auch bei von Hand bearbeiteten Instanzdateien.
const speicher = services.pruefeSpeicher(
inst.java.minMemMb || settings.defaultMinMemMb,
inst.java.maxMemMb || settings.defaultMaxMemMb,
gesamtSpeicherMb(),
);
if (!speicher.ok) {
return { ok: false, message: speicher.message + ' Bitte im Bearbeiten-Dialog oder in den Einstellungen anpassen.' };
}
// Auf Wunsch vor dem Start sichern. Bewusst abgewartet eine Sicherung,
// die nebenher läuft, während Minecraft dieselben Dateien anfasst, wäre
// nicht verlässlich.
if (settings.autoBackupEnabled && settings.autoBackupBeforeLaunch) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
}
await backupNachZeitplan('vor-dem-start').catch(() => {});
}
const opts = {
versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '',
gameDir: store.gameDir(id),
sharedDir: path.join(app.getPath('userData'), 'shared'),
userDataDir: app.getPath('userData'),
javaPath: inst.java.path || settings.javaPath || '',
minMemMb: inst.java.minMemMb || settings.defaultMinMemMb,
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
extraArgs: inst.java.extraArgs || '',
preLaunchCommand: inst.preLaunchCommand || settings.globalPreLaunch || '',
postExitCommand: inst.postExitCommand || settings.globalPostExit || '',
wrapperCommand: settings.wrapperCommand || '',
mcMaximized: !!settings.mcMaximized,
mcWidth: settings.mcWidth || 854,
mcHeight: settings.mcHeight || 480,
auth: null,
// 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() : '',
};
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
const clientId = settings.azureClientId || auth.DEFAULT_CLIENT_ID;
const account = accountId
? await auth.getValidAccountById(clientId, accountId)
: await auth.getValidAccount(clientId);
if (account && account.profile) {
opts.auth = {
name: account.profile.name,
uuid: account.profile.id,
accessToken: account.mcAccessToken,
userType: 'msa',
};
}
// Konto dieses Starts merken nicht das aktive, denn das kann inzwischen
// gewechselt worden sein, während dieses Spiel noch läuft.
const kontoId = account && account.profile ? account.profile.id : null;
let gezaehlt = false;
try {
let launchStart = 0;
const res = await launcher.prepareAndLaunch(opts, (evt) => {
if (evt.phase === 'launch') {
launchStart = Date.now();
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
}
if (evt.phase === 'exit') {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
if (launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
launchStart = 0; // Spielzeit nur einmal gutschreiben
const cur = store.getInstance(id);
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
}
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
}
});
// Start fehlgeschlagen, obwohl schon gezählt -> Zählung zurücknehmen,
// sonst bliebe das Konto für immer als "spielt" stehen
if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
}
});
// ---------------------------------------------------------------------------
// IPC Dialoge
// ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFolder', async (_e, title) => {
const res = await dialog.showOpenDialog(mainWindow, {
title: title || 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('dialog:pickImage', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Instanz-Bild wählen',
properties: ['openFile'],
filters: [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }],
});
return res.canceled ? null : res.filePaths[0];
});
// eigenes Bild einer Instanz zuweisen / entfernen
ipcMain.handle('instances:setImage', (_e, id, sourcePath) => {
store.setCustomImage(id, sourcePath);
return { meta: store.getInstance(id), iconData: store.iconDataUri(id) };
});
ipcMain.handle('instances:clearImage', (_e, id) => store.clearCustomImage(id));
ipcMain.handle('instances:icon', (_e, id) => store.iconDataUri(id));
// beliebige Bilddatei als data:-URI (für Vorschau vor dem Speichern)
ipcMain.handle('file:dataUri', (_e, filePath) => {
try {
const stat = fs.statSync(filePath);
if (stat.size > 8 * 1024 * 1024) return null; // >8 MB nicht als Vorschau
let ext = path.extname(filePath).slice(1).toLowerCase();
if (ext === 'jpg') ext = 'jpeg';
const buf = fs.readFileSync(filePath);
return `data:image/${ext};base64,` + buf.toString('base64');
} catch { return null; }
});
ipcMain.handle('dialog:pickJava', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'java.exe wählen',
properties: ['openFile'],
filters: [{ name: 'Java', extensions: ['exe'] }],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('app:openExternal', (_e, url) => {
if (typeof url === 'string' && /^https?:\/\//.test(url)) shell.openExternal(url);
return true;
});
// ---------------------------------------------------------------------------
// IPC Instanzen aus MultiMC/Prism übernehmen
// ---------------------------------------------------------------------------
ipcMain.handle('mmc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'MultiMC-Ordner wählen (der Ordner mit MultiMC.exe)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('mmc:scan', (_e, folder) => {
try { return mmcimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('mmc:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = mmcimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
ipcMain.handle('cf:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('cf:scan', (_e, folder) => {
try { return cfimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('cf:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = cfimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('mc:scan', (_e, folder) => {
try { return mcimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('mc:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = mcimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('technic:scan', (_e, folder) => {
try { return technicimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('technic:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = technicimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
ipcMain.handle('technicPlatform:search', async (_e, query) => {
try { return { ok: true, hits: await technicdiscover.search(query) }; }
catch (err) { return { ok: false, message: err.message }; }
});
ipcMain.handle('technicPlatform:install', async (_e, slug) => {
try {
const res = await technicdiscover.installFromPlatform(store, slug, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p);
});
if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
return res;
} catch (err) {
return { ok: false, message: err.message };
}
});
// ---------------------------------------------------------------------------
// IPC Minecraft Bedrock (Windows-Store-App)
// ---------------------------------------------------------------------------
ipcMain.handle('bedrock:detect', () => bedrock.detect());
ipcMain.handle('bedrock:launch', () => bedrock.launch());
ipcMain.handle('bedrock:worlds', () => bedrock.listWorlds());
ipcMain.handle('bedrock:list', (_e, kind) => bedrock.listFolder(kind));
ipcMain.handle('bedrock:remove', (_e, kind, name) => bedrock.removeEntry(kind, name));
ipcMain.handle('bedrock:openFolder', (_e, kind) => {
const p = bedrock.folderPath(kind);
if (p) { fs.mkdirSync(p, { recursive: true }); shell.openPath(p); }
return true;
});
ipcMain.handle('bedrock:installAddon', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Add-on oder Welt importieren',
properties: ['openFile'],
filters: [{ name: 'Bedrock-Pakete', extensions: ['mcpack', 'mcaddon', 'mcworld', 'mctemplate'] }],
});
if (res.canceled) return { ok: false, canceled: true };
return bedrock.installAddon(res.filePaths[0]);
});
// Weltordner im Explorer zeigen
ipcMain.handle('bedrock:openWorld', (_e, dir) => {
const base = bedrock.folderPath('worlds');
if (base && dir && dir.startsWith(base) && fs.existsSync(dir)) shell.openPath(dir);
return true;
});
// ---------------------------------------------------------------------------
// IPC echte Minecraft-Icons (aus den eigenen Spieldateien)
// ---------------------------------------------------------------------------
function iconDirs() {
const ud = app.getPath('userData');
const parent = path.dirname(ud);
// auch im Datenordner vor der Umbenennung suchen
const candidates = [path.join(ud, 'shared')];
for (const alt of ['viper-network', 'Viper-Network', 'aeromc', 'AeroMC']) {
const p = path.join(parent, alt, 'shared');
if (!candidates.includes(p)) candidates.push(p);
}
return { shared: candidates, out: path.join(ud, 'icons') };
}
ipcMain.handle('icons:list', () => {
const { out } = iconDirs();
return icons.listIcons(out);
});
ipcMain.handle('icons:extract', () => {
const { shared, out } = iconDirs();
const res = icons.extractIcons(shared, out);
if (!res.ok) return { ok: false, reason: res.reason };
return { ok: true, version: res.version, count: res.count, icons: icons.listIcons(out) };
});
// ---------------------------------------------------------------------------
// IPC Automatische Updates
// ---------------------------------------------------------------------------
ipcMain.handle('update:check', async () => {
try { return await updater.checkForUpdate(app.getVersion()); }
catch (err) { return { available: false, error: err.message }; }
});
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
ipcMain.handle('update:install', async (_e, asset) => {
try {
const file = await updater.downloadAndInstall(asset, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update:progress', p);
});
const res = 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 };
} catch (err) {
return { ok: false, message: err.message };
}
});
ipcMain.handle('app:info', () => ({
name: pkg.productName || pkg.build?.productName || pkg.name || 'AeroMC',
version: app.getVersion(),
author: pkg.author || '',
homepage: pkg.homepage || '',
launcherWebsite: pkg.launcherWebsite || '',
}));