1670 lines
66 KiB
JavaScript
1670 lines
66 KiB
JavaScript
'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 || ''),
|
||
}));
|