Files
AeroMc-Launcher/src/main.js
2026-08-02 21:56:31 +02:00

1670 lines
66 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 } = 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 javadl = require('./javadl');
const crashreports = require('./crashreports');
const skins = require('./skins');
const servers = require('./servers');
// 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;
}
// Startwunsch an die Oberfläche geben dort hängt die gesamte Rückmeldung dran
function starteInstanzImFenster(id) {
if (!id || !mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('instance:launch-request', id);
}
app.on('second-instance', (_e, argv) => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
starteInstanzImFenster(instanzAusArgumenten(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;
}
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() {
mainWindow = new BrowserWindow({
width: 1200,
height: 820,
minWidth: 940,
minHeight: 640,
backgroundColor: '#1b1e24',
title: 'AeroMC',
icon: windowIcon(),
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
mainWindow.loadFile(path.join(__dirname, 'index.html'));
// mit "--launch <id>" gestartet -> Instanz anwerfen, sobald die Oberfläche steht
const startId = instanzAusArgumenten(process.argv);
if (startId) {
mainWindow.webContents.once('did-finish-load', () => starteInstanzImFenster(startId));
}
}
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());
createWindow();
// 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 (info.available && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:available', info);
}
} catch { /* offline o. Ä. still ignorieren */ }
}, 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();
});
// ---------------------------------------------------------------------------
// 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); 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;
}
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;
}
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 };
});
// 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'));
// 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);
});
// 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 };
}
});
// ---- 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,
}),
});
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);
}
return result;
} catch (err) {
return { ok: false, message: 'Modpack-Installation 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 };
try {
const buf = fs.readFileSync(res.filePaths[0]);
const zip = forge.readZipIndex(buf);
if (zip['modrinth.index.json']) return await importMrpack(buf, zip);
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.' };
}
return await importCurseForge(buf, zip, zugang);
}
return { ok: false, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
} catch (err) {
return { ok: false, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
}
});
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
ipcMain.handle('instances:shortcut', (_e, id) => {
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 name = String(inst.name || 'Instanz').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: `--launch="${id}"`,
cwd: path.dirname(ziel),
description: 'AeroMC ' + inst.name,
icon: 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 };
}
});
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 };
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: res.filePaths[0], 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 */ }
}
});
// ---------------------------------------------------------------------------
// 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' });
}
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 { done.push(mmcimport.importOne(store, entry, copyData !== false)); }
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 { done.push(cfimport.importOne(store, entry, copyData !== false)); }
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 { done.push(mcimport.importOne(store, entry, copyData !== false)); }
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 };
});
// ---------------------------------------------------------------------------
// 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 || '',
gitpage: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''),
}));