Upload via GUI (39 Dateien)

This commit is contained in:
Git Manager GUI
2026-08-02 17:01:18 +02:00
parent a803f297ea
commit eff6403dd5
26 changed files with 3536 additions and 134 deletions
+519 -17
View File
@@ -9,6 +9,7 @@
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');
@@ -24,6 +25,10 @@ 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');
@@ -76,6 +81,48 @@ function applyProxy(s) {
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();
function instanzAusArgumenten(argv) {
const args = argv || [];
for (let i = 0; i < args.length; i++) {
const a = String(args[i]);
if (a === '--launch' && args[i + 1]) return String(args[i + 1]);
if (a.startsWith('--launch=')) return a.slice('--launch='.length);
}
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');
@@ -103,11 +150,20 @@ function createWindow() {
});
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; } },
@@ -128,6 +184,14 @@ app.whenReady().then(() => {
} 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 {
@@ -255,6 +319,146 @@ ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
ipcMain.handle('mods:remove', (_e, id, filename) => store.removeMod(id, filename));
// 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);
});
// 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) => {
const res = store.setModEnabled(id, filename, !!enabled);
if (res.ok) return res;
const gruende = {
'not-found': 'Die Datei liegt nicht mehr im mods-Ordner.',
'both-exist': 'Es liegen beide Fassungen im mods-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) => {
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), gameVersion, loader, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
});
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) => {
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 modsDir = store.modsDir(id);
const gewuenscht = new Set(projectIds || []);
const erneuert = [];
const fehler = [];
for (const mod of store.listMods(id)) {
if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue;
try {
const res = await services.modrinthInstall({
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir,
});
if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; }
store.addMod(id, res.entry);
// 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);
// war die Mod abgeschaltet, bleibt sie es auch nach dem Update
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false);
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)
// ---------------------------------------------------------------------------
@@ -262,8 +466,59 @@ ipcMain.handle('mods:remove', (_e, id, filename) => store.removeMod(id, filename
ipcMain.handle('auth:status', () => auth.currentAccount());
ipcMain.handle('auth:health', () => authHealth);
ipcMain.handle('auth:recheck', () => checkAuthServices());
ipcMain.handle('auth:accounts', () => auth.listAccounts());
// "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; });
@@ -454,6 +709,86 @@ async function importMrpack(buf, zip) {
return { ok: true, instance: store.getInstance(created.id), fileCount: files.length };
}
// ---- 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:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Modpack importieren',
@@ -466,7 +801,12 @@ ipcMain.handle('modpack:import', async () => {
const zip = forge.readZipIndex(buf);
if (zip['modrinth.index.json']) return await importMrpack(buf, zip);
if (zip['manifest.json']) {
return { ok: false, message: 'CurseForge-Modpacks brauchen einen CurseForge-API-Key und werden noch nicht unterstützt. Bitte ein Modrinth-.mrpack verwenden.' };
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) {
@@ -474,6 +814,32 @@ ipcMain.handle('modpack:import', async () => {
}
});
// 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,
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',
@@ -494,8 +860,15 @@ ipcMain.handle('instances:import', async () => {
.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'))) {
throw new Error('Keine gültige Instanz im Archiv (instance.json fehlt).');
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({
@@ -522,7 +895,19 @@ ipcMain.handle('instances:import', async () => {
}
});
ipcMain.handle('backup:global', async () => {
// ---------------------------------------------------------------------------
// 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({
@@ -531,17 +916,67 @@ ipcMain.handle('backup:global', async () => {
includeSettings: true,
destinationPath,
onProgress: (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
if (!still && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (mainWindow && !mainWindow.isDestroyed()) {
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')));
@@ -571,6 +1006,32 @@ ipcMain.handle('backup:restore', async (_e, sourcePath) => {
});
// 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) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
@@ -578,6 +1039,28 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
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',
@@ -596,6 +1079,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
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),
};
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
@@ -612,22 +1097,38 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
};
}
// 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 (evt.phase === 'exit' && launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
const cur = store.getInstance(id);
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
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 };
}
});
@@ -745,7 +1246,7 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
ipcMain.handle('mc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Minecraft-Launcher-Ordner waehlen (meist .minecraft)',
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
@@ -839,15 +1340,16 @@ ipcMain.handle('update:check', async () => {
catch (err) { return { available: false, error: err.message }; }
});
// Ein Klick: herunterladen, prüfen, Installer starten und Launcher beenden
// 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);
});
updater.runInstaller(file.path);
setTimeout(() => app.quit(), 1200); // Installer übernimmt, Launcher schließt sich
return { ok: true, path: file.path };
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 };
}