Upload via GUI (39 Dateien)
This commit is contained in:
+519
-17
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user