Upload via GUI (47 Dateien)

This commit is contained in:
2026-09-27 18:17:44 +00:00
parent 3b54217989
commit ee0e6dec32
9 changed files with 1706 additions and 163 deletions
+402 -96
View File
@@ -6,7 +6,7 @@
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard } = require('electron');
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard, desktopCapturer, session, screen } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
@@ -85,6 +85,274 @@ function applyProxy(s) {
let mainWindow = null;
// ---------------------------------------------------------------------------
// Popup-Fenster: alle großen Dialoge als eigene, frei verschiebbare Fenster
// (native Titelleiste ersetzt durch den eigenen dunklen Kopfbereich) statt
// als Modal im Hauptfenster. Laden dieselbe index.html mit z. B.
// "?popout=mods&instanceId=..." – der Renderer blendet dann nur diesen einen
// Dialog vollflächig ein.
// ---------------------------------------------------------------------------
const popoutWindows = new Map(); // modalId -> BrowserWindow
const POPOUT_SPEC = {
settings: { title: 'AeroMC – Einstellungen', width: 980, height: 720, minWidth: 700, minHeight: 500 },
console: { title: 'AeroMC – Konsole / Log', width: 860, height: 680, minWidth: 640, minHeight: 480 },
mods: { title: 'AeroMC – Mods', width: 900, height: 700, minWidth: 640, minHeight: 480 },
crash: { title: 'AeroMC – Absturzbericht', width: 860, height: 680, minWidth: 640, minHeight: 480 },
logs: { title: 'AeroMC – Logdateien', width: 860, height: 680, minWidth: 640, minHeight: 480 },
content: { title: 'AeroMC – Welten & Pakete', width: 860, height: 680, minWidth: 640, minHeight: 480 },
screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 },
servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 },
skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 },
bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 },
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 },
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 },
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
};
const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 };
let pendingUpdateInfo = null; // letztes Ergebnis von update:check/checkForUpdate, für Update-Popup
// an Hauptfenster UND alle offenen Popup-Fenster senden (Start-Fortschritt
// muss z. B. auch im herausgelösten Konsolen-Fenster ankommen)
function broadcast(channel, payload) {
for (const w of [mainWindow, ...popoutWindows.values()]) {
if (w && !w.isDestroyed()) w.webContents.send(channel, payload);
}
}
function openPopout(modalId, params) {
let win = popoutWindows.get(modalId);
if (win && !win.isDestroyed()) {
win.show();
win.focus();
// erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
// laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
win.webContents.send('popout:reopen', params || {});
return;
}
const query = { popout: modalId };
for (const [k, v] of Object.entries(params || {})) if (v !== undefined && v !== null) query[k] = String(v);
const spec = POPOUT_SPEC[modalId] || POPOUT_DEFAULT;
win = new BrowserWindow({
width: spec.width,
height: spec.height,
minWidth: spec.minWidth,
minHeight: spec.minHeight,
title: spec.title || POPOUT_DEFAULT.title,
backgroundColor: '#1b1e24',
icon: windowIcon(),
frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
popoutWindows.set(modalId, win);
win.loadFile(path.join(__dirname, 'index.html'), { query });
win.once('ready-to-show', () => win.show());
win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
});
win.on('close', (e) => {
if (appIsQuitting) return;
e.preventDefault();
// Läuft gerade eine Aufzeichnung, darf ein natives Schließen (Alt+F4,
// Taskleiste) sie nicht stillschweigend verstecken -> der Renderer
// entscheidet selbst (Stoppen+Speichern oder Abbrechen)
if (modalId === 'record' && recordingGuardActive) {
win.webContents.send('record:closeAttempt');
return;
}
win.hide();
});
win.on('closed', () => { popoutWindows.delete(modalId); });
}
let recordingGuardActive = false;
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params));
ipcMain.on('popout:hide', (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w && !w.isDestroyed()) w.hide();
});
ipcMain.handle('update:getPending', () => pendingUpdateInfo);
// ---------------------------------------------------------------------------
// Spielaufzeichnung: Fenster-Erkennung per Fenstertitel (kein PID-Tracking
// nötig dafür - funktioniert unabhängig von Konto/settings.wrapperCommand).
// Video kommt vom Fenster, Systemton separat vom Bildschirm (Chromium erlaubt
// keine Pro-Fenster-Tonaufnahme) - siehe renderer.js für den Aufnahme-Teil.
// Die PID wird NUR für die optionale isolierte "Nur Minecraft"-Tonspur
// gebraucht (per-Prozess-Aufnahme via loopback-capture, siehe unten).
// ---------------------------------------------------------------------------
const letzteQuickPlayServer = new Map(); // instanceId -> Server-Adresse
// Minecraft schreibt diese Zeile bei JEDER Verbindung zu einem Server ins Log
// - egal ob per Quick Play oder von Hand im Spiel verbunden, und egal ob das
// Aufzeichnungs-Fenster dabei schon offen war. Hier (statt im Renderer)
// verfolgt, weil das hier zentral für alle Fenster gilt, auch für ein erst
// SPÄTER geöffnetes Aufzeichnungs-Popup, das die Zeile sonst verpasst hätte.
const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i;
ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null);
const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
// Welche Bildschirmquelle als Nächstes über getDisplayMedia erfasst werden
// soll - vom Renderer kurz vor dem eigentlichen Aufruf gesetzt (siehe
// setDisplayMediaRequestHandler weiter unten). Für den cursorfreien
// Erfassungsversuch: ganzer Bildschirm ohne Mauszeiger, der Zuschnitt aufs
// Minecraft-Fenster passiert danach per Canvas im Renderer (siehe
// recording:getWindowBounds). Schlägt das fehl, läuft die Erfassung über die
// alte, zuverlässige chromeMediaSource-Fenster-Methode (Mauszeiger sichtbar).
let ausstehendeErfassungsQuelle = null;
ipcMain.handle('recording:setCaptureTarget', (_e, sourceId) => { ausstehendeErfassungsQuelle = sourceId; });
// Sucht das Fenster des laufenden Minecraft-Prozesses (über die PID aus
// laufendePid, robuster als ein Titel-Abgleich) und ermittelt daraus:
// - die aktuellen Fenstergrenzen in Bildschirmkoordinaten
// - den Bildschirm, auf dem das Fenster gerade sitzt (Mehrschirm-fähig)
// - die zu diesem Bildschirm gehörende Erfassungsquelle für getDisplayMedia
// Liefert null, wenn das Fenster nicht gefunden wird (z. B. Minecraft nicht
// über den Launcher gestartet, oder bereits beendet) - der Renderer fällt in
// dem Fall auf die alte Fenster-Erfassung zurück statt abzustürzen.
async function findeFensterUndBildschirm(pid, fensterTitel) {
let windowManager;
try { ({ windowManager } = require('node-window-manager')); }
catch (err) { console.log('[Aufzeichnung] node-window-manager Fehler:', err.message); return null; }
let alleFenster;
try { alleFenster = windowManager.getWindows(); }
catch (err) { console.log('[Aufzeichnung] getWindows Fehler:', err.message); return null; }
// Die PID kennt der Launcher nur für Prozesse, die er in DIESER laufenden
// Sitzung selbst gestartet hat - nach einem Neustart des Launchers (z. B.
// während Minecraft weiterläuft) ist laufendePid leer, obwohl das Spiel
// noch läuft. Dann über den EXAKTEN Fenstertitel suchen, den der Renderer
// schon über recording:listWindows (dort per /Minecraft/i gefiltert und
// vom Nutzer im Suchdialog bestätigt) positiv identifiziert hat - NICHT
// erneut breit nach /Minecraft/i suchen, das kann auf einem Rechner mit
// vielen offenen Fenstern (Editor, Browser-Tabs usw.) ein völlig falsches,
// unbeteiligtes Fenster treffen und dessen Inhalt zeigen/aufnehmen.
let fenster = pid ? alleFenster.find((w) => w.processId === pid) : null;
if (!fenster && fensterTitel) {
fenster = alleFenster.find((w) => { try { return w.getTitle() === fensterTitel; } catch { return false; } });
}
if (!fenster) { console.log('[Aufzeichnung] kein Minecraft-Fenster gefunden (PID', pid, ', Titel', fensterTitel, ')'); return null; }
const fensterBounds = fenster.getBounds();
if (!fensterBounds || !fensterBounds.width || !fensterBounds.height) {
console.log('[Aufzeichnung] ungültige Fenstergrenzen:', JSON.stringify(fensterBounds));
return null;
}
const anzeige = screen.getDisplayMatching(fensterBounds);
const screenSources = await desktopCapturer.getSources({ types: ['screen'] });
const quelle = screenSources.find((s) => String(s.display_id) === String(anzeige.id)) || screenSources[0];
if (!quelle) { console.log('[Aufzeichnung] keine Bildschirmquelle gefunden'); return null; }
console.log('[Aufzeichnung] gefunden:', JSON.stringify({ fensterBounds, bildschirmBounds: anzeige.bounds, screenSourceId: quelle.id }));
return {
fensterBounds,
bildschirmBounds: anzeige.bounds,
screenSourceId: quelle.id,
};
}
ipcMain.handle('recording:getWindowBounds', async (_e, instanceId, fensterTitel) => {
const pid = laufendePid.get(instanceId);
return findeFensterUndBildschirm(pid, fensterTitel);
});
// Isolierte Ton-Aufnahme nur eines Prozesses (inkl. Kindprozesse) über
// Windows' Process-Loopback-API. Nur eine Aufnahme gleichzeitig aktiv.
let aktiveSpielAudioCapture = null;
ipcMain.handle('recording:startGameAudio', (event, pid) => {
if (aktiveSpielAudioCapture) return { ok: false, message: 'Läuft bereits.' };
let loopback;
try {
loopback = require('loopback-capture');
} catch (err) {
return { ok: false, message: 'Spielton-Isolierung nicht verfügbar: ' + err.message };
}
const win = BrowserWindow.fromWebContents(event.sender);
try {
const capture = new loopback.LoopbackCapture();
capture.start(pid, true, (chunk) => {
if (win && !win.isDestroyed()) win.webContents.send('recording:gameAudioChunk', new Uint8Array(chunk));
});
aktiveSpielAudioCapture = { capture };
return { ok: true };
} catch (err) {
return { ok: false, message: 'Spielton-Aufnahme fehlgeschlagen (Windows 10 Version 2004+ erforderlich, oder Spiel bereits beendet): ' + err.message };
}
});
ipcMain.handle('recording:stopGameAudio', () => {
if (aktiveSpielAudioCapture) {
try { aktiveSpielAudioCapture.capture.stop(); } catch { /* egal, wird verworfen */ }
aktiveSpielAudioCapture = null;
}
return true;
});
ipcMain.handle('recording:listWindows', async () => {
const sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 200, height: 120 },
});
return sources
.filter((s) => /Minecraft/i.test(s.name))
.map((s) => ({
id: s.id,
name: s.name,
thumbnail: s.thumbnail && !s.thumbnail.isEmpty() ? s.thumbnail.toDataURL() : null,
}));
});
// Für den Systemton reicht irgendeine Bildschirmquelle als Pflichtfeld für
// getUserMedia -- der Ton ist ohnehin nicht pro Bildschirm getrennt.
ipcMain.handle('recording:getAudioSourceId', async () => {
const sources = await desktopCapturer.getSources({ types: ['screen'] });
return (sources[0] && sources[0].id) || null;
});
function aufzeichnungsOrdner(instanceName) {
return path.join(app.getPath('videos'), 'AeroMC Launcher',
String(instanceName || 'Instanz').replace(/[^\w.-]+/g, '_'));
}
ipcMain.handle('recording:save', async (_e, instanceName, buffer, serverLabel) => {
try {
const zielOrdner = aufzeichnungsOrdner(instanceName);
fs.mkdirSync(zielOrdner, { recursive: true });
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
const serverTeil = serverLabel ? String(serverLabel).replace(/[^\w.-]+/g, '_') + '_' : '';
let zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}.webm`);
let n = 2;
while (fs.existsSync(zielDatei)) { zielDatei = path.join(zielOrdner, `${serverTeil}${zeitstempel}_${n}.webm`); n++; }
fs.writeFileSync(zielDatei, Buffer.from(buffer));
notify('Aufzeichnung gespeichert', `${instanceName}: ${path.basename(zielDatei)}`);
return { ok: true, path: zielDatei };
} catch (err) {
return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message };
}
});
ipcMain.handle('recording:openFolder', (_e, instanceName) => {
const dir = aufzeichnungsOrdner(instanceName);
fs.mkdirSync(dir, { recursive: true });
shell.openPath(dir);
return true;
});
// ---------------------------------------------------------------------------
// Verknüpfungen: "AeroMC.exe --launch <id>" startet eine Instanz direkt
// ---------------------------------------------------------------------------
@@ -217,7 +485,12 @@ function createWindow(startHidden) {
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
// Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist.
mainWindow.on('close', (e) => {
if (appIsQuitting || !store.getSettings().autostart) return;
if (appIsQuitting || !store.getSettings().autostart) {
// Hauptfenster schließt wirklich -> keine nur versteckten Popup-Fenster
// zurücklassen (die sonst window-all-closed nie erreichen ließen)
for (const w of popoutWindows.values()) if (!w.isDestroyed()) w.destroy();
return;
}
e.preventDefault();
mainWindow.hide();
});
@@ -306,6 +579,17 @@ app.whenReady().then(() => {
const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden);
// Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog:
// das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
// hier direkt zurückgegeben, statt den Nutzer nochmal wählen zu lassen
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
const quelle = sources.find((s) => s.id === ausstehendeErfassungsQuelle) || sources[0];
console.log('[Aufzeichnung] setDisplayMediaRequestHandler: angefordert=', ausstehendeErfassungsQuelle, 'gewählt=', quelle && quelle.id);
callback(quelle ? { video: quelle } : {});
});
}, { useSystemPicker: false });
// Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung
setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800);
@@ -329,17 +613,14 @@ app.whenReady().then(() => {
setTimeout(async () => {
try {
const info = await updater.checkForUpdate(app.getVersion());
if (mainWindow && !mainWindow.isDestroyed()) {
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
// kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt
mainWindow.webContents.send('update:checked', info);
if (info.available) mainWindow.webContents.send('update:available', info);
}
pendingUpdateInfo = info;
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
// kann – nicht erst wenn der Nutzer manuell auf "Suchen" klickt
broadcast('update:checked', info);
if (info.available) broadcast('update:available', info);
} catch (err) {
// offline o. Ä. – kein Absturz, aber die Info-Seite darf den Fehler zeigen
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) });
}
broadcast('update:checked', { error: (err && err.message) || String(err) });
}
}, 4000);
@@ -351,7 +632,7 @@ app.whenReady().then(() => {
const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
auth.getValidAccount(cid)
.then((acc) => {
if (acc && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:refreshed');
if (acc) broadcast('auth:refreshed');
})
.catch(() => { /* offline o. Ä. – Konto bleibt gespeichert */ });
@@ -393,6 +674,15 @@ ipcMain.handle('settings:save', (_e, patch) => {
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s);
// Einstellungen können jetzt auch aus dem Popup-Fenster gespeichert werden –
// das Hauptfenster muss Theme/Sortierung/Bedrock-Sichtbarkeit/Skin-Hintergrund
// dann selbst nachziehen. NICHT bei der beiläufigen "letzte Instanz"-Notiz
// (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das
// Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert.
const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId');
if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('settings:changed', s);
}
return s;
});
@@ -522,14 +812,14 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
* per Hand zugeschickt). Die Datei taucht danach ganz normal in der
* "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt.
*/
ipcMain.handle('mods:addFile', async (_e, id, art) => {
ipcMain.handle('mods:addFile', async (e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const filters = (art || 'mod') === 'mod'
? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }]
: [{ name: typ.label || 'Datei', extensions: ['zip'] }];
const res = await dialog.showOpenDialog(mainWindow, {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Datei hinzufügen',
properties: ['openFile', 'multiSelections'],
filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]),
@@ -629,7 +919,7 @@ ipcMain.handle('optifine:setup', async (_e, id) => {
let prep;
try {
prep = await launcher.prepareAndLaunch(opts, (evt) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
broadcast('launch:progress', Object.assign({ id }, evt));
});
} catch (err) {
return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message };
@@ -796,7 +1086,7 @@ ipcMain.handle('mods:checkUpdates', async (_e, id, art) => {
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);
broadcast('mods:updateProgress', p);
}, art || 'mod');
return { ok: true, ...res };
} catch (err) {
@@ -864,8 +1154,8 @@ ipcMain.handle('skin:profile', async () => {
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
});
ipcMain.handle('skin:choose', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('skin:choose', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Skin auswählen',
properties: ['openFile'],
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
@@ -886,14 +1176,22 @@ ipcMain.handle('skin:choose', async () => {
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); }
try {
const res = await skins.setzeSkin(token, dateiPfad, variante);
if (res && res.ok) broadcast('skin:changed');
return res;
}
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); }
try {
const res = await skins.setzeSkinZurueck(token);
if (res && res.ok) broadcast('skin:changed');
return res;
}
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
@@ -905,7 +1203,7 @@ ipcMain.handle('auth:login', async () => {
const account = await auth.login(
clientId,
(code) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:code', code);
broadcast('auth:code', code);
},
() => {},
);
@@ -923,9 +1221,17 @@ 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:create', (_e, data) => {
const res = store.createInstance(data);
broadcast('instances:changed');
return res;
});
ipcMain.handle('instances:update', (_e, id, patch) => store.updateInstance(id, patch));
ipcMain.handle('instances:update', (_e, id, patch) => {
const res = store.updateInstance(id, patch);
broadcast('instances:changed');
return res;
});
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
@@ -1121,9 +1427,7 @@ async function spieleMrpackEin(buf, zip, index, gameDir, { schonePlayerDaten })
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' });
}
broadcast('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;
@@ -1231,9 +1535,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
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 …' });
}
broadcast('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());
@@ -1276,6 +1578,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
}),
});
broadcast('instances:changed');
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
return {
ok: true,
@@ -1317,9 +1620,7 @@ async function importCurseForge(buf, zip, zugang) {
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 });
}
broadcast('launch:progress', { phase: 'modpack', current, total, detail });
};
// 1) Dateiliste über die CurseForge-API auflösen
@@ -1388,11 +1689,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
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 …',
});
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
const file = await services.modrinthResolveModpack({ projectId, gameVersion });
if (!file) {
return {
@@ -1402,12 +1699,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
: 'Keine Modpack-Version gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack',
detail: 'Lade ' + (file.filename || file.name || 'Modpack') + ' …',
});
}
broadcast('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']) {
@@ -1427,6 +1719,7 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
result.instance = store.getInstance(inst.id);
}
if (result && result.ok && result.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1446,11 +1739,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
const modId = Number(rawId);
if (!modId) 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 …',
});
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion);
if (!file || !file.url) {
return {
@@ -1460,11 +1749,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
: 'Keine passende Modpack-Datei gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Lade ' + file.filename + ' …',
});
}
broadcast('launch:progress', { phase: 'modpack', detail: 'Lade ' + file.filename + ' …' });
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen);
const zip = forge.readZipIndex(buf);
@@ -1473,6 +1758,7 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
}
const result = await importCurseForge(buf, zip, zugang);
if (result && result.ok && result.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1505,6 +1791,7 @@ async function importModpackFromPath(filePath) {
return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
}
if (result && result.ok && result.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
@@ -1513,8 +1800,8 @@ async function importModpackFromPath(filePath) {
}
}
ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('modpack:import', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Modpack importieren',
properties: ['openFile'],
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
@@ -1643,14 +1930,16 @@ async function importInstanceZipFromPath(filePath) {
}
}
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('instances:import', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
return importInstanceZipFromPath(res.filePaths[0]);
const result = await importInstanceZipFromPath(res.filePaths[0]);
if (result && result.ok) broadcast('instances:changed');
return result;
});
/*
@@ -1696,6 +1985,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack);
// .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen
const instanceRes = await importInstanceZipFromPath(filePath);
if (instanceRes && instanceRes.ok) broadcast('instances:changed');
return Object.assign({ kind: 'instance' }, instanceRes);
}
@@ -1710,7 +2000,7 @@ ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
function notify(title, body) {
try {
if (store.getSettings().desktopNotifications === false) return;
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return;
if (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isFocused())) return;
if (!Notification.isSupported()) return;
const n = new Notification({ title, body, silent: false });
n.on('click', () => {
@@ -1844,7 +2134,7 @@ ipcMain.handle('backup:restore', async (_e, sourcePath) => {
const spielendeKonten = new Map();
function meldeKontenAenderung() {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('accounts:changed');
broadcast('accounts:changed');
}
function kontoSpieltJetzt(kontoId) {
@@ -1862,6 +2152,12 @@ function kontoSpieltNichtMehr(kontoId) {
}
ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// für die Spielaufzeichnung merken, mit welchem Server (Quick Play) diese
// Instanz zuletzt gestartet wurde -- muss im Hauptprozess liegen, weil
// Serverliste und Aufzeichnung zwei getrennte Popup-Fenster mit jeweils
// eigenem Renderer-Speicher sind
if (launchOpts && launchOpts.quickPlayServer) letzteQuickPlayServer.set(id, launchOpts.quickPlayServer);
else letzteQuickPlayServer.delete(id);
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!inst.minecraft.version) {
@@ -1884,9 +2180,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// 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 …' });
}
broadcast('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
await backupNachZeitplan('vor-dem-start').catch(() => {});
}
@@ -1947,6 +2241,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (evt.phase === 'exit') {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
discordpresence.clearActivity();
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
if (launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
launchStart = 0; // Spielzeit nur einmal gutschreiben
@@ -1954,14 +2249,23 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
}
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
if (evt.phase === 'log') {
const m = VERBINDUNGS_ZEILE.exec(evt.line);
if (m) {
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
letzteQuickPlayServer.set(id, server);
// damit ein schon offenes Aufzeichnungs-Fenster den Servernamen im
// Titel nachträgt, auch wenn es VOR dem Verbinden geöffnet wurde
broadcast('recording:serverDetected', { id, server });
}
}
broadcast('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); discordpresence.clearActivity(); }
if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
if (res.ok && res.pid) laufendePid.set(id, res.pid);
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
@@ -1973,8 +2277,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
// IPC – Dialoge
// ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFolder', async (_e, title) => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('dialog:pickFolder', async (e, title) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: title || 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
});
@@ -2010,8 +2314,8 @@ ipcMain.handle('file:dataUri', (_e, filePath) => {
} catch { return null; }
});
ipcMain.handle('dialog:pickJava', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('dialog:pickJava', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'java.exe wählen',
properties: ['openFile'],
filters: [{ name: 'Java', extensions: ['exe'] }],
@@ -2028,8 +2332,8 @@ ipcMain.handle('app:openExternal', (_e, url) => {
// IPC – Instanzen aus MultiMC/Prism übernehmen
// ---------------------------------------------------------------------------
ipcMain.handle('mmc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('mmc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'MultiMC-Ordner wählen (der Ordner mit MultiMC.exe)',
properties: ['openDirectory'],
});
@@ -2050,15 +2354,14 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mmc:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('cf:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('cf:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)',
properties: ['openDirectory'],
});
@@ -2079,15 +2382,14 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
properties: ['openDirectory'],
});
@@ -2108,15 +2410,14 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('mc:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('technic:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)',
properties: ['openDirectory'],
});
@@ -2137,10 +2438,9 @@ ipcMain.handle('technic:import', (_e, entries, copyData) => {
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length });
}
broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
@@ -2154,9 +2454,12 @@ ipcMain.handle('technicPlatform:search', async (_e, query) => {
ipcMain.handle('technicPlatform:install', async (_e, slug) => {
try {
const res = await technicdiscover.installFromPlatform(store, slug, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p);
broadcast('technicPlatform:progress', p);
});
if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
if (res.ok && res.instance) {
broadcast('instances:changed');
notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
}
return res;
} catch (err) {
return { ok: false, message: err.message };
@@ -2179,8 +2482,8 @@ ipcMain.handle('bedrock:openFolder', (_e, kind) => {
return true;
});
ipcMain.handle('bedrock:installAddon', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('bedrock:installAddon', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Add-on oder Welt importieren',
properties: ['openFile'],
filters: [{ name: 'Bedrock-Pakete', extensions: ['mcpack', 'mcaddon', 'mcworld', 'mctemplate'] }],
@@ -2229,15 +2532,18 @@ ipcMain.handle('icons:extract', () => {
// ---------------------------------------------------------------------------
ipcMain.handle('update:check', async () => {
try { return await updater.checkForUpdate(app.getVersion()); }
catch (err) { return { available: false, error: err.message }; }
try {
const info = await updater.checkForUpdate(app.getVersion());
pendingUpdateInfo = info;
return info;
} 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);
broadcast('update:progress', p);
});
const res = updater.applyUpdate(file);
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt